import { REALTIME_EVENT_PROTOCOL, type EventTypeId, type StreamRegistrationId, } from "./realtime-streams.ts"; export const REALTIME_EVENT_FIELD_LIMITS = Object.freeze({ maxOpaqueIdentifierLength: 128, maxScopeBindingLength: 256, maxResumeCursorLength: 1_024, maxSequenceDigits: 20, maxTimestampFractionDigits: 9, }); export const REALTIME_MAX_SEQUENCE = "18446744073709551615"; export type RealtimeEventBase = Readonly<{ protocol: typeof REALTIME_EVENT_PROTOCOL; streamId: StreamRegistrationId; streamEpoch: string; eventType: EventTypeId; eventId: string; sequence: string; occurredAt: string; scopeBinding: string; payload: Payload; }>; export type RealtimeEventEnvelope = Readonly< RealtimeEventBase & ( | Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string; }> | Readonly<{ recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD"; resumeCursor: null; }> ) >; export type SnapshotCheckpoint = Readonly< { streamEpoch: string; lastAppliedSequence: string; snapshotRevision: string; } & ( | Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string; }> | Readonly<{ recoveryMode: "SNAPSHOT_ONLY"; resumeCursor: null; }> ) >; export type RealtimeResumeState = Readonly< { streamEpoch: string; lastAppliedSequence: string; } & ( | Readonly<{ recoveryMode: "CURSOR"; resumeCursor: string; }> | Readonly<{ recoveryMode: "SNAPSHOT_ONLY" | "SESSION_REBUILD"; resumeCursor: null; }> ) >; const OPAQUE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:~+/=-]*$/u; const HEADER_SAFE_CURSOR = /^[\x21-\x7e]+$/u; const CANONICAL_SEQUENCE = /^(?:0|[1-9][0-9]{0,19})$/u; const RFC_3339 = /^([0-9]{4})-(0[1-9]|1[0-2])-([0-2][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9])(?:\.([0-9]{1,9}))?(Z|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))$/u; export function isRealtimeOpaqueIdentifier( value: unknown, ): value is string { return ( typeof value === "string" && value.length >= 1 && value.length <= REALTIME_EVENT_FIELD_LIMITS.maxOpaqueIdentifierLength && OPAQUE_IDENTIFIER.test(value) ); } export function isRealtimeScopeBinding( value: unknown, ): value is string { return ( typeof value === "string" && value.length >= 1 && value.length <= REALTIME_EVENT_FIELD_LIMITS.maxScopeBindingLength && OPAQUE_IDENTIFIER.test(value) ); } export function isRealtimeResumeCursor( value: unknown, ): value is string { return ( typeof value === "string" && value.length >= 1 && value.length <= REALTIME_EVENT_FIELD_LIMITS.maxResumeCursorLength && HEADER_SAFE_CURSOR.test(value) && !value.includes("\0") && !value.includes("\r") && !value.includes("\n") ); } export function isCanonicalRealtimeSequence( value: unknown, ): value is string { if ( typeof value !== "string" || value.length > REALTIME_EVENT_FIELD_LIMITS.maxSequenceDigits || !CANONICAL_SEQUENCE.test(value) ) { return false; } try { return BigInt(value) <= BigInt(REALTIME_MAX_SEQUENCE); } catch { return false; } } export function compareRealtimeSequences( left: string, right: string, ): -1 | 0 | 1 { if ( !isCanonicalRealtimeSequence(left) || !isCanonicalRealtimeSequence(right) ) { throw new TypeError("Realtime sequence is invalid."); } const leftValue = BigInt(left); const rightValue = BigInt(right); return leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0; } export function nextRealtimeSequence(value: string): string | null { if (!isCanonicalRealtimeSequence(value)) { throw new TypeError("Realtime sequence is invalid."); } const next = BigInt(value) + 1n; return next > BigInt(REALTIME_MAX_SEQUENCE) ? null : next.toString(10); } /** * A bounded RFC 3339 profile: uppercase T/Z, a required offset, real calendar * dates, seconds 00-59 and at most nanosecond fractional precision. */ export function isStrictRealtimeTimestamp( value: unknown, ): value is string { if (typeof value !== "string") return false; const match = RFC_3339.exec(value); if (!match) return false; const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); if (year < 1 || day > daysInMonth(year, month)) return false; return Number.isFinite(Date.parse(value)); } function daysInMonth(year: number, month: number): number { if (month === 2) { return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; } return [4, 6, 9, 11].includes(month) ? 30 : 31; }