279 lines
7.3 KiB
TypeScript
279 lines
7.3 KiB
TypeScript
import type {
|
|
RealtimeTransportEventOutcome,
|
|
} from "../../application/ports/realtime/event-authority.ts";
|
|
import type {
|
|
RealtimeFailure,
|
|
RealtimeFailureKind,
|
|
RealtimeOperation,
|
|
RealtimeResult,
|
|
} from "../../application/ports/realtime/shared.ts";
|
|
import {
|
|
REALTIME_FAILURE_KINDS,
|
|
REALTIME_OPERATIONS,
|
|
} from "../../application/ports/realtime/shared.ts";
|
|
import {
|
|
isCanonicalRealtimeSequence,
|
|
isRealtimeOpaqueIdentifier,
|
|
isRealtimeResumeCursor,
|
|
} from "../../contracts/realtime-events.ts";
|
|
|
|
export type {
|
|
RealtimeFailure,
|
|
RealtimeFailureKind,
|
|
RealtimeOperation,
|
|
RealtimeResult,
|
|
} from "../../application/ports/realtime/shared.ts";
|
|
|
|
const DEFAULT_RETRYABLE = new Set<RealtimeFailureKind>([
|
|
"OFFLINE",
|
|
"CONNECT_TIMEOUT",
|
|
"IDLE_TIMEOUT",
|
|
"RATE_LIMITED",
|
|
"PROVIDER_UNAVAILABLE",
|
|
]);
|
|
const FAILURE_KINDS = new Set<unknown>(REALTIME_FAILURE_KINDS);
|
|
const OPERATIONS = new Set<unknown>(REALTIME_OPERATIONS);
|
|
|
|
export type RealtimeDataSnapshot = Readonly<{
|
|
keys: readonly string[];
|
|
values: Readonly<Record<string, unknown>>;
|
|
frozen: boolean;
|
|
}>;
|
|
|
|
export function realtimeSuccess<Value>(
|
|
value: Value,
|
|
): Extract<RealtimeResult<Value>, { ok: true }> {
|
|
return Object.freeze({ ok: true, value });
|
|
}
|
|
|
|
export function realtimeFailure(
|
|
kind: RealtimeFailureKind,
|
|
operation: RealtimeOperation,
|
|
retryable = DEFAULT_RETRYABLE.has(kind),
|
|
): Extract<RealtimeResult<never>, { ok: false }> {
|
|
return Object.freeze({
|
|
ok: false,
|
|
error: Object.freeze({
|
|
kind,
|
|
operation,
|
|
retryable,
|
|
} satisfies RealtimeFailure),
|
|
});
|
|
}
|
|
|
|
export function isRealtimeFailure(
|
|
value: unknown,
|
|
): value is RealtimeFailure {
|
|
return parseRealtimeFailure(value, true) !== null;
|
|
}
|
|
|
|
/**
|
|
* Captures an external result through own data descriptors exactly once and
|
|
* returns a new canonical value. Callers that need to use the validated fields
|
|
* must use this returned snapshot rather than reading the source again.
|
|
*/
|
|
export function snapshotRealtimeResult<Value>(
|
|
value: unknown,
|
|
isValue: (candidate: unknown) => candidate is Value,
|
|
): RealtimeResult<Value> | null {
|
|
return parseRealtimeResult(value, isValue, false);
|
|
}
|
|
|
|
export function isRealtimeResult<Value>(
|
|
value: unknown,
|
|
isValue: (candidate: unknown) => candidate is Value,
|
|
): value is RealtimeResult<Value> {
|
|
return parseRealtimeResult(value, isValue, true) !== null;
|
|
}
|
|
|
|
export function isRealtimeTransportEventOutcome(
|
|
value: unknown,
|
|
): value is RealtimeTransportEventOutcome {
|
|
const snapshot = captureRealtimeDataSnapshot(value);
|
|
if (!snapshot || !snapshot.frozen) {
|
|
return false;
|
|
}
|
|
if (snapshot.values.kind === "CONTINUE") {
|
|
return hasExactSnapshotKeys(snapshot, ["kind"]);
|
|
}
|
|
if (
|
|
snapshot.values.kind !== "RECOVERY_COMMITTED" ||
|
|
!hasExactSnapshotKeys(snapshot, [
|
|
"checkpoint",
|
|
"kind",
|
|
"streamId",
|
|
]) ||
|
|
typeof snapshot.values.streamId !== "string"
|
|
) {
|
|
return false;
|
|
}
|
|
const checkpoint = captureRealtimeDataSnapshot(
|
|
snapshot.values.checkpoint,
|
|
);
|
|
return (
|
|
checkpoint !== null &&
|
|
checkpoint.frozen &&
|
|
hasExactSnapshotKeys(checkpoint, [
|
|
"lastAppliedSequence",
|
|
"recoveryMode",
|
|
"resumeCursor",
|
|
"streamEpoch",
|
|
]) &&
|
|
isRealtimeOpaqueIdentifier(snapshot.values.streamId) &&
|
|
isRealtimeOpaqueIdentifier(checkpoint.values.streamEpoch) &&
|
|
isCanonicalRealtimeSequence(
|
|
checkpoint.values.lastAppliedSequence,
|
|
) &&
|
|
(checkpoint.values.recoveryMode === "CURSOR"
|
|
? isRealtimeResumeCursor(checkpoint.values.resumeCursor)
|
|
: (checkpoint.values.recoveryMode === "SNAPSHOT_ONLY" ||
|
|
checkpoint.values.recoveryMode === "SESSION_REBUILD") &&
|
|
checkpoint.values.resumeCursor === null)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reads a plain record without invoking property accessors. Symbol keys,
|
|
* inherited shapes, non-enumerable fields and accessors are rejected. The
|
|
* returned null-prototype value map is immutable and detached from later
|
|
* property reads on the source object.
|
|
*/
|
|
export function captureRealtimeDataSnapshot(
|
|
value: unknown,
|
|
): RealtimeDataSnapshot | null {
|
|
try {
|
|
if (
|
|
!value ||
|
|
typeof value !== "object" ||
|
|
Array.isArray(value)
|
|
) {
|
|
return null;
|
|
}
|
|
const prototype = Object.getPrototypeOf(value);
|
|
if (
|
|
prototype !== Object.prototype &&
|
|
prototype !== null
|
|
) {
|
|
return null;
|
|
}
|
|
const extensible = Object.isExtensible(value);
|
|
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
const ownKeys = Reflect.ownKeys(descriptors);
|
|
if (ownKeys.some((key) => typeof key !== "string")) {
|
|
return null;
|
|
}
|
|
const keys = (ownKeys as string[]).sort();
|
|
const values = Object.create(null) as Record<string, unknown>;
|
|
let frozen = !extensible;
|
|
for (const key of keys) {
|
|
const descriptor = descriptors[key];
|
|
if (
|
|
!descriptor ||
|
|
!Object.hasOwn(descriptor, "value") ||
|
|
descriptor.enumerable !== true
|
|
) {
|
|
return null;
|
|
}
|
|
Object.defineProperty(values, key, {
|
|
configurable: false,
|
|
enumerable: true,
|
|
value: descriptor.value,
|
|
writable: false,
|
|
});
|
|
frozen =
|
|
frozen &&
|
|
descriptor.configurable === false &&
|
|
descriptor.writable === false;
|
|
}
|
|
return Object.freeze({
|
|
keys: Object.freeze(keys),
|
|
values: Object.freeze(values),
|
|
frozen,
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseRealtimeResult<Value>(
|
|
value: unknown,
|
|
isValue: (candidate: unknown) => candidate is Value,
|
|
requireFrozenSource: boolean,
|
|
): RealtimeResult<Value> | null {
|
|
const snapshot = captureRealtimeDataSnapshot(value);
|
|
if (
|
|
!snapshot ||
|
|
(requireFrozenSource && !snapshot.frozen)
|
|
) {
|
|
return null;
|
|
}
|
|
if (
|
|
snapshot.values.ok === true &&
|
|
hasExactSnapshotKeys(snapshot, ["ok", "value"])
|
|
) {
|
|
let accepted: boolean;
|
|
try {
|
|
accepted = isValue(snapshot.values.value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return accepted
|
|
? realtimeSuccess(snapshot.values.value as Value)
|
|
: null;
|
|
}
|
|
if (
|
|
snapshot.values.ok !== false ||
|
|
!hasExactSnapshotKeys(snapshot, ["error", "ok"])
|
|
) {
|
|
return null;
|
|
}
|
|
const failure = parseRealtimeFailure(
|
|
snapshot.values.error,
|
|
requireFrozenSource,
|
|
);
|
|
return failure
|
|
? realtimeFailure(
|
|
failure.kind,
|
|
failure.operation,
|
|
failure.retryable,
|
|
)
|
|
: null;
|
|
}
|
|
|
|
function parseRealtimeFailure(
|
|
value: unknown,
|
|
requireFrozenSource: boolean,
|
|
): RealtimeFailure | null {
|
|
const snapshot = captureRealtimeDataSnapshot(value);
|
|
if (
|
|
!snapshot ||
|
|
(requireFrozenSource && !snapshot.frozen) ||
|
|
!hasExactSnapshotKeys(snapshot, [
|
|
"kind",
|
|
"operation",
|
|
"retryable",
|
|
]) ||
|
|
!FAILURE_KINDS.has(snapshot.values.kind) ||
|
|
!OPERATIONS.has(snapshot.values.operation) ||
|
|
typeof snapshot.values.retryable !== "boolean"
|
|
) {
|
|
return null;
|
|
}
|
|
return Object.freeze({
|
|
kind: snapshot.values.kind as RealtimeFailureKind,
|
|
operation: snapshot.values.operation as RealtimeOperation,
|
|
retryable: snapshot.values.retryable,
|
|
});
|
|
}
|
|
|
|
function hasExactSnapshotKeys(
|
|
snapshot: RealtimeDataSnapshot,
|
|
expectedKeys: readonly string[],
|
|
): boolean {
|
|
const expected = [...expectedKeys].sort();
|
|
return (
|
|
snapshot.keys.length === expected.length &&
|
|
snapshot.keys.every((key, index) => key === expected[index])
|
|
);
|
|
}
|