blob: 964c87d9cd8f522ba3faadf4f15ec320f4856c8e [file]
import {
requireCount,
requireEncodedByteLimit,
requireEntityId,
requireExactRecord,
requireId,
requireRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
import { defineOperation } from './operation-spec.js';
export const SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES = 16 * 1024;
export const SESSION_TRANSCRIPT_PAGE_MAX_BYTES = 512 * 1024;
export const SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES = 256;
export const SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES = 4_096;
export const SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES = 744 * 1024;
export const SESSION_TRANSCRIPT_CURSOR_MAX_BYTES = 1024;
export type SessionTranscriptPageSource = 'durable' | 'overlay';
export type SessionTranscriptPageDirection = 'older' | 'newer';
export type SessionTranscriptFragment =
| {
readonly kind: 'durable';
readonly sequence: number;
readonly byteOffset: number;
readonly totalBytes: number;
readonly payloadDigest: `sha256:${string}` | null;
readonly data: string;
}
| {
readonly kind: 'overlay';
readonly messageIndex: number;
readonly byteOffset: number;
readonly totalBytes: number;
readonly data: string;
};
export interface SessionTranscriptPage {
readonly kind: 'page';
readonly sessionId: string;
readonly source: SessionTranscriptPageSource;
readonly direction: SessionTranscriptPageDirection;
readonly throughSequence: number | null;
readonly rawBytes: number;
readonly fragments: readonly SessionTranscriptFragment[];
readonly nextCursor: string | null;
}
export interface SessionTranscriptBootstrap {
readonly throughSequence: number | null;
readonly overlayMessageCount: number;
readonly durable: SessionTranscriptPage;
readonly overlay: SessionTranscriptPage;
}
export interface SessionTranscriptPageInput {
readonly subscriptionId: string;
readonly source: SessionTranscriptPageSource;
readonly direction: SessionTranscriptPageDirection;
readonly throughSequence: number | null;
readonly cursor: string | null;
readonly anchorSequence: number | null;
readonly maxBytes: number;
}
export interface SessionTranscriptOverlayReleaseInput {
readonly subscriptionId: string;
}
export interface SessionTranscriptOverlayReleaseResult {
readonly subscriptionId: string;
}
const QUERY_ERRORS = [
'host_not_ready',
'host_draining',
'operation_unavailable',
'invalid_request',
'not_found',
'operation_conflict',
'persistence_failed',
'internal_failure',
] as const;
export const SESSION_TRANSCRIPT_OPERATION_SPECS = {
'session.transcript.page': defineOperation({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeSessionTranscriptPageInput,
decodeOutput: decodeSessionTranscriptPage,
assertOutputForInput: assertSessionTranscriptPageOutput,
}),
'session.transcript.overlay.release': defineOperation({
mode: 'control',
availability: 'ready',
errors: QUERY_ERRORS,
decodeInput: decodeSessionTranscriptOverlayReleaseInput,
decodeOutput: decodeSessionTranscriptOverlayReleaseResult,
assertOutputForInput: (input, output) => {
if (input.subscriptionId !== output.subscriptionId) {
throw invalidProtocolFrame('Session transcript overlay release identity changed');
}
},
}),
} as const;
function decodeSessionTranscriptOverlayReleaseInput(
value: unknown,
): SessionTranscriptOverlayReleaseInput {
const input = requireExactRecord(value, 'Session transcript overlay release input', [
'subscriptionId',
]);
return { subscriptionId: requireId(input.subscriptionId, 'subscriptionId') };
}
function decodeSessionTranscriptOverlayReleaseResult(
value: unknown,
): SessionTranscriptOverlayReleaseResult {
const result = requireExactRecord(value, 'Session transcript overlay release result', [
'subscriptionId',
]);
return { subscriptionId: requireId(result.subscriptionId, 'subscriptionId') };
}
export function decodeSessionTranscriptPageInput(value: unknown): SessionTranscriptPageInput {
const input = requireExactRecord(value, 'Session transcript page input', [
'subscriptionId',
'source',
'direction',
'throughSequence',
'cursor',
'anchorSequence',
'maxBytes',
]);
const cursor =
input.cursor === null
? null
: requireUtf8String(
input.cursor,
'Session transcript cursor',
SESSION_TRANSCRIPT_CURSOR_MAX_BYTES,
);
const anchorSequence =
input.anchorSequence === null
? null
: requireCount(input.anchorSequence, 'Session transcript anchor sequence');
if (cursor !== null && anchorSequence !== null) {
throw invalidProtocolFrame('Session transcript cursor and anchor are mutually exclusive');
}
return {
subscriptionId: requireId(input.subscriptionId, 'subscriptionId'),
source: decodeSource(input.source),
direction: decodeDirection(input.direction),
throughSequence:
input.throughSequence === null
? null
: requireCount(input.throughSequence, 'Session transcript watermark'),
cursor,
anchorSequence,
maxBytes: requirePageByteLimit(input.maxBytes),
};
}
export function decodeSessionTranscriptBootstrap(value: unknown): SessionTranscriptBootstrap {
const bootstrap = requireExactRecord(value, 'Session transcript bootstrap', [
'throughSequence',
'overlayMessageCount',
'durable',
'overlay',
]);
const throughSequence =
bootstrap.throughSequence === null
? null
: requireCount(bootstrap.throughSequence, 'Session transcript watermark');
const overlayMessageCount = requireCount(
bootstrap.overlayMessageCount,
'Session transcript overlay message count',
);
if (overlayMessageCount > SESSION_TRANSCRIPT_OVERLAY_MAX_MESSAGES) {
throw invalidProtocolFrame('Session transcript overlay exceeds its message limit');
}
const durable = decodeSessionTranscriptPage(bootstrap.durable);
const overlay = decodeSessionTranscriptPage(bootstrap.overlay);
if (
durable.source !== 'durable' ||
durable.direction !== 'older' ||
overlay.source !== 'overlay' ||
overlay.direction !== 'older' ||
durable.throughSequence !== throughSequence ||
overlay.throughSequence !== throughSequence
) {
throw invalidProtocolFrame('Invalid Session transcript bootstrap correlation');
}
if (durable.rawBytes + overlay.rawBytes > SESSION_TRANSCRIPT_BOOTSTRAP_MAX_BYTES) {
throw invalidProtocolFrame('Session transcript bootstrap exceeds byte limit');
}
return { throughSequence, overlayMessageCount, durable, overlay };
}
export function decodeSessionTranscriptPage(value: unknown): SessionTranscriptPage {
requireEncodedByteLimit(
value,
'Session transcript page result',
SESSION_TRANSCRIPT_PAGE_RESULT_MAX_BYTES,
);
const result = requireExactRecord(value, 'Session transcript page result', [
'kind',
'sessionId',
'source',
'direction',
'throughSequence',
'rawBytes',
'fragments',
'nextCursor',
]);
if (result.kind !== 'page') throw invalidProtocolFrame('Invalid Session transcript page kind');
const source = decodeSource(result.source);
const direction = decodeDirection(result.direction);
const throughSequence =
result.throughSequence === null
? null
: requireCount(result.throughSequence, 'Session transcript watermark');
if (
!Array.isArray(result.fragments) ||
result.fragments.length > SESSION_TRANSCRIPT_PAGE_MAX_MESSAGES
) {
throw invalidProtocolFrame('Invalid Session transcript page fragments');
}
const fragments = result.fragments.map((fragment) =>
decodeSessionTranscriptFragment(fragment, source, throughSequence),
);
assertFragmentOrder(fragments, direction);
const rawBytes = requireCount(result.rawBytes, 'Session transcript page bytes');
if (
rawBytes > SESSION_TRANSCRIPT_PAGE_MAX_BYTES ||
fragments.reduce(
(total, fragment) => total + Buffer.from(fragment.data, 'base64').byteLength,
0,
) !== rawBytes
) {
throw invalidProtocolFrame('Invalid Session transcript page byte count');
}
const nextCursor =
result.nextCursor === null
? null
: requireUtf8String(
result.nextCursor,
'Session transcript cursor',
SESSION_TRANSCRIPT_CURSOR_MAX_BYTES,
);
if (fragments.length === 0 && (rawBytes !== 0 || nextCursor !== null)) {
throw invalidProtocolFrame('Invalid empty Session transcript page');
}
return {
kind: 'page',
sessionId: requireEntityId(result.sessionId, 'sessionId'),
source,
direction,
throughSequence,
rawBytes,
fragments,
nextCursor,
};
}
function assertFragmentOrder(
fragments: readonly SessionTranscriptFragment[],
direction: SessionTranscriptPageDirection,
): void {
let previous: number | undefined;
for (const fragment of fragments) {
const identity = fragment.kind === 'durable' ? fragment.sequence : fragment.messageIndex;
if (
previous !== undefined &&
(direction === 'older' ? identity >= previous : identity <= previous)
) {
throw invalidProtocolFrame('Session transcript page fragment order changed');
}
previous = identity;
}
}
function decodeSessionTranscriptFragment(
value: unknown,
source: SessionTranscriptPageSource,
throughSequence: number | null,
): SessionTranscriptFragment {
const fragment = requireRecord(value, 'Session transcript fragment');
const identityKey = source === 'durable' ? 'sequence' : 'messageIndex';
const exact = requireExactRecord(fragment, 'Session transcript fragment', [
'kind',
identityKey,
'byteOffset',
'totalBytes',
...(source === 'durable' ? ['payloadDigest'] : []),
'data',
]);
if (exact.kind !== source) {
throw invalidProtocolFrame('Session transcript fragment source changed');
}
const byteOffset = requireCount(exact.byteOffset, 'Session transcript fragment byte offset');
const totalBytes = requireCount(exact.totalBytes, 'Session transcript fragment total bytes');
const data = requireBase64Fragment(exact.data);
const dataBytes = Buffer.from(data, 'base64').byteLength;
if (
totalBytes === 0 ||
dataBytes === 0 ||
byteOffset >= totalBytes ||
byteOffset + dataBytes > totalBytes
) {
throw invalidProtocolFrame('Invalid Session transcript fragment bounds');
}
if (source === 'durable') {
const sequence = requireCount(exact.sequence, 'Session transcript message sequence');
if (throughSequence === null || sequence > throughSequence) {
throw invalidProtocolFrame('Session transcript fragment exceeds watermark');
}
const payloadDigest =
exact.payloadDigest === null
? null
: requirePayloadDigest(exact.payloadDigest, 'Session transcript payload digest');
return { kind: 'durable', sequence, byteOffset, totalBytes, payloadDigest, data };
}
return {
kind: 'overlay',
messageIndex: requireCount(exact.messageIndex, 'Session transcript overlay index'),
byteOffset,
totalBytes,
data,
};
}
function requirePayloadDigest(value: unknown, label: string): `sha256:${string}` {
if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value as `sha256:${string}`;
}
function requireBase64Fragment(value: unknown): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) {
throw invalidProtocolFrame('Invalid Session transcript fragment data');
}
const bytes = Buffer.from(value, 'base64');
if (bytes.byteLength > SESSION_TRANSCRIPT_PAGE_MAX_BYTES || bytes.toString('base64') !== value) {
throw invalidProtocolFrame('Invalid Session transcript fragment data');
}
return value;
}
function assertSessionTranscriptPageOutput(
input: SessionTranscriptPageInput,
output: SessionTranscriptPage,
): void {
if (
output.source !== input.source ||
output.direction !== input.direction ||
output.throughSequence !== input.throughSequence ||
output.rawBytes > input.maxBytes
) {
throw invalidProtocolFrame('Session transcript page does not match request');
}
}
function decodeSource(value: unknown): SessionTranscriptPageSource {
if (value !== 'durable' && value !== 'overlay') {
throw invalidProtocolFrame('Invalid Session transcript page source');
}
return value;
}
function decodeDirection(value: unknown): SessionTranscriptPageDirection {
if (value !== 'older' && value !== 'newer') {
throw invalidProtocolFrame('Invalid Session transcript page direction');
}
return value;
}
function requirePageByteLimit(value: unknown): number {
const limit = requireCount(value, 'Session transcript page byte limit');
if (limit < 1 || limit > SESSION_TRANSCRIPT_PAGE_MAX_BYTES) {
throw invalidProtocolFrame('Invalid Session transcript page byte limit');
}
return limit;
}