186 lines
4.6 KiB
TypeScript
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;
|
|
}
|
|
|