Files
tech-log-frontend/src/contracts/realtime-events.ts
T

186 lines
4.6 KiB
TypeScript

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<Payload> = 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<Payload = unknown> = Readonly<
RealtimeEventBase<Payload> &
(
| 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;
}