chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClockPort } from "../../../src/application/ports/clock-port.ts";
|
||||
import { definePollLeasePolicy } from "../../../src/application/policies/bounded-polling.ts";
|
||||
import {
|
||||
createBoundedPollCoordinator,
|
||||
type BoundedPollAttemptResult,
|
||||
type BoundedPollEnvironment,
|
||||
} from "../../../src/adapters/realtime/polling/bounded-poll-coordinator.ts";
|
||||
|
||||
type Sleeper = {
|
||||
dueAt: number;
|
||||
resolve(): void;
|
||||
reject(): void;
|
||||
signal?: AbortSignal;
|
||||
onAbort?: () => void;
|
||||
};
|
||||
|
||||
class ManualClock implements ClockPort {
|
||||
current = 0;
|
||||
readonly sleepers: Sleeper[] = [];
|
||||
|
||||
now(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const sleeper: Sleeper = {
|
||||
dueAt: this.current + milliseconds,
|
||||
resolve: () => {
|
||||
signal?.removeEventListener("abort", sleeper.onAbort!);
|
||||
resolve();
|
||||
},
|
||||
reject: () => {
|
||||
signal?.removeEventListener("abort", sleeper.onAbort!);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
signal,
|
||||
};
|
||||
sleeper.onAbort = () => {
|
||||
this.remove(sleeper);
|
||||
sleeper.reject();
|
||||
};
|
||||
signal?.addEventListener("abort", sleeper.onAbort, {
|
||||
once: true,
|
||||
});
|
||||
this.sleepers.push(sleeper);
|
||||
});
|
||||
}
|
||||
|
||||
advance(milliseconds: number): void {
|
||||
this.current += milliseconds;
|
||||
const ready = this.sleepers
|
||||
.filter((sleeper) => sleeper.dueAt <= this.current)
|
||||
.sort((left, right) => left.dueAt - right.dueAt);
|
||||
for (const sleeper of ready) {
|
||||
this.remove(sleeper);
|
||||
sleeper.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private remove(target: Sleeper): void {
|
||||
const index = this.sleepers.indexOf(target);
|
||||
if (index >= 0) this.sleepers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
class MutableEnvironment implements BoundedPollEnvironment {
|
||||
visible = true;
|
||||
connected = true;
|
||||
readonly visibilityListeners = new Set<
|
||||
(visibility: "HIDDEN" | "VISIBLE") => void
|
||||
>();
|
||||
readonly onlineListeners = new Set<(online: boolean) => void>();
|
||||
|
||||
visibility(): "HIDDEN" | "VISIBLE" {
|
||||
return this.visible ? "VISIBLE" : "HIDDEN";
|
||||
}
|
||||
|
||||
online(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
subscribeVisibility(
|
||||
listener: (visibility: "HIDDEN" | "VISIBLE") => void,
|
||||
): () => void {
|
||||
this.visibilityListeners.add(listener);
|
||||
return () => this.visibilityListeners.delete(listener);
|
||||
}
|
||||
|
||||
subscribeOnline(listener: (online: boolean) => void): () => void {
|
||||
this.onlineListeners.add(listener);
|
||||
return () => this.onlineListeners.delete(listener);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.visible = false;
|
||||
for (const listener of this.visibilityListeners) listener("HIDDEN");
|
||||
}
|
||||
}
|
||||
|
||||
const policy = definePollLeasePolicy({
|
||||
operationId: "GET_JOB_STATUS",
|
||||
owner: "reference-job",
|
||||
minimumIntervalMs: 5_000,
|
||||
successIntervalMs: 5_000,
|
||||
maxIntervalMs: 60_000,
|
||||
maxAttempts: 3,
|
||||
maxElapsedMs: 60_000,
|
||||
maxResponseBytes: 1_024,
|
||||
visibility: "VISIBLE_ONLY",
|
||||
fallbackReason: "CONVERGENCE",
|
||||
terminalStates: ["COMPLETED", "FAILED"],
|
||||
});
|
||||
const operation = {
|
||||
operationId: "GET_JOB_STATUS",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
method: "GET",
|
||||
replayPolicy: "SAFE",
|
||||
retry: "never",
|
||||
maxResponseBytes: 1_024,
|
||||
transportMaxAttempts: 1,
|
||||
authRecoveryCount: 0,
|
||||
maxCumulativeSleepMs: 0,
|
||||
serverStream: false,
|
||||
} as const;
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let turn = 0; turn < 12; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("bounded poll coordinator", () => {
|
||||
it("chains completed attempts without overlap and stops at a terminal state", async () => {
|
||||
const clock = new ManualClock();
|
||||
const environment = new MutableEnvironment();
|
||||
let active = 0;
|
||||
let highWatermark = 0;
|
||||
const execute = vi
|
||||
.fn<
|
||||
(
|
||||
input: Readonly<{
|
||||
operationId: string;
|
||||
attempt: number;
|
||||
maxResponseBytes: number;
|
||||
signal: AbortSignal;
|
||||
}>,
|
||||
) => Promise<BoundedPollAttemptResult<string>>
|
||||
>()
|
||||
.mockImplementation(async ({ attempt }) => {
|
||||
active += 1;
|
||||
highWatermark = Math.max(highWatermark, active);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: attempt === 1 ? "working" : "done",
|
||||
responseBytes: 32,
|
||||
state: attempt === 1 ? "RUNNING" : "COMPLETED",
|
||||
},
|
||||
};
|
||||
});
|
||||
const onValue = vi.fn();
|
||||
const coordinator = createBoundedPollCoordinator({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment,
|
||||
clock,
|
||||
random: () => 0.5,
|
||||
});
|
||||
|
||||
const result = coordinator.run({ onValue });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(clock.sleepers).toHaveLength(1);
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "TERMINAL",
|
||||
attempts: 2,
|
||||
state: "COMPLETED",
|
||||
value: "done",
|
||||
},
|
||||
});
|
||||
expect(onValue.mock.calls.map(([value]) => value)).toEqual([
|
||||
"working",
|
||||
"done",
|
||||
]);
|
||||
expect(highWatermark).toBe(1);
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
expect(environment.visibilityListeners.size).toBe(0);
|
||||
expect(environment.onlineListeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("passes the strictest response ceiling to the executor before decode", async () => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi.fn(
|
||||
async ({
|
||||
maxResponseBytes,
|
||||
}: Readonly<{ maxResponseBytes: number }>) => ({
|
||||
ok: true as const,
|
||||
value: {
|
||||
kind: "VALUE" as const,
|
||||
value: "done",
|
||||
responseBytes: maxResponseBytes,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation: {
|
||||
...operation,
|
||||
maxResponseBytes: 4_096,
|
||||
},
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { state: "COMPLETED" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResponseBytes: 1_024 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start another attempt while a request is unresolved", async () => {
|
||||
const clock = new ManualClock();
|
||||
const environment = new MutableEnvironment();
|
||||
let resolveFirst:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<BoundedPollAttemptResult<string>>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "done",
|
||||
responseBytes: 10,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment,
|
||||
clock,
|
||||
random: () => 0.5,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(30_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirst?.({
|
||||
ok: true,
|
||||
value: { kind: "UNCHANGED", responseBytes: 0 },
|
||||
});
|
||||
await flush();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { attempts: 2, state: "COMPLETED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ends the lease when an in-flight executor ignores abort and exceeds max elapsed", async () => {
|
||||
const clock = new ManualClock();
|
||||
let attemptSignal: AbortSignal | undefined;
|
||||
let settleAttempt:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const execute = vi.fn(
|
||||
({ signal }: Readonly<{ signal: AbortSignal }>) => {
|
||||
attemptSignal = signal;
|
||||
return new Promise<BoundedPollAttemptResult<string>>(
|
||||
(resolve) => {
|
||||
settleAttempt = resolve;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(attemptSignal?.aborted).toBe(false);
|
||||
|
||||
clock.advance(54_999);
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("RUNNING");
|
||||
clock.advance(1);
|
||||
await flush();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "POLL_BUDGET_EXHAUSTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(attemptSignal?.aborted).toBe(true);
|
||||
expect(coordinator.getState()).toBe("DRAINING");
|
||||
await expect(coordinator.run()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
settleAttempt?.({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
});
|
||||
|
||||
it("returns promptly when a caller aborts an executor that ignores its signal", async () => {
|
||||
const clock = new ManualClock();
|
||||
const caller = new AbortController();
|
||||
let settleAttempt:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<BoundedPollAttemptResult<string>>(
|
||||
(resolve) => {
|
||||
settleAttempt = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run({ signal: caller.signal });
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
caller.abort();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(coordinator.getState()).toBe("DRAINING");
|
||||
settleAttempt?.({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
});
|
||||
|
||||
it("fails closed and aborts the attempt when the lease deadline clock is unavailable", async () => {
|
||||
let current = 0;
|
||||
let sleeps = 0;
|
||||
const clock: ClockPort = {
|
||||
now: () => current,
|
||||
sleep: async (milliseconds) => {
|
||||
sleeps += 1;
|
||||
if (sleeps > 1) throw new Error("deadline unavailable");
|
||||
current += milliseconds;
|
||||
},
|
||||
};
|
||||
let attemptSignal: AbortSignal | undefined;
|
||||
const execute = vi.fn(
|
||||
async ({ signal }: Readonly<{ signal: AbortSignal }>) => {
|
||||
attemptSignal = signal;
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
kind: "UNCHANGED" as const,
|
||||
responseBytes: 0 as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(coordinator.run()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROVIDER_UNAVAILABLE",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(attemptSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("fences a non-cooperative apply callback at the lease deadline", async () => {
|
||||
const clock = new ManualClock();
|
||||
let applyContext:
|
||||
| Readonly<{
|
||||
signal: AbortSignal;
|
||||
isCurrent(): boolean;
|
||||
}>
|
||||
| undefined;
|
||||
let releaseApply: (() => void) | undefined;
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "working",
|
||||
responseBytes: 10,
|
||||
state: "RUNNING",
|
||||
},
|
||||
}),
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run({
|
||||
onValue: (_value, context) => {
|
||||
applyContext = context;
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseApply = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(applyContext?.isCurrent()).toBe(true);
|
||||
|
||||
clock.advance(55_000);
|
||||
await flush();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "POLL_BUDGET_EXHAUSTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(applyContext?.signal.aborted).toBe(true);
|
||||
expect(applyContext?.isCurrent()).toBe(false);
|
||||
expect(coordinator.getState()).toBe("DRAINING");
|
||||
releaseApply?.();
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
});
|
||||
|
||||
it("honors Retry-After as a floor and exhausts finite attempts", async () => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RATE_LIMITED",
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
retryAfterMs: 10_000,
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { kind: "UNCHANGED", responseBytes: 0 },
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy: definePollLeasePolicy({
|
||||
...policy,
|
||||
maxAttempts: 2,
|
||||
}),
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(9_999);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(1);
|
||||
await flush();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "POLL_BUDGET_EXHAUSTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["RATE_LIMITED", "PROVIDER_UNAVAILABLE"] as const)(
|
||||
"does not retry %s without a bounded server hint",
|
||||
async (kind) => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores Retry-After for retryable failure kinds that cannot carry the hint", async () => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CONNECT_TIMEOUT",
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
retryAfterMs: 10_000,
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "done",
|
||||
responseBytes: 10,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(4_999);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(1);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { attempts: 2, state: "COMPLETED" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["AUTH_REQUIRED", "FORBIDDEN"] as const)(
|
||||
"never retries terminal %s failures even when the executor marks them retryable",
|
||||
async (kind) => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
retryAfterMs: 10_000,
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("aborts in-flight work on hidden lifecycle and rejects late scope results", async () => {
|
||||
const hiddenClock = new ManualClock();
|
||||
const hiddenEnvironment = new MutableEnvironment();
|
||||
const hiddenCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: hiddenEnvironment,
|
||||
clock: hiddenClock,
|
||||
execute: ({ signal }) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
const hidden = hiddenCoordinator.run();
|
||||
hiddenClock.advance(5_000);
|
||||
await flush();
|
||||
hiddenEnvironment.hide();
|
||||
await expect(hidden).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const scopeClock = new ManualClock();
|
||||
let current = true;
|
||||
let resolveAttempt:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const onValue = vi.fn();
|
||||
const scopeCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: new MutableEnvironment(),
|
||||
clock: scopeClock,
|
||||
isCurrent: () => current,
|
||||
execute: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveAttempt = resolve;
|
||||
}),
|
||||
});
|
||||
const fenced = scopeCoordinator.run({ onValue });
|
||||
scopeClock.advance(5_000);
|
||||
await flush();
|
||||
current = false;
|
||||
resolveAttempt?.({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "late",
|
||||
responseBytes: 10,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
});
|
||||
await expect(fenced).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_FENCED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(onValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects accessor and extra-key attempt results without re-reading them", async () => {
|
||||
const accessorClock = new ManualClock();
|
||||
const readOk = vi.fn(() => true);
|
||||
const accessorResult = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
ok: {
|
||||
enumerable: true,
|
||||
get: readOk,
|
||||
},
|
||||
value: {
|
||||
enumerable: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "unsafe",
|
||||
responseBytes: 1,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
},
|
||||
},
|
||||
) as BoundedPollAttemptResult<string>;
|
||||
const accessorCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: new MutableEnvironment(),
|
||||
clock: accessorClock,
|
||||
execute: async () => accessorResult,
|
||||
});
|
||||
const accessorRun = accessorCoordinator.run();
|
||||
accessorClock.advance(5_000);
|
||||
await expect(accessorRun).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(readOk).not.toHaveBeenCalled();
|
||||
|
||||
const extraClock = new ManualClock();
|
||||
const extraCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: new MutableEnvironment(),
|
||||
clock: extraClock,
|
||||
execute: async () =>
|
||||
({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "unsafe",
|
||||
responseBytes: 1,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
extra: true,
|
||||
}) as BoundedPollAttemptResult<string>,
|
||||
});
|
||||
const extraRun = extraCoordinator.run();
|
||||
extraClock.advance(5_000);
|
||||
await expect(extraRun).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on response ceilings, concurrent runs and close", async () => {
|
||||
const clock = new ManualClock();
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation: {
|
||||
...operation,
|
||||
maxResponseBytes: 512,
|
||||
},
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "oversized",
|
||||
responseBytes: 513,
|
||||
state: "RUNNING",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const first = coordinator.run();
|
||||
await expect(coordinator.run()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
clock.advance(5_000);
|
||||
await expect(first).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
coordinator.close();
|
||||
coordinator.close();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(coordinator.getState()).toBe("CLOSED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BOUNDED_POLLING_CEILINGS,
|
||||
assertBoundedPollOperation,
|
||||
definePollLeasePolicy,
|
||||
type BoundedPollOperationContract,
|
||||
} from "../../../src/application/policies/bounded-polling.ts";
|
||||
|
||||
const input = {
|
||||
operationId: "GET_JOB_STATUS",
|
||||
owner: "reference-job",
|
||||
minimumIntervalMs: 5_000,
|
||||
successIntervalMs: 10_000,
|
||||
maxIntervalMs: 60_000,
|
||||
maxAttempts: 5,
|
||||
maxElapsedMs: 120_000,
|
||||
maxResponseBytes: 16_384,
|
||||
visibility: "VISIBLE_ONLY",
|
||||
fallbackReason: "CONVERGENCE",
|
||||
terminalStates: ["COMPLETED", "FAILED"],
|
||||
} as const;
|
||||
|
||||
describe("bounded polling policy", () => {
|
||||
it("copies and freezes a finite immutable lease", () => {
|
||||
const terminalStates = ["COMPLETED", "FAILED"];
|
||||
const policy = definePollLeasePolicy({
|
||||
...input,
|
||||
terminalStates,
|
||||
});
|
||||
terminalStates.push("CANCELLED");
|
||||
|
||||
expect(policy.terminalStates).toEqual(["COMPLETED", "FAILED"]);
|
||||
expect(Object.isFrozen(policy)).toBe(true);
|
||||
expect(Object.isFrozen(policy.terminalStates)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects push-like cadence and unbounded convergence", () => {
|
||||
expect(() =>
|
||||
definePollLeasePolicy({
|
||||
...input,
|
||||
minimumIntervalMs: 4_999,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
definePollLeasePolicy({
|
||||
...input,
|
||||
terminalStates: [],
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
definePollLeasePolicy({
|
||||
...input,
|
||||
maxAttempts: 121,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("admits only a one-request terminal replay-safe REST query", () => {
|
||||
const policy = definePollLeasePolicy(input);
|
||||
const operation: BoundedPollOperationContract = {
|
||||
operationId: "GET_JOB_STATUS",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
method: "GET",
|
||||
replayPolicy: "SAFE",
|
||||
retry: "never",
|
||||
maxResponseBytes: 16_384,
|
||||
transportMaxAttempts: 1,
|
||||
authRecoveryCount: 0,
|
||||
maxCumulativeSleepMs: 0,
|
||||
serverStream: false,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, operation),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
transportMaxAttempts: 2 as 1,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
maxResponseBytes: 8_192,
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
maxResponseBytes: 0,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
maxResponseBytes:
|
||||
BOUNDED_POLLING_CEILINGS.maxResponseBytes + 1,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareRealtimeSequences,
|
||||
isCanonicalRealtimeSequence,
|
||||
isRealtimeResumeCursor,
|
||||
isStrictRealtimeTimestamp,
|
||||
nextRealtimeSequence,
|
||||
REALTIME_MAX_SEQUENCE,
|
||||
} from "../../../src/contracts/realtime-events.ts";
|
||||
import {
|
||||
isValidatedRealtimeEventDto,
|
||||
} from "../../../src/adapters/realtime/event-codec.ts";
|
||||
import {
|
||||
TEST_LIMITS,
|
||||
createTestRealtimeCodec,
|
||||
createTestRealtimeRegistry,
|
||||
realtimeEventJson,
|
||||
realtimeEventValue,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("REALTIME_EVENT_V1 codec", () => {
|
||||
it("accepts an exact registered envelope and snapshots schema output", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
const result = codec.decode(realtimeEventJson());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
envelope: {
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
sequence: "1",
|
||||
recoveryMode: "CURSOR",
|
||||
resumeCursor: "cursor-00000001",
|
||||
payload: { value: "changed" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result.ok) return;
|
||||
expect(isValidatedRealtimeEventDto(result.value)).toBe(true);
|
||||
expect(Object.isFrozen(result.value)).toBe(true);
|
||||
expect(Object.isFrozen(result.value.envelope)).toBe(true);
|
||||
expect(Object.isFrozen(result.value.envelope.payload)).toBe(true);
|
||||
expect(result.value.wireBytes).toBeGreaterThan(0);
|
||||
expect(result.value.fingerprintBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects extra keys, unknown registrations and schema failures", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
|
||||
expect(
|
||||
codec.decode(
|
||||
JSON.stringify({
|
||||
...realtimeEventValue(),
|
||||
arbitrary: "override",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
||||
});
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({ streamId: "UNKNOWN_STREAM" }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({ eventType: "UNKNOWN_EVENT" }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
expect(
|
||||
codec.decode(realtimeEventJson({ payload: { value: 42 } })),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
|
||||
const duplicateEnvelopeMember =
|
||||
`{"eventId":"shadowed",${JSON.stringify(
|
||||
realtimeEventValue(),
|
||||
).slice(1)}`;
|
||||
expect(codec.decode(duplicateEnvelopeMember)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
||||
});
|
||||
const duplicatePayloadMember = realtimeEventJson().replace(
|
||||
'"payload":{"value":"changed"}',
|
||||
'"payload":{"value":"first","\\u0076alue":"changed"}',
|
||||
);
|
||||
expect(codec.decode(duplicatePayloadMember)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on version, sequence, timestamp, scope and cursor syntax", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
|
||||
expect(
|
||||
codec.decode(realtimeEventJson({ protocol: "REALTIME_EVENT_V2" })),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
for (const overrides of [
|
||||
{ sequence: "01" },
|
||||
{ sequence: "18446744073709551616" },
|
||||
{ occurredAt: "2026-02-30T01:02:03Z" },
|
||||
{ occurredAt: "2026-07-28 01:02:03Z" },
|
||||
{ scopeBinding: "scope\r\ninjected" },
|
||||
{ resumeCursor: "" },
|
||||
{ resumeCursor: "cursor\ninjected" },
|
||||
{ recoveryMode: "SNAPSHOT_ONLY", resumeCursor: null },
|
||||
]) {
|
||||
expect(codec.decode(realtimeEventJson(overrides))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces global, per-stream and payload structure ceilings", () => {
|
||||
const strictCodec = createTestRealtimeCodec(
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxEventBytes: 512,
|
||||
maxPayloadNodes: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
strictCodec.decode(realtimeEventJson()),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
|
||||
const byteBoundCodec = createTestRealtimeCodec(
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxEventBytes: 512,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
byteBoundCodec.decode(
|
||||
realtimeEventJson({ payload: { value: "x".repeat(600) } }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "EVENT_TOO_LARGE" },
|
||||
});
|
||||
|
||||
expect(
|
||||
createTestRealtimeCodec().decode(
|
||||
"x".repeat(64 * 1024 + 1),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "EVENT_TOO_LARGE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("supports the exact null-cursor SESSION_REBUILD discriminant", () => {
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "EPHEMERAL",
|
||||
stateBearing: false,
|
||||
});
|
||||
const codec = createTestRealtimeCodec(registry);
|
||||
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({
|
||||
recoveryMode: "SESSION_REBUILD",
|
||||
resumeCursor: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
envelope: {
|
||||
recoveryMode: "SESSION_REBUILD",
|
||||
resumeCursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({
|
||||
recoveryMode: "SESSION_REBUILD",
|
||||
resumeCursor: "synthetic",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes semantic identity independently of JSON key order", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
const value = realtimeEventValue();
|
||||
const reversed = Object.fromEntries(
|
||||
Object.entries(value).reverse(),
|
||||
);
|
||||
const first = codec.decode(JSON.stringify(value));
|
||||
const second = codec.decode(JSON.stringify(reversed));
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
if (!first.ok || !second.ok) return;
|
||||
expect(first.value.semanticFingerprint).toBe(
|
||||
second.value.semanticFingerprint,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses bounded uint64 sequence and header-safe cursor helpers", () => {
|
||||
expect(isCanonicalRealtimeSequence("0")).toBe(true);
|
||||
expect(isCanonicalRealtimeSequence(REALTIME_MAX_SEQUENCE)).toBe(true);
|
||||
expect(isCanonicalRealtimeSequence("00")).toBe(false);
|
||||
expect(isCanonicalRealtimeSequence("18446744073709551616")).toBe(false);
|
||||
expect(compareRealtimeSequences("9", "10")).toBe(-1);
|
||||
expect(nextRealtimeSequence("9")).toBe("10");
|
||||
expect(nextRealtimeSequence(REALTIME_MAX_SEQUENCE)).toBeNull();
|
||||
expect(isRealtimeResumeCursor("cursor:/+=._~-")).toBe(true);
|
||||
expect(isRealtimeResumeCursor("cursor\nunsafe")).toBe(false);
|
||||
expect(isStrictRealtimeTimestamp("2024-02-29T23:59:59Z")).toBe(true);
|
||||
expect(isStrictRealtimeTimestamp("2023-02-29T23:59:59Z")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
RealtimeAcceptDisposition,
|
||||
RealtimeRecoveryCheckpoint,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type {
|
||||
RealtimeResult,
|
||||
} from "../../../src/application/ports/realtime/shared.ts";
|
||||
import {
|
||||
createRealtimeEventConsumer,
|
||||
} from "../../../src/adapters/realtime/event-consumer.ts";
|
||||
import type {
|
||||
RealtimeEventCodec,
|
||||
ValidatedRealtimeEventDto,
|
||||
} from "../../../src/adapters/realtime/event-codec.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
import type {
|
||||
RealtimeStreamCoordinator,
|
||||
} from "../../../src/adapters/realtime/stream-coordinator.ts";
|
||||
|
||||
function setup(
|
||||
recoveryMode:
|
||||
| "CURSOR"
|
||||
| "SNAPSHOT_ONLY"
|
||||
| "SESSION_REBUILD",
|
||||
resumeCursor: string | null,
|
||||
) {
|
||||
const dto = {
|
||||
envelope: {
|
||||
recoveryMode,
|
||||
resumeCursor,
|
||||
streamId: "REFERENCE_STREAM",
|
||||
},
|
||||
} as ValidatedRealtimeEventDto;
|
||||
const codec: RealtimeEventCodec = {
|
||||
decode: vi.fn(() => realtimeSuccess(dto)),
|
||||
};
|
||||
const accept = vi.fn<
|
||||
(
|
||||
event: ValidatedRealtimeEventDto,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<RealtimeResult<RealtimeAcceptDisposition>>
|
||||
>(() =>
|
||||
Promise.resolve(
|
||||
realtimeSuccess({
|
||||
outcome: "DROPPED" as const,
|
||||
reason: "DUPLICATE_EVENT" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const consumer = createRealtimeEventConsumer({
|
||||
codec,
|
||||
coordinator: {
|
||||
accept,
|
||||
} as Pick<RealtimeStreamCoordinator, "accept">,
|
||||
});
|
||||
return { consumer, accept, codec };
|
||||
}
|
||||
|
||||
describe("realtime transport event consumer", () => {
|
||||
it("requires exact equality between SSE id and CURSOR envelope", async () => {
|
||||
const matching = setup("CURSOR", "cursor.0001");
|
||||
await expect(
|
||||
matching.consumer.consume("{}", {
|
||||
kind: "SSE_DIRECT_CURSOR",
|
||||
eventId: "cursor.0001",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(matching.accept).toHaveBeenCalledTimes(1);
|
||||
|
||||
const advanced = setup("CURSOR", "cursor.0002");
|
||||
await expect(
|
||||
advanced.consumer.consume("{}", {
|
||||
kind: "SSE_DIRECT_CURSOR",
|
||||
eventId: "cursor.0001",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"),
|
||||
);
|
||||
expect(advanced.accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids SSE id semantics for non-CURSOR recovery", async () => {
|
||||
const runtime = setup("SNAPSHOT_ONLY", null);
|
||||
await expect(
|
||||
runtime.consumer.consume("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await expect(
|
||||
runtime.consumer.consume("{}", {
|
||||
kind: "SSE_DIRECT_CURSOR",
|
||||
eventId: "cursor.0001",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes an encapsulated WebSocket envelope through the codec", async () => {
|
||||
const runtime = setup("SESSION_REBUILD", null);
|
||||
await expect(
|
||||
runtime.consumer.consumeEncapsulated(
|
||||
Object.freeze({ protocol: "REALTIME_EVENT_V1" }),
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(runtime.codec.decode).toHaveBeenCalledWith(
|
||||
'{"protocol":"REALTIME_EVENT_V1"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("projects common dispositions into the canonical transport outcome", async () => {
|
||||
const runtime = setup("SNAPSHOT_ONLY", null);
|
||||
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { kind: "CONTINUE" },
|
||||
});
|
||||
|
||||
const checkpoint = Object.freeze({
|
||||
recoveryMode: "SNAPSHOT_ONLY" as const,
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: null,
|
||||
}) as RealtimeRecoveryCheckpoint;
|
||||
runtime.accept.mockResolvedValueOnce(
|
||||
realtimeSuccess({
|
||||
outcome: "RECOVERED",
|
||||
reason: "INITIALIZE",
|
||||
resumeState: checkpoint,
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "RECOVERY_COMMITTED",
|
||||
streamId: "REFERENCE_STREAM",
|
||||
checkpoint,
|
||||
},
|
||||
});
|
||||
|
||||
runtime.accept.mockResolvedValueOnce(
|
||||
realtimeSuccess({
|
||||
outcome: "DROPPED",
|
||||
reason: "RECOVERY_IN_PROGRESS",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"),
|
||||
);
|
||||
|
||||
runtime.accept.mockResolvedValueOnce(
|
||||
realtimeSuccess({
|
||||
outcome: "DROPPED",
|
||||
reason: "SCOPE_FENCED",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("SCOPE_FENCED", "RECEIVE"),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads transport cancellation into the common authority", async () => {
|
||||
const runtime = setup("SNAPSHOT_ONLY", null);
|
||||
const active = new AbortController();
|
||||
|
||||
await runtime.consumer.consume(
|
||||
"{}",
|
||||
{ kind: "SSE_NO_CURSOR" },
|
||||
active.signal,
|
||||
);
|
||||
expect(runtime.accept).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
active.signal,
|
||||
);
|
||||
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await expect(
|
||||
runtime.consumer.consume(
|
||||
"{}",
|
||||
{ kind: "SSE_NO_CURSOR" },
|
||||
aborted.signal,
|
||||
),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("ABORTED", "RECEIVE"),
|
||||
);
|
||||
expect(runtime.accept).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SSE_CONTINUE,
|
||||
createFetchSseConnection,
|
||||
} from "../../../src/adapters/realtime/sse/fetch-sse-connection.ts";
|
||||
import {
|
||||
realtimeTransportRecoveryCommitted,
|
||||
type RealtimeRecoveryCheckpoint,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import type {
|
||||
StreamRegistrationId,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const continueEvent = () => realtimeSuccess(SSE_CONTINUE);
|
||||
const RECOVERY_CHECKPOINT = Object.freeze({
|
||||
recoveryMode: "CURSOR" as const,
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
lastAppliedSequence: "1",
|
||||
resumeCursor: "cursor-1",
|
||||
}) as RealtimeRecoveryCheckpoint;
|
||||
const RECOVERY_OUTCOME = realtimeTransportRecoveryCommitted(
|
||||
"REFERENCE_STREAM" as StreamRegistrationId,
|
||||
RECOVERY_CHECKPOINT,
|
||||
);
|
||||
|
||||
function eventStream(
|
||||
chunks: readonly string[],
|
||||
options: Readonly<{
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
}> = {},
|
||||
): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: options.status ?? 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
options.contentType ?? "text/event-stream; charset=utf-8",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function connection(
|
||||
fetcher: typeof fetch,
|
||||
recoveryMode: "CURSOR" | "SESSION_REBUILD" | "SNAPSHOT_ONLY" =
|
||||
"CURSOR",
|
||||
) {
|
||||
return createFetchSseConnection({
|
||||
endpoint: "https://app.example.test/events",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetch SSE connection", () => {
|
||||
it("uses the fixed request profile and streams events sequentially", async () => {
|
||||
const response = eventStream([
|
||||
": heartbeat\r\n",
|
||||
"retry: 2500\n",
|
||||
"id: cursor-1\ndata: first\n",
|
||||
"data: second\n\n",
|
||||
]);
|
||||
const fetcher = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
response,
|
||||
);
|
||||
const received: string[] = [];
|
||||
const comments = vi.fn();
|
||||
const hints = vi.fn();
|
||||
const runtime = connection(fetcher as typeof fetch);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: "cursor-0",
|
||||
onEvent: async (event) => {
|
||||
received.push(event.data);
|
||||
return realtimeSuccess(SSE_CONTINUE);
|
||||
},
|
||||
onComment: comments,
|
||||
onRetryHint: hints,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: false,
|
||||
retryHintMs: 2_500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(received).toEqual(["first\nsecond"]);
|
||||
expect(comments).toHaveBeenCalledOnce();
|
||||
expect(hints).toHaveBeenCalledWith(2_500);
|
||||
const [target, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(target).toBe("https://app.example.test/events");
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
});
|
||||
expect(new Headers(init?.headers)).toEqual(
|
||||
new Headers({
|
||||
Accept: "text/event-stream",
|
||||
"Last-Event-ID": "cursor-0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds event consumption behind the validated open barrier gate", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: after-barrier\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
let release:
|
||||
| ((result: RealtimeResult<void>) => void)
|
||||
| undefined;
|
||||
const gate = new Promise<RealtimeResult<void>>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const onOpen = vi.fn(() => gate);
|
||||
const onEvent = vi.fn(continueEvent);
|
||||
|
||||
const reading = runtime.read({
|
||||
resumeCursor: null,
|
||||
onOpen,
|
||||
onEvent,
|
||||
});
|
||||
await vi.waitFor(() => expect(onOpen).toHaveBeenCalledOnce());
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
|
||||
release?.(realtimeSuccess(undefined));
|
||||
await expect(reading).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "EOF" },
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates an exact open-barrier failure before reading events", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: unreachable\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const barrierFailure = realtimeFailure(
|
||||
"SCOPE_FENCED",
|
||||
"RECOVER",
|
||||
false,
|
||||
);
|
||||
const onEvent = vi.fn(continueEvent);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onOpen: () => barrierFailure,
|
||||
onEvent,
|
||||
}),
|
||||
).resolves.toBe(barrierFailure);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats 204 as terminal and rejects incorrect media types", async () => {
|
||||
const terminal = connection(
|
||||
(async () => new Response(null, { status: 204 })) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
terminal.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { kind: "NO_RECONNECT" },
|
||||
});
|
||||
|
||||
const wrongType = connection(
|
||||
(async () =>
|
||||
eventStream(["data: value\n\n"], {
|
||||
contentType: "application/json",
|
||||
})) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
wrongType.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, "AUTH_REQUIRED", false],
|
||||
[403, "FORBIDDEN", false],
|
||||
[409, "CURSOR_EXPIRED", false],
|
||||
[410, "CURSOR_EXPIRED", false],
|
||||
[429, "RATE_LIMITED", true],
|
||||
[502, "PROVIDER_UNAVAILABLE", true],
|
||||
[503, "PROVIDER_UNAVAILABLE", true],
|
||||
[504, "PROVIDER_UNAVAILABLE", true],
|
||||
[500, "PROTOCOL_MISMATCH", false],
|
||||
] as const)(
|
||||
"maps HTTP %i to %s without exposing a response body",
|
||||
async (status, kind, retryable) => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response("private backend text", {
|
||||
status,
|
||||
headers:
|
||||
status === 429 || status === 503
|
||||
? { "Retry-After": "10" }
|
||||
: undefined,
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const result = await runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "CONNECT",
|
||||
retryable,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("private backend");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[429, "RATE_LIMITED"],
|
||||
[503, "PROVIDER_UNAVAILABLE"],
|
||||
] as const)(
|
||||
"does not retry HTTP %i without a bounded server hint",
|
||||
async (status, kind) => {
|
||||
const runtime = connection(
|
||||
(async () => new Response(null, { status })) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not clamp an excessive Retry-After into an early retry", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "120" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RATE_LIMITED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct event IDs only for CURSOR recovery", async () => {
|
||||
const missingCursorId = connection(
|
||||
(async () =>
|
||||
eventStream(["data: value\n\n"])) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
missingCursorId.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const unexpectedCursorId = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: value\n\n",
|
||||
])) as typeof fetch,
|
||||
"SNAPSHOT_ONLY",
|
||||
);
|
||||
await expect(
|
||||
unexpectedCursorId.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stops the old stream after authority recovery commits", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: first\n\n",
|
||||
"id: cursor-2\ndata: stale-generation\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const onEvent = vi.fn(() =>
|
||||
realtimeSuccess(RECOVERY_OUTCOME),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: RECOVERY_OUTCOME,
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates a canonical consumer failure and aborts its event generation", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: forbidden\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
let handlerSignal: AbortSignal | undefined;
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: (_event, signal) => {
|
||||
handlerSignal = signal;
|
||||
return realtimeFailure("FORBIDDEN", "RECEIVE");
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("FORBIDDEN", "RECEIVE"),
|
||||
);
|
||||
expect(handlerSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("fences recovery immediately and waits for bounded reader cancellation", async () => {
|
||||
let releaseCancellation: (() => void) | undefined;
|
||||
let cancellationStarted = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode("id: cursor-1\ndata: first\n\n"),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancellationStarted = true;
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseCancellation = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const pending = runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: () =>
|
||||
realtimeSuccess(RECOVERY_OUTCOME),
|
||||
});
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(cancellationStarted).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
releaseCancellation?.();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: true,
|
||||
value: RECOVERY_OUTCOME,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards incomplete EOF and closes an active reader idempotently", async () => {
|
||||
const incomplete = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: incomplete\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
incomplete.read({
|
||||
resumeCursor: null,
|
||||
onEvent() {
|
||||
throw new Error("must not run");
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: true,
|
||||
retryHintMs: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pendingStream = new ReadableStream<Uint8Array>({
|
||||
pull: () => new Promise<void>(() => {}),
|
||||
});
|
||||
const active = connection(
|
||||
(async () =>
|
||||
new Response(pendingStream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const pending = active.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
active.close();
|
||||
active.close();
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
active.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches a CR-terminated blank block completed at EOF", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\rdata: final\r\r",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const received = vi.fn(() =>
|
||||
realtimeSuccess(SSE_CONTINUE),
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: received,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: false,
|
||||
},
|
||||
});
|
||||
expect(received).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: "final" }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects arbitrary endpoints and mode-incoherent cursors before fetch", async () => {
|
||||
expect(() =>
|
||||
createFetchSseConnection({
|
||||
endpoint: "https://other.example.test/events",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode: "CURSOR",
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
createFetchSseConnection({
|
||||
endpoint:
|
||||
"https://app.example.test/events?token=not-allowed",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode: "CURSOR",
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
|
||||
const fetcher = vi.fn(
|
||||
async () => eventStream(["data: unreachable\n\n"]),
|
||||
);
|
||||
const snapshotOnly = connection(
|
||||
fetcher as unknown as typeof fetch,
|
||||
"SNAPSHOT_ONLY",
|
||||
);
|
||||
await expect(
|
||||
snapshotOnly.read({
|
||||
resumeCursor: "cursor-not-allowed",
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
mappingSuccess,
|
||||
type InstalledBoundaryMapper,
|
||||
} from "../../../src/contracts/boundary-mapper.ts";
|
||||
import type { ApiOperation } from "../../../src/contracts/api-operations.ts";
|
||||
import {
|
||||
createRealtimePolicyRegistry,
|
||||
defineEventTypeId,
|
||||
defineExternalEventEffectProfileId,
|
||||
defineRealtimeEndpointId,
|
||||
defineRealtimeKillSwitchId,
|
||||
defineStreamRegistrationId,
|
||||
type RealtimeEventTypeRegistration,
|
||||
type RealtimeLimits,
|
||||
type RealtimePolicyRegistry,
|
||||
type RealtimeRecoveryProfile,
|
||||
type RealtimeStreamRegistration,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import type { RuntimeSchemaCodec } from "../../../src/contracts/schema-registry.ts";
|
||||
import {
|
||||
createRealtimeEventCodec,
|
||||
type RealtimeEventCodec,
|
||||
} from "../../../src/adapters/realtime/event-codec.ts";
|
||||
|
||||
export const STREAM_ID = defineStreamRegistrationId("REFERENCE_STREAM");
|
||||
export const EVENT_TYPE = defineEventTypeId("REFERENCE_CHANGED");
|
||||
export const ENDPOINT_ID = defineRealtimeEndpointId("REFERENCE_ENDPOINT");
|
||||
export const EFFECT_PROFILE_ID =
|
||||
defineExternalEventEffectProfileId("REFERENCE_INVALIDATE");
|
||||
export const KILL_SWITCH_ID =
|
||||
defineRealtimeKillSwitchId("REFERENCE_KILL_SWITCH");
|
||||
|
||||
export const TEST_LIMITS: RealtimeLimits = Object.freeze({
|
||||
maxEventBytes: 4_096,
|
||||
maxPayloadDepth: 8,
|
||||
maxPayloadNodes: 128,
|
||||
maxQueueEvents: 8,
|
||||
maxQueueBytes: 32_768,
|
||||
maxDedupeEntries: 16,
|
||||
maxDedupeBytes: 32_768,
|
||||
dedupeTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const eventPayloadCodec: RuntimeSchemaCodec = Object.freeze({
|
||||
schemaId: "ReferenceRealtimePayload",
|
||||
parse(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
typeof (value as Readonly<Record<string, unknown>>).value !== "string"
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
issues: [{ path: "value", code: "INVALID_TYPE" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
value: (value as Readonly<Record<string, string>>).value,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const checkpointCodec: RuntimeSchemaCodec = Object.freeze({
|
||||
schemaId: "ReferenceRealtimeCheckpoint",
|
||||
parse: (value) => ({ success: true, data: value }),
|
||||
});
|
||||
|
||||
export const TEST_SCHEMA_CODECS = Object.freeze({
|
||||
ReferenceRealtimePayload: eventPayloadCodec,
|
||||
ReferenceRealtimeCheckpoint: checkpointCodec,
|
||||
});
|
||||
|
||||
export const TEST_MAPPER: InstalledBoundaryMapper = Object.freeze({
|
||||
mapperId: "ReferenceRealtimeMapper",
|
||||
mapperVersion: 1,
|
||||
inputSchemaId: "ReferenceRealtimePayload",
|
||||
outputContractId: "ReferenceRealtimeEvent",
|
||||
owner: "sample-owner",
|
||||
maxOutputItems: 1,
|
||||
map(input) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
typeof (input as Readonly<Record<string, unknown>>).value !== "string"
|
||||
) {
|
||||
return { ok: false, code: "MAPPING_INVARIANT_REJECTED" };
|
||||
}
|
||||
return mappingSuccess(
|
||||
Object.freeze({
|
||||
value: (input as Readonly<Record<string, string>>).value,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const TEST_MAPPERS = Object.freeze({
|
||||
ReferenceRealtimeMapper: TEST_MAPPER,
|
||||
});
|
||||
|
||||
const snapshotOperation: ApiOperation = Object.freeze({
|
||||
method: "GET",
|
||||
path: "/api/reference-snapshot",
|
||||
operationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
auth: "external-session",
|
||||
timeoutMs: 10_000,
|
||||
idempotency: "safe",
|
||||
retry: "never",
|
||||
requestSource: "none",
|
||||
requestSchema: "NoRequest",
|
||||
responseSchema: "ReferenceRealtimeCheckpoint",
|
||||
owner: "sample-owner",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
replayPolicy: "SAFE",
|
||||
idempotencyKeyPolicy: "NONE",
|
||||
mapperId: "ReferenceRealtimeSnapshotMapper",
|
||||
successStatuses: [200],
|
||||
responseMediaTypes: ["application/json"],
|
||||
maxResponseBytes: 16_384,
|
||||
providerId: "PRIMARY_API",
|
||||
authProfileId: "EXTERNAL_SESSION",
|
||||
csrfProfileId: "NONE",
|
||||
pathSchema: "NoRequest",
|
||||
pathParameterNames: [],
|
||||
maxEncodedSearchBytes: 0,
|
||||
});
|
||||
|
||||
export const TEST_API_OPERATIONS = Object.freeze({
|
||||
GET_REFERENCE_REALTIME_SNAPSHOT: snapshotOperation,
|
||||
});
|
||||
|
||||
export type TestRegistryOptions = Readonly<{
|
||||
recovery?: RealtimeRecoveryProfile;
|
||||
delivery?: RealtimeStreamRegistration["delivery"];
|
||||
stateBearing?: boolean;
|
||||
limits?: RealtimeLimits;
|
||||
streamMutator?: (
|
||||
stream: RealtimeStreamRegistration,
|
||||
) => RealtimeStreamRegistration;
|
||||
eventTypeMutator?: (
|
||||
eventType: RealtimeEventTypeRegistration,
|
||||
) => RealtimeEventTypeRegistration;
|
||||
}>;
|
||||
|
||||
export function createTestRealtimeRegistry(
|
||||
options: TestRegistryOptions = {},
|
||||
): RealtimePolicyRegistry {
|
||||
const recovery =
|
||||
options.recovery ??
|
||||
({
|
||||
mode: "CURSOR",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "REPLAY",
|
||||
} as const);
|
||||
const eventType: RealtimeEventTypeRegistration = {
|
||||
id: EVENT_TYPE,
|
||||
owner: "sample-owner",
|
||||
payloadSchemaId: "ReferenceRealtimePayload",
|
||||
mapperId: "ReferenceRealtimeMapper",
|
||||
effectProfileId: EFFECT_PROFILE_ID,
|
||||
stateBearing: options.stateBearing ?? true,
|
||||
};
|
||||
const stream: RealtimeStreamRegistration = {
|
||||
id: STREAM_ID,
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
owner: "sample-owner",
|
||||
scope: "ACCOUNT_BOUND",
|
||||
primaryTransport: "SSE",
|
||||
endpointId: ENDPOINT_ID,
|
||||
eventTypeIds: [EVENT_TYPE],
|
||||
delivery: options.delivery ?? "AUTHORITATIVE_DELTA",
|
||||
recovery,
|
||||
fallback:
|
||||
recovery.mode === "SESSION_REBUILD"
|
||||
? "EXPLICITLY_STALE"
|
||||
: "BOUNDED_POLLING",
|
||||
hiddenPolicy: "CLOSE",
|
||||
limits: options.limits ?? TEST_LIMITS,
|
||||
killSwitchId: KILL_SWITCH_ID,
|
||||
};
|
||||
return createRealtimePolicyRegistry({
|
||||
streams: [options.streamMutator?.(stream) ?? stream],
|
||||
eventTypes: [
|
||||
options.eventTypeMutator?.(eventType) ?? eventType,
|
||||
],
|
||||
bindings: {
|
||||
schemaCodecs: TEST_SCHEMA_CODECS,
|
||||
mappers: TEST_MAPPERS,
|
||||
apiOperations: TEST_API_OPERATIONS,
|
||||
endpointIds: [ENDPOINT_ID],
|
||||
effectProfileIds: [EFFECT_PROFILE_ID],
|
||||
killSwitchIds: [KILL_SWITCH_ID],
|
||||
rebuildInputIds: ["referenceRealtimeRebuild"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createTestRealtimeCodec(
|
||||
registry = createTestRealtimeRegistry(),
|
||||
): RealtimeEventCodec {
|
||||
return createRealtimeEventCodec({
|
||||
registry,
|
||||
schemaCodecs: TEST_SCHEMA_CODECS,
|
||||
});
|
||||
}
|
||||
|
||||
export type EventOverrides = Readonly<{
|
||||
protocol?: unknown;
|
||||
streamId?: unknown;
|
||||
streamEpoch?: unknown;
|
||||
eventType?: unknown;
|
||||
eventId?: unknown;
|
||||
sequence?: unknown;
|
||||
recoveryMode?: unknown;
|
||||
resumeCursor?: unknown;
|
||||
occurredAt?: unknown;
|
||||
scopeBinding?: unknown;
|
||||
payload?: unknown;
|
||||
}>;
|
||||
|
||||
export function realtimeEventValue(
|
||||
overrides: EventOverrides = {},
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return {
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
streamId: STREAM_ID,
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
eventType: EVENT_TYPE,
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
recoveryMode: "CURSOR",
|
||||
resumeCursor: "cursor-00000001",
|
||||
occurredAt: "2026-07-28T01:02:03.123Z",
|
||||
scopeBinding: "scope-binding-0001",
|
||||
payload: { value: "changed" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function realtimeEventJson(
|
||||
overrides: EventOverrides = {},
|
||||
): string {
|
||||
return JSON.stringify(realtimeEventValue(overrides));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClockPort } from "../../../src/application/ports/clock-port.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import {
|
||||
createLivePollHandoffCoordinator,
|
||||
type LivePollHandoffCoordinatorDependencies,
|
||||
type LivePollHandoffLimits,
|
||||
type LiveProbeLease,
|
||||
} from "../../../src/adapters/realtime/live-poll-handoff-coordinator.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
type Sleeper = Readonly<{
|
||||
dueAt: number;
|
||||
resolve(): void;
|
||||
reject(): void;
|
||||
signal?: AbortSignal;
|
||||
onAbort(): void;
|
||||
}>;
|
||||
|
||||
class ManualClock implements ClockPort {
|
||||
current = 0;
|
||||
readonly sleepers: Sleeper[] = [];
|
||||
|
||||
now(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
let sleeper: Sleeper;
|
||||
const onAbort = () => {
|
||||
this.remove(sleeper);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
sleeper = {
|
||||
dueAt: this.current + milliseconds,
|
||||
resolve: () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
},
|
||||
reject: () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
signal,
|
||||
onAbort,
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.sleepers.push(sleeper);
|
||||
});
|
||||
}
|
||||
|
||||
advance(milliseconds: number): void {
|
||||
this.current += milliseconds;
|
||||
const ready = this.sleepers.filter(
|
||||
(sleeper) => sleeper.dueAt <= this.current,
|
||||
);
|
||||
for (const sleeper of ready) {
|
||||
this.remove(sleeper);
|
||||
sleeper.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private remove(target: Sleeper): void {
|
||||
const index = this.sleepers.indexOf(target);
|
||||
if (index >= 0) this.sleepers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const limits: LivePollHandoffLimits = Object.freeze({
|
||||
quiescenceTimeoutMs: 100,
|
||||
maxActiveQueueCount: 3,
|
||||
maxActiveQueueBytes: 30,
|
||||
maxProbeBufferedEvents: 3,
|
||||
maxProbeBufferedBytes: 30,
|
||||
maxItemBytes: 10,
|
||||
});
|
||||
|
||||
function createHarness(
|
||||
input: Readonly<{
|
||||
initial?: "LIVE" | "POLL";
|
||||
limits?: LivePollHandoffLimits;
|
||||
apply?: LivePollHandoffCoordinatorDependencies<string>["apply"];
|
||||
recover?: LivePollHandoffCoordinatorDependencies<string>["establishAuthoritativeCheckpoint"];
|
||||
}> = {},
|
||||
) {
|
||||
const clock = new ManualClock();
|
||||
const effects: string[] = [];
|
||||
const recoveries: string[] = [];
|
||||
const apply =
|
||||
input.apply ??
|
||||
vi.fn(async ({ writer, value }) => {
|
||||
effects.push(`${writer}:${value}`);
|
||||
return realtimeSuccess(undefined);
|
||||
});
|
||||
const recover =
|
||||
input.recover ??
|
||||
vi.fn(async ({ from, to }) => {
|
||||
recoveries.push(`${from}->${to}`);
|
||||
return realtimeSuccess(undefined);
|
||||
});
|
||||
const coordinator = createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: input.initial ?? "LIVE",
|
||||
authoritativeCheckpointEstablished: true,
|
||||
},
|
||||
limits: input.limits ?? limits,
|
||||
apply,
|
||||
establishAuthoritativeCheckpoint: recover,
|
||||
clock,
|
||||
});
|
||||
return { apply, clock, coordinator, effects, recover, recoveries };
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let turn = 0; turn < 12; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("live/poll authoritative writer handoff", () => {
|
||||
it("serializes effects through the one current writer lease", async () => {
|
||||
const first = deferred<RealtimeResult<void>>();
|
||||
const starts: string[] = [];
|
||||
let activeEffects = 0;
|
||||
let highWatermark = 0;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(async ({ value }) => {
|
||||
starts.push(value);
|
||||
activeEffects += 1;
|
||||
highWatermark = Math.max(highWatermark, activeEffects);
|
||||
if (value === "first") await first.promise;
|
||||
activeEffects -= 1;
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const writer = harness.coordinator.currentWriter();
|
||||
expect(writer?.writer).toBe("LIVE");
|
||||
|
||||
const firstWrite = writer!.write("first", 5);
|
||||
const secondWrite = writer!.write("second", 6);
|
||||
await flush();
|
||||
expect(starts).toEqual(["first"]);
|
||||
|
||||
first.resolve(realtimeSuccess(undefined));
|
||||
await expect(firstWrite).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "APPLIED", writer: "LIVE" },
|
||||
});
|
||||
await expect(secondWrite).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "APPLIED", writer: "LIVE" },
|
||||
});
|
||||
expect(starts).toEqual(["first", "second"]);
|
||||
expect(highWatermark).toBe(1);
|
||||
});
|
||||
|
||||
it("fails closed when a non-cooperative active writer fills the bounded tail", async () => {
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
let firstIsCurrent: (() => boolean) | undefined;
|
||||
const starts: string[] = [];
|
||||
const harness = createHarness({
|
||||
limits: {
|
||||
...limits,
|
||||
maxActiveQueueCount: 2,
|
||||
maxActiveQueueBytes: 10,
|
||||
},
|
||||
apply: vi.fn(async ({ value, signal, isCurrent }) => {
|
||||
starts.push(value);
|
||||
if (value === "first") {
|
||||
firstSignal = signal;
|
||||
firstIsCurrent = isCurrent;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
}
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const writer = harness.coordinator.currentWriter()!;
|
||||
const first = writer.write("first", 5);
|
||||
const second = writer.write("second", 5);
|
||||
await flush();
|
||||
|
||||
expect(starts).toEqual(["first"]);
|
||||
expect(firstIsCurrent?.()).toBe(true);
|
||||
await expect(writer.write("overflow", 1)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "QUEUE_OVERFLOW",
|
||||
operation: "APPLY",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
expect(firstIsCurrent?.()).toBe(false);
|
||||
expect(writer.isCurrent()).toBe(false);
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "CLOSED",
|
||||
activeWriter: null,
|
||||
});
|
||||
expect(starts).toEqual(["first"]);
|
||||
await expect(writer.write("after-close", 1)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "CLOSED" },
|
||||
});
|
||||
void first;
|
||||
void second;
|
||||
});
|
||||
|
||||
it("fences and aborts live, waits for quiescence, then recovers before activating poll", async () => {
|
||||
const liveEffect = deferred<RealtimeResult<void>>();
|
||||
const checkpoint = deferred<RealtimeResult<void>>();
|
||||
const order: string[] = [];
|
||||
let liveSignal: AbortSignal | undefined;
|
||||
let liveIsCurrent: (() => boolean) | undefined;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(async ({ writer, value, signal, isCurrent }) => {
|
||||
order.push(`apply:${writer}:${value}`);
|
||||
if (writer === "LIVE") {
|
||||
liveSignal = signal;
|
||||
liveIsCurrent = isCurrent;
|
||||
return await liveEffect.promise;
|
||||
}
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
recover: vi.fn(async ({ from, to }) => {
|
||||
order.push(`recover:${from}->${to}`);
|
||||
return await checkpoint.promise;
|
||||
}),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
const pendingEffect = live.write("in-flight", 9);
|
||||
await flush();
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
expect(liveSignal?.aborted).toBe(true);
|
||||
expect(liveIsCurrent?.()).toBe(false);
|
||||
expect(live.isCurrent()).toBe(false);
|
||||
await expect(live.write("stale", 999)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
expect(harness.recover).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
harness.coordinator.switchToPoll(),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
|
||||
liveEffect.resolve(realtimeSuccess(undefined));
|
||||
await expect(pendingEffect).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
await flush();
|
||||
expect(order).toEqual([
|
||||
"apply:LIVE:in-flight",
|
||||
"recover:LIVE->POLL",
|
||||
]);
|
||||
expect(harness.coordinator.currentWriter()).toBeNull();
|
||||
|
||||
checkpoint.resolve(realtimeSuccess(undefined));
|
||||
const result = await transition;
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { writer: "POLL" },
|
||||
});
|
||||
if (!result.ok) throw new Error("expected poll writer");
|
||||
await result.value.write("polled", 6);
|
||||
expect(order.at(-1)).toBe("apply:POLL:polled");
|
||||
expect(result.value.generation).toBeGreaterThan(live.generation);
|
||||
expect(harness.coordinator.inspect().state).toBe("POLL_ACTIVE");
|
||||
});
|
||||
|
||||
it("fails closed when the prior writer cannot quiesce before the bound", async () => {
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(
|
||||
async () =>
|
||||
await new Promise<RealtimeResult<void>>(() => {}),
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
void live.write("hung", 4);
|
||||
await flush();
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
await flush();
|
||||
expect(harness.clock.sleepers).toHaveLength(1);
|
||||
harness.clock.advance(100);
|
||||
|
||||
await expect(transition).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "RECOVER" },
|
||||
});
|
||||
expect(harness.recover).not.toHaveBeenCalled();
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "CLOSED",
|
||||
activeWriter: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps poll authoritative while probing, then recovers and drains live values serially", async () => {
|
||||
const firstLive = deferred<RealtimeResult<void>>();
|
||||
const order: string[] = [];
|
||||
let activeEffects = 0;
|
||||
let highWatermark = 0;
|
||||
const harness = createHarness({
|
||||
initial: "POLL",
|
||||
apply: vi.fn(async ({ writer, value }) => {
|
||||
activeEffects += 1;
|
||||
highWatermark = Math.max(highWatermark, activeEffects);
|
||||
order.push(`apply:${writer}:${value}`);
|
||||
if (value === "live-1") await firstLive.promise;
|
||||
activeEffects -= 1;
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
recover: vi.fn(async ({ from, to }) => {
|
||||
order.push(`recover:${from}->${to}`);
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const poll = harness.coordinator.currentWriter()!;
|
||||
const opened = harness.coordinator.beginLiveProbe();
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) throw new Error("expected live probe");
|
||||
const live = opened.value;
|
||||
|
||||
await expect(live.write("live-1", 6)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "BUFFERED" },
|
||||
});
|
||||
await live.write("live-2", 6);
|
||||
expect(live.isCurrent()).toBe(false);
|
||||
expect(poll.isCurrent()).toBe(true);
|
||||
await poll.write("poll-during-probe", 8);
|
||||
expect(order).toEqual(["apply:POLL:poll-during-probe"]);
|
||||
|
||||
const activation = live.activate();
|
||||
await vi.waitFor(() =>
|
||||
expect(order).toEqual([
|
||||
"apply:POLL:poll-during-probe",
|
||||
"recover:POLL->LIVE",
|
||||
"apply:LIVE:live-1",
|
||||
]),
|
||||
);
|
||||
await live.write("live-3", 6);
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "LIVE_PROBING",
|
||||
bufferedEvents: 2,
|
||||
transitioning: true,
|
||||
});
|
||||
|
||||
firstLive.resolve(realtimeSuccess(undefined));
|
||||
const activated = await activation;
|
||||
expect(activated).toMatchObject({
|
||||
ok: true,
|
||||
value: { writer: "LIVE" },
|
||||
});
|
||||
expect(order).toEqual([
|
||||
"apply:POLL:poll-during-probe",
|
||||
"recover:POLL->LIVE",
|
||||
"apply:LIVE:live-1",
|
||||
"apply:LIVE:live-2",
|
||||
"apply:LIVE:live-3",
|
||||
]);
|
||||
expect(highWatermark).toBe(1);
|
||||
expect(live.isCurrent()).toBe(true);
|
||||
expect(poll.isCurrent()).toBe(false);
|
||||
await expect(poll.write("stale-poll", 5)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "LIVE_ACTIVE",
|
||||
bufferedEvents: 0,
|
||||
bufferedBytes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an overflowing probe without displacing poll and never reuses its generation", async () => {
|
||||
const harness = createHarness({
|
||||
initial: "POLL",
|
||||
limits: {
|
||||
...limits,
|
||||
maxProbeBufferedEvents: 2,
|
||||
},
|
||||
});
|
||||
const poll = harness.coordinator.currentWriter()!;
|
||||
const first = expectProbe(harness.coordinator.beginLiveProbe());
|
||||
await first.write("one", 3);
|
||||
await first.write("two", 3);
|
||||
await expect(first.write("overflow", 3)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "QUEUE_OVERFLOW" },
|
||||
});
|
||||
expect(harness.coordinator.inspect().state).toBe("POLL_ACTIVE");
|
||||
expect(poll.isCurrent()).toBe(true);
|
||||
await expect(first.write("stale", 999)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
|
||||
const second = expectProbe(harness.coordinator.beginLiveProbe());
|
||||
expect(second.generation).toBeGreaterThan(first.generation);
|
||||
const canceled = second.cancel();
|
||||
expect(canceled).toMatchObject({
|
||||
ok: true,
|
||||
value: { writer: "POLL", generation: poll.generation },
|
||||
});
|
||||
expect(second.signal.aborted).toBe(true);
|
||||
expect(harness.coordinator.inspect().state).toBe("POLL_ACTIVE");
|
||||
});
|
||||
|
||||
it("closes if authoritative recovery fails and never activates the candidate", async () => {
|
||||
const harness = createHarness({
|
||||
recover: vi.fn(async () =>
|
||||
realtimeFailure("CURSOR_EXPIRED", "RECOVER"),
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
|
||||
await expect(
|
||||
harness.coordinator.switchToPoll(),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "CURSOR_EXPIRED", operation: "RECOVER" },
|
||||
});
|
||||
expect(live.signal.aborted).toBe(true);
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "CLOSED",
|
||||
activeWriter: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a non-cooperative authoritative checkpoint", async () => {
|
||||
let checkpointIsCurrent: (() => boolean) | undefined;
|
||||
const harness = createHarness({
|
||||
recover: vi.fn(async ({ isCurrent }) => {
|
||||
checkpointIsCurrent = isCurrent;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
}),
|
||||
});
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
await flush();
|
||||
expect(checkpointIsCurrent?.()).toBe(true);
|
||||
expect(harness.clock.sleepers).toHaveLength(1);
|
||||
harness.clock.advance(100);
|
||||
|
||||
await expect(transition).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "RECOVER" },
|
||||
});
|
||||
expect(checkpointIsCurrent?.()).toBe(false);
|
||||
expect(harness.coordinator.inspect().state).toBe("CLOSED");
|
||||
});
|
||||
|
||||
it("aborts and bounds close quiescence when an effect ignores cancellation", async () => {
|
||||
let effectSignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(
|
||||
async ({ signal }) => {
|
||||
effectSignal = signal;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
},
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
void live.write("hung-close", 8);
|
||||
await flush();
|
||||
|
||||
const closing = harness.coordinator.close();
|
||||
expect(effectSignal?.aborted).toBe(true);
|
||||
expect(harness.coordinator.inspect().state).toBe("CLOSED");
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid initial authority and resource ceilings", () => {
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: "LIVE",
|
||||
authoritativeCheckpointEstablished: false,
|
||||
} as never,
|
||||
limits,
|
||||
apply: async () => realtimeSuccess(undefined),
|
||||
establishAuthoritativeCheckpoint: async () =>
|
||||
realtimeSuccess(undefined),
|
||||
}),
|
||||
).toThrow(/initial checkpoint/u);
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: "POLL",
|
||||
authoritativeCheckpointEstablished: true,
|
||||
},
|
||||
limits: { ...limits, maxProbeBufferedEvents: 257 },
|
||||
apply: async () => realtimeSuccess(undefined),
|
||||
establishAuthoritativeCheckpoint: async () =>
|
||||
realtimeSuccess(undefined),
|
||||
}),
|
||||
).toThrow(/limits/u);
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: "POLL",
|
||||
authoritativeCheckpointEstablished: true,
|
||||
},
|
||||
limits: { ...limits, maxActiveQueueCount: 257 },
|
||||
apply: async () => realtimeSuccess(undefined),
|
||||
establishAuthoritativeCheckpoint: async () =>
|
||||
realtimeSuccess(undefined),
|
||||
}),
|
||||
).toThrow(/limits/u);
|
||||
});
|
||||
});
|
||||
|
||||
function expectProbe(
|
||||
result: RealtimeResult<LiveProbeLease<string>>,
|
||||
): LiveProbeLease<string> {
|
||||
if (!result.ok) throw new Error("expected live probe");
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function deferred<Value>() {
|
||||
let resolve!: (value: Value) => void;
|
||||
const promise = new Promise<Value>((selectedResolve) => {
|
||||
resolve = selectedResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
calculateReconnectDelay,
|
||||
defineReconnectPolicy,
|
||||
isReconnectAttemptResetEligible,
|
||||
parseRetryAfterDelay,
|
||||
REALTIME_RECONNECT_CEILINGS,
|
||||
reconnectBudgetRemaining,
|
||||
type ReconnectPolicy,
|
||||
} from "../../../src/adapters/realtime/reconnect-policy.ts";
|
||||
|
||||
const policy = defineReconnectPolicy({
|
||||
baseDelayMs: 1_000,
|
||||
maxDelayMs: 60_000,
|
||||
maxAttempts: 10,
|
||||
maxElapsedMs: 300_000,
|
||||
stableOpenMs: 30_000,
|
||||
});
|
||||
|
||||
describe("realtime reconnect policy", () => {
|
||||
it("uses full jitter and treats a server hint as a not-before floor", () => {
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 2,
|
||||
remainingElapsedMs: 10_000,
|
||||
random: () => 0.5,
|
||||
}),
|
||||
).toBe(2_000);
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 2,
|
||||
remainingElapsedMs: 10_000,
|
||||
random: () => 0.5,
|
||||
serverNotBeforeMs: 3_000,
|
||||
}),
|
||||
).toBe(3_000);
|
||||
});
|
||||
|
||||
it("stops instead of clamping a hint past a hard or remaining budget", () => {
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 0,
|
||||
remainingElapsedMs: 100_000,
|
||||
random: () => 0,
|
||||
serverNotBeforeMs: 60_001,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 0,
|
||||
remainingElapsedMs: 3_000,
|
||||
random: () => 0,
|
||||
serverNotBeforeMs: 3_000,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 10,
|
||||
remainingElapsedMs: 100_000,
|
||||
random: () => 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses only bounded-shape Retry-After syntax for caller validation", () => {
|
||||
expect(parseRetryAfterDelay("12", 0)).toBe(12_000);
|
||||
expect(
|
||||
parseRetryAfterDelay(
|
||||
"Thu, 01 Jan 1970 00:00:20 GMT",
|
||||
5_000,
|
||||
),
|
||||
).toBe(15_000);
|
||||
expect(parseRetryAfterDelay("1.5", 0)).toBeNull();
|
||||
expect(parseRetryAfterDelay("-1", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("resets attempts only after stable open or a valid signal", () => {
|
||||
expect(
|
||||
isReconnectAttemptResetEligible({
|
||||
policy,
|
||||
openedAtMs: 10,
|
||||
nowMs: 29_000,
|
||||
observedValidHeartbeatOrEvent: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReconnectAttemptResetEligible({
|
||||
policy,
|
||||
openedAtMs: 10,
|
||||
nowMs: 30_010,
|
||||
observedValidHeartbeatOrEvent: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReconnectAttemptResetEligible({
|
||||
policy,
|
||||
openedAtMs: 10,
|
||||
nowMs: 11,
|
||||
observedValidHeartbeatOrEvent: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(reconnectBudgetRemaining(policy, 1_000, 2_000)).toBe(
|
||||
299_000,
|
||||
);
|
||||
expect(reconnectBudgetRemaining(policy, 2_000, 1_000)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects policies above the implementation ceiling", () => {
|
||||
expect(() =>
|
||||
defineReconnectPolicy({
|
||||
...policy,
|
||||
maxAttempts: 11,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("keeps aborted-task drain below the absolute implementation ceiling", () => {
|
||||
expect(REALTIME_RECONNECT_CEILINGS.drainTimeoutMs).toBe(2_000);
|
||||
expect(
|
||||
REALTIME_RECONNECT_CEILINGS.drainTimeoutMs,
|
||||
).toBeLessThanOrEqual(
|
||||
REALTIME_RECONNECT_CEILINGS.maxDrainTimeoutMs,
|
||||
);
|
||||
expect(
|
||||
REALTIME_RECONNECT_CEILINGS.maxDrainTimeoutMs,
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("snapshots only exact own data properties", () => {
|
||||
const inherited = Object.create(policy) as ReconnectPolicy;
|
||||
const accessor = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
baseDelayMs: { enumerable: true, value: 1_000 },
|
||||
maxDelayMs: {
|
||||
enumerable: true,
|
||||
get: () => 60_000,
|
||||
},
|
||||
maxAttempts: { enumerable: true, value: 10 },
|
||||
maxElapsedMs: { enumerable: true, value: 300_000 },
|
||||
stableOpenMs: { enumerable: true, value: 30_000 },
|
||||
},
|
||||
) as ReconnectPolicy;
|
||||
|
||||
expect(() => defineReconnectPolicy(inherited)).toThrow(TypeError);
|
||||
expect(() => defineReconnectPolicy(accessor)).toThrow(TypeError);
|
||||
expect(() =>
|
||||
defineReconnectPolicy({
|
||||
...policy,
|
||||
extra: true,
|
||||
} as ReconnectPolicy),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createRealtimePolicyRegistry,
|
||||
defineEventTypeId,
|
||||
defineStreamRegistrationId,
|
||||
REALTIME_HARD_LIMITS,
|
||||
type RealtimeLimits,
|
||||
type RealtimeStreamRegistration,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
EFFECT_PROFILE_ID,
|
||||
ENDPOINT_ID,
|
||||
EVENT_TYPE,
|
||||
KILL_SWITCH_ID,
|
||||
STREAM_ID,
|
||||
TEST_API_OPERATIONS,
|
||||
TEST_LIMITS,
|
||||
TEST_MAPPERS,
|
||||
TEST_SCHEMA_CODECS,
|
||||
createTestRealtimeRegistry,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("realtime policy registry", () => {
|
||||
it("deep-snapshots registrations and resolves only stream-owned event types", () => {
|
||||
const baseline = createTestRealtimeRegistry();
|
||||
const sourceEventIds = [EVENT_TYPE];
|
||||
const sourceLimits = { ...TEST_LIMITS };
|
||||
const sourceStream: RealtimeStreamRegistration = {
|
||||
...baseline.listStreams()[0]!,
|
||||
eventTypeIds: sourceEventIds,
|
||||
limits: sourceLimits,
|
||||
};
|
||||
const sourceEventType = {
|
||||
...baseline.listEventTypes()[0]!,
|
||||
};
|
||||
const registry = createRealtimePolicyRegistry({
|
||||
streams: [sourceStream],
|
||||
eventTypes: [sourceEventType],
|
||||
bindings: bindings(),
|
||||
});
|
||||
|
||||
sourceEventIds[0] = defineEventTypeId("MUTATED_EVENT");
|
||||
sourceLimits.maxQueueEvents = 1;
|
||||
sourceEventType.owner = "mutated-owner";
|
||||
|
||||
const installed = registry.findStream(STREAM_ID);
|
||||
expect(installed).toMatchObject({
|
||||
id: STREAM_ID,
|
||||
eventTypeIds: [EVENT_TYPE],
|
||||
limits: { maxQueueEvents: TEST_LIMITS.maxQueueEvents },
|
||||
});
|
||||
expect(registry.findEventType(EVENT_TYPE)?.owner).toBe(
|
||||
"sample-owner",
|
||||
);
|
||||
expect(
|
||||
registry.findStreamEventType(STREAM_ID, EVENT_TYPE)?.id,
|
||||
).toBe(EVENT_TYPE);
|
||||
expect(
|
||||
registry.findStreamEventType(STREAM_ID, "MUTATED_EVENT"),
|
||||
).toBeUndefined();
|
||||
expect(Object.isFrozen(installed)).toBe(true);
|
||||
expect(Object.isFrozen(installed?.eventTypeIds)).toBe(true);
|
||||
expect(Object.isFrozen(installed?.limits)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicates, unknown references and extra keys", () => {
|
||||
const baseline = createTestRealtimeRegistry();
|
||||
const stream = baseline.listStreams()[0]!;
|
||||
const eventType = baseline.listEventTypes()[0]!;
|
||||
|
||||
expect(() =>
|
||||
createRealtimePolicyRegistry({
|
||||
streams: [stream, stream],
|
||||
eventTypes: [eventType],
|
||||
bindings: bindings(),
|
||||
}),
|
||||
).toThrow("stream is duplicated");
|
||||
|
||||
expect(() =>
|
||||
createRealtimePolicyRegistry({
|
||||
streams: [
|
||||
{
|
||||
...stream,
|
||||
eventTypeIds: [defineEventTypeId("UNKNOWN_EVENT")],
|
||||
},
|
||||
],
|
||||
eventTypes: [eventType],
|
||||
bindings: bindings(),
|
||||
}),
|
||||
).toThrow("stream registration is invalid");
|
||||
|
||||
expect(() =>
|
||||
createRealtimePolicyRegistry({
|
||||
streams: [
|
||||
{
|
||||
...stream,
|
||||
unregisteredOverride: true,
|
||||
} as RealtimeStreamRegistration,
|
||||
],
|
||||
eventTypes: [eventType],
|
||||
bindings: bindings(),
|
||||
}),
|
||||
).toThrow("stream registration is invalid");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
eventTypeMutator: (candidate) => ({
|
||||
...candidate,
|
||||
mapperId: "MissingMapper",
|
||||
}),
|
||||
}),
|
||||
).toThrow("event type registration is invalid");
|
||||
});
|
||||
|
||||
it("closes state-bearing, ephemeral and recovery contradictions", () => {
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SNAPSHOT_ONLY",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "NONE",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: true,
|
||||
}),
|
||||
).toThrow("recovery contract is contradictory");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "EPHEMERAL",
|
||||
stateBearing: true,
|
||||
}),
|
||||
).toThrow("recovery contract is contradictory");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: false,
|
||||
}),
|
||||
).toThrow("recovery contract is contradictory");
|
||||
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "EPHEMERAL",
|
||||
stateBearing: false,
|
||||
});
|
||||
expect(registry.findStream(STREAM_ID)?.recovery.mode).toBe(
|
||||
"SESSION_REBUILD",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows only reductions of implementation ceilings", () => {
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxQueueEvents: REALTIME_HARD_LIMITS.maxQueueEvents + 1,
|
||||
},
|
||||
}),
|
||||
).toThrow("exceed implementation ceilings");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxQueueBytes: TEST_LIMITS.maxEventBytes - 1,
|
||||
},
|
||||
}),
|
||||
).toThrow("cannot hold one event");
|
||||
|
||||
const strictLimits: RealtimeLimits = {
|
||||
...TEST_LIMITS,
|
||||
maxQueueEvents: 1,
|
||||
maxDedupeEntries: 1,
|
||||
};
|
||||
expect(
|
||||
createTestRealtimeRegistry({ limits: strictLimits }).findStream(
|
||||
STREAM_ID,
|
||||
)?.limits,
|
||||
).toMatchObject({
|
||||
maxQueueEvents: 1,
|
||||
maxDedupeEntries: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("issues only bounded closed registry IDs", () => {
|
||||
expect(defineStreamRegistrationId("VALID_STREAM")).toBe(
|
||||
"VALID_STREAM",
|
||||
);
|
||||
expect(() => defineStreamRegistrationId("arbitrary-channel")).toThrow(
|
||||
"stream ID is invalid",
|
||||
);
|
||||
expect(() => defineEventTypeId("X")).toThrow(
|
||||
"event type ID is invalid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function bindings() {
|
||||
return {
|
||||
schemaCodecs: TEST_SCHEMA_CODECS,
|
||||
mappers: TEST_MAPPERS,
|
||||
apiOperations: TEST_API_OPERATIONS,
|
||||
endpointIds: [ENDPOINT_ID],
|
||||
effectProfileIds: [EFFECT_PROFILE_ID],
|
||||
killSwitchIds: [KILL_SWITCH_ID],
|
||||
rebuildInputIds: ["referenceRealtimeRebuild"],
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isRealtimeResult,
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
snapshotRealtimeResult,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
describe("realtime result boundary", () => {
|
||||
it("uses an immutable one-shot snapshot instead of rereading a mutable result", () => {
|
||||
const source = { ok: true, value: "captured" };
|
||||
const captured = snapshotRealtimeResult(source, (value): value is string => {
|
||||
source.value = "changed-during-validation";
|
||||
return value === "captured";
|
||||
});
|
||||
source.value = "changed-after-validation";
|
||||
|
||||
expect(captured).toEqual(
|
||||
realtimeSuccess("captured"),
|
||||
);
|
||||
expect(Object.isFrozen(captured)).toBe(true);
|
||||
expect(isRealtimeResult(source, isString)).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes failure fields before the source can change", () => {
|
||||
const source = {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "FORBIDDEN",
|
||||
operation: "RECOVER",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
const captured = snapshotRealtimeResult(
|
||||
source,
|
||||
(_value): _value is never => false,
|
||||
);
|
||||
source.error.kind = "PROVIDER_UNAVAILABLE";
|
||||
source.error.operation = "CONNECT";
|
||||
source.error.retryable = true;
|
||||
|
||||
expect(captured).toEqual(
|
||||
realtimeFailure("FORBIDDEN", "RECOVER", false),
|
||||
);
|
||||
expect(
|
||||
captured && !captured.ok
|
||||
? Object.isFrozen(captured.error)
|
||||
: false,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects accessors without invoking them", () => {
|
||||
let outerReads = 0;
|
||||
const outerAccessor = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
ok: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
outerReads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
value: {
|
||||
enumerable: true,
|
||||
value: "safe",
|
||||
},
|
||||
},
|
||||
);
|
||||
let nestedReads = 0;
|
||||
const nestedAccessor = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
kind: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
nestedReads += 1;
|
||||
return "FORBIDDEN";
|
||||
},
|
||||
},
|
||||
operation: {
|
||||
enumerable: true,
|
||||
value: "RECOVER",
|
||||
},
|
||||
retryable: {
|
||||
enumerable: true,
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
snapshotRealtimeResult(outerAccessor, isString),
|
||||
).toBeNull();
|
||||
expect(
|
||||
snapshotRealtimeResult(
|
||||
{ ok: false, error: nestedAccessor },
|
||||
(_value): _value is never => false,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(outerReads).toBe(0);
|
||||
expect(nestedReads).toBe(0);
|
||||
});
|
||||
|
||||
it("consumes proxy fields only from one descriptor snapshot", () => {
|
||||
const propertyReads: PropertyKey[] = [];
|
||||
const descriptorReads = new Map<PropertyKey, number>();
|
||||
const source = new Proxy(
|
||||
{ ok: true, value: "descriptor-value" },
|
||||
{
|
||||
get(_target, key) {
|
||||
propertyReads.push(key);
|
||||
return key === "value" ? "get-trap-value" : false;
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
descriptorReads.set(
|
||||
key,
|
||||
(descriptorReads.get(key) ?? 0) + 1,
|
||||
);
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshotRealtimeResult(source, isString)).toEqual(
|
||||
realtimeSuccess("descriptor-value"),
|
||||
);
|
||||
expect(propertyReads).toEqual([]);
|
||||
expect(descriptorReads).toEqual(
|
||||
new Map<PropertyKey, number>([
|
||||
["ok", 1],
|
||||
["value", 1],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a proxy is revoked", () => {
|
||||
const revocable = Proxy.revocable(
|
||||
{ ok: true, value: "safe" },
|
||||
{},
|
||||
);
|
||||
revocable.revoke();
|
||||
|
||||
expect(
|
||||
snapshotRealtimeResult(revocable.proxy, isString),
|
||||
).toBeNull();
|
||||
expect(isRealtimeResult(revocable.proxy, isString)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ok: true, value: "safe", extra: true },
|
||||
Object.assign(
|
||||
Object.create({ inherited: true }) as Record<string, unknown>,
|
||||
{ ok: true, value: "safe" },
|
||||
),
|
||||
Object.assign(
|
||||
{ ok: true, value: "safe" },
|
||||
{ [Symbol("hidden")]: true },
|
||||
),
|
||||
])("rejects extra, inherited and symbol-key shapes", (source) => {
|
||||
expect(snapshotRealtimeResult(source, isString)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createIncrementalSseParser,
|
||||
type SseParserItem,
|
||||
} from "../../../src/adapters/realtime/sse/sse-parser.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function pushText(
|
||||
parser: ReturnType<typeof createIncrementalSseParser>,
|
||||
text: string,
|
||||
): readonly SseParserItem[] {
|
||||
const result = parser.push(encoder.encode(text));
|
||||
if (!result.ok) throw new Error(result.error.kind);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
describe("incremental SSE parser", () => {
|
||||
it("handles BOM, chunk boundaries, CR/LF/CRLF, comments and multi-line data", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
const chunks = [
|
||||
"\uFEFF: ready\r",
|
||||
"\nretry: 2500\revent: resource.updated\n",
|
||||
"id: cursor-1\r\ndata: first\rdata:second\n\n",
|
||||
];
|
||||
const items = chunks.flatMap((chunk) => pushText(parser, chunk));
|
||||
|
||||
expect(items).toEqual([
|
||||
{ kind: "COMMENT" },
|
||||
{ kind: "RETRY", retryMs: 2_500 },
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "resource.updated",
|
||||
data: "first\nsecond",
|
||||
id: "cursor-1",
|
||||
hasExplicitId: true,
|
||||
},
|
||||
]);
|
||||
expect(parser.finish()).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [],
|
||||
incompleteEventDiscarded: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves standard inherited ID state while marking direct IDs", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
const items = pushText(
|
||||
parser,
|
||||
"id: cursor-a\ndata: one\n\ndata: two\n\ndata:\n\n",
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "one",
|
||||
id: "cursor-a",
|
||||
hasExplicitId: true,
|
||||
},
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "two",
|
||||
id: "cursor-a",
|
||||
hasExplicitId: false,
|
||||
},
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "",
|
||||
id: "cursor-a",
|
||||
hasExplicitId: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores invalid ID and retry fields without inventing a cursor", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
expect(
|
||||
pushText(
|
||||
parser,
|
||||
"id: invalid\u0000cursor\nretry: 99999\ndata: value\n\n",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "value",
|
||||
id: null,
|
||||
hasExplicitId: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("discards an event that was not terminated by a blank line", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
expect(pushText(parser, "data: incomplete\n")).toEqual([]);
|
||||
expect(parser.finish()).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [],
|
||||
incompleteEventDiscarded: true,
|
||||
},
|
||||
});
|
||||
expect(parser.push(encoder.encode("data: late\n\n"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on malformed UTF-8 and parser ceilings", () => {
|
||||
const malformed = createIncrementalSseParser();
|
||||
expect(
|
||||
malformed.push(new Uint8Array([0xc3, 0x28])),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "MALFORMED_EVENT",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const longLine = createIncrementalSseParser({
|
||||
maxLineBytes: 4,
|
||||
maxEventBytes: 8,
|
||||
maxIncompleteBufferBytes: 8,
|
||||
maxRetryMs: 100,
|
||||
});
|
||||
expect(longLine.push(encoder.encode("data:"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "EVENT_TOO_LARGE",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const largeEvent = createIncrementalSseParser({
|
||||
maxLineBytes: 16,
|
||||
maxEventBytes: 8,
|
||||
maxIncompleteBufferBytes: 16,
|
||||
maxRetryMs: 100,
|
||||
});
|
||||
expect(largeEvent.push(encoder.encode("data:abc\n"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "EVENT_TOO_LARGE",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const excessiveBatch = createIncrementalSseParser({
|
||||
maxItemsPerChunk: 2,
|
||||
});
|
||||
expect(excessiveBatch.push(encoder.encode(":\n:\n:\n"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "QUEUE_OVERFLOW",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const oversizedChunk = createIncrementalSseParser({
|
||||
maxChunkBytes: 65_536,
|
||||
});
|
||||
expect(
|
||||
oversizedChunk.push(new Uint8Array(65_537)),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "EVENT_TOO_LARGE",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,975 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
ExternalRealtimeEventContext,
|
||||
RealtimeEventAuthority,
|
||||
RealtimeObservation,
|
||||
RealtimeRecoveryCommit,
|
||||
RealtimeRecoveryRequest,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import {
|
||||
createRealtimeStreamCoordinator,
|
||||
} from "../../../src/adapters/realtime/stream-coordinator.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
import type { ValidatedRealtimeEventDto } from "../../../src/adapters/realtime/event-codec.ts";
|
||||
import type { RealtimePolicyRegistry } from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
STREAM_ID,
|
||||
TEST_LIMITS,
|
||||
TEST_MAPPER,
|
||||
TEST_MAPPERS,
|
||||
createTestRealtimeCodec,
|
||||
createTestRealtimeRegistry,
|
||||
realtimeEventJson,
|
||||
type EventOverrides,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("transport-independent realtime stream coordinator", () => {
|
||||
it("applies one stream sequentially and commits each cursor after its effect", async () => {
|
||||
const first = deferred<RealtimeResult<void>>();
|
||||
const applied: string[] = [];
|
||||
const harness = createHarness({
|
||||
async apply(_profile, value) {
|
||||
const selected = (value as Readonly<{ value: string }>).value;
|
||||
applied.push(selected);
|
||||
return selected === "first"
|
||||
? first.promise
|
||||
: realtimeSuccess(undefined);
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const firstResult = harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
resumeCursor: "cursor-00000001",
|
||||
payload: { value: "first" },
|
||||
}),
|
||||
);
|
||||
const secondResult = harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
payload: { value: "second" },
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(applied).toEqual(["first"]));
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: "cursor-snapshot-0",
|
||||
});
|
||||
|
||||
first.resolve(realtimeSuccess(undefined));
|
||||
await expect(firstResult).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
await expect(secondResult).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
expect(applied).toEqual(["first", "second"]);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toEqual({
|
||||
recoveryMode: "CURSOR",
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
lastAppliedSequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops exact duplicates and stale events without replaying effects", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.initialize();
|
||||
const event = harness.event();
|
||||
|
||||
await harness.coordinator.accept(event);
|
||||
await expect(harness.coordinator.accept(event)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "DROPPED",
|
||||
reason: "DUPLICATE_EVENT",
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-stale-0001",
|
||||
sequence: "0",
|
||||
resumeCursor: "cursor-stale-0001",
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "DROPPED",
|
||||
reason: "STALE_EVENT",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
expect(harness.coordinator.inspect(STREAM_ID).dedupeEntries).toBe(1);
|
||||
});
|
||||
|
||||
it("recovers without applying a conflicting ID, sequence gap or epoch", async () => {
|
||||
for (const [overrides, expectedReason] of [
|
||||
[
|
||||
{
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
payload: { value: "conflict" },
|
||||
},
|
||||
"EVENT_CONFLICT",
|
||||
],
|
||||
[
|
||||
{
|
||||
eventId: "event-gap-000001",
|
||||
sequence: "3",
|
||||
resumeCursor: "cursor-gap-000001",
|
||||
},
|
||||
"SEQUENCE_GAP",
|
||||
],
|
||||
[
|
||||
{
|
||||
eventId: "event-epoch-0001",
|
||||
sequence: "2",
|
||||
streamEpoch: "stream-epoch-0002",
|
||||
resumeCursor: "cursor-epoch-0001",
|
||||
},
|
||||
"STREAM_EPOCH_CHANGED",
|
||||
],
|
||||
] as const) {
|
||||
const harness = createHarness();
|
||||
await harness.initialize();
|
||||
await harness.coordinator.accept(harness.event());
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event(overrides)),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: expectedReason,
|
||||
},
|
||||
});
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
expectedReason,
|
||||
]);
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply or advance when mapping fails", async () => {
|
||||
const harness = createHarness({
|
||||
mappers: {
|
||||
ReferenceRealtimeMapper: {
|
||||
...TEST_MAPPER,
|
||||
map: () => ({
|
||||
ok: false as const,
|
||||
code: "MAPPING_INVARIANT_REJECTED" as const,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "MAPPING_CONTRACT_VIOLATION",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).not.toHaveBeenCalled();
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
"MAPPING_CONTRACT_VIOLATION",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the prior checkpoint when an effect and its recovery fail", async () => {
|
||||
let recoveryCount = 0;
|
||||
const harness = createHarness({
|
||||
apply: async () => realtimeFailure("APPLY_FAILED", "APPLY"),
|
||||
recover: async () => {
|
||||
recoveryCount += 1;
|
||||
return recoveryCount === 1
|
||||
? realtimeSuccess(snapshotCommit("0"))
|
||||
: realtimeFailure("PROVIDER_UNAVAILABLE", "RECOVER");
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROVIDER_UNAVAILABLE",
|
||||
operation: "RECOVER",
|
||||
},
|
||||
});
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: "cursor-snapshot-0",
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"UNKNOWN",
|
||||
);
|
||||
await harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
);
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects malformed authority results without advancing a checkpoint", async () => {
|
||||
let recoveryCount = 0;
|
||||
const harness = createHarness({
|
||||
apply: async () =>
|
||||
({
|
||||
ok: true,
|
||||
value: "not-void",
|
||||
}) as unknown as RealtimeResult<void>,
|
||||
recover: async () => {
|
||||
recoveryCount += 1;
|
||||
return recoveryCount === 1
|
||||
? realtimeSuccess(snapshotCommit("0"))
|
||||
: ({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "FORBIDDEN",
|
||||
operation: "APPLY",
|
||||
retryable: false,
|
||||
},
|
||||
} as RealtimeResult<RealtimeRecoveryCommit>);
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"),
|
||||
);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "0",
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"UNKNOWN",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects recovery accessors without invoking them", async () => {
|
||||
let checkpointReads = 0;
|
||||
const accessorCommit = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
checkpoint: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
checkpointReads += 1;
|
||||
return snapshotCommit("0");
|
||||
},
|
||||
},
|
||||
kind: {
|
||||
enumerable: true,
|
||||
value: "SNAPSHOT_RESET",
|
||||
},
|
||||
},
|
||||
) as RealtimeRecoveryCommit;
|
||||
const harness = createHarness({
|
||||
recover: async () => realtimeSuccess(accessorCommit),
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.coordinator.recover(STREAM_ID, "INITIALIZE"),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"),
|
||||
);
|
||||
expect(checkpointReads).toBe(0);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("commits only the one-shot descriptor snapshot of a recovery proxy", async () => {
|
||||
const descriptorCommit = snapshotCommit("0");
|
||||
const propertyReads: PropertyKey[] = [];
|
||||
const proxyCommit = new Proxy(descriptorCommit, {
|
||||
get(_target, key) {
|
||||
propertyReads.push(key);
|
||||
if (key === "kind") return "SESSION_REBUILD";
|
||||
if (key === "checkpoint") return snapshotCommit("99");
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const harness = createHarness({
|
||||
recover: async () => realtimeSuccess(proxyCommit),
|
||||
});
|
||||
|
||||
const recovered = await harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
|
||||
expect(recovered).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: "cursor-snapshot-0",
|
||||
},
|
||||
});
|
||||
expect(propertyReads).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Object.freeze({
|
||||
...snapshotCommit("0"),
|
||||
unexpected: true,
|
||||
}),
|
||||
Object.assign(
|
||||
Object.create({ inherited: true }) as Record<string, unknown>,
|
||||
snapshotCommit("0"),
|
||||
),
|
||||
])(
|
||||
"rejects extra and inherited recovery commit shapes",
|
||||
async (commit) => {
|
||||
const harness = createHarness({
|
||||
recover: async () =>
|
||||
realtimeSuccess(commit as RealtimeRecoveryCommit),
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.coordinator.recover(STREAM_ID, "INITIALIZE"),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"),
|
||||
);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("expires effect and recovery commit leases when callbacks settle", async () => {
|
||||
let effectContext: ExternalRealtimeEventContext | undefined;
|
||||
let recoveryRequest: RealtimeRecoveryRequest | undefined;
|
||||
const harness = createHarness({
|
||||
async apply(_profile, _value, context) {
|
||||
effectContext = context;
|
||||
expect(context.isCurrent()).toBe(true);
|
||||
return realtimeSuccess(undefined);
|
||||
},
|
||||
async recover(request) {
|
||||
recoveryRequest = request;
|
||||
expect(request.isCurrent()).toBe(true);
|
||||
return realtimeSuccess(snapshotCommit("0"));
|
||||
},
|
||||
});
|
||||
|
||||
await harness.initialize();
|
||||
expect(recoveryRequest?.isCurrent()).toBe(false);
|
||||
await harness.coordinator.accept(harness.event());
|
||||
expect(effectContext?.isCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it("fences an in-flight successful effect before cursor commit", async () => {
|
||||
const pending = deferred<RealtimeResult<void>>();
|
||||
let effectContext: ExternalRealtimeEventContext | undefined;
|
||||
const harness = createHarness({
|
||||
apply: async (_profile, _value, context) => {
|
||||
effectContext = context;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const accepted = harness.coordinator.accept(harness.event());
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.effects).toHaveBeenCalledOnce(),
|
||||
);
|
||||
harness.setCurrent(false);
|
||||
expect(effectContext?.isCurrent()).toBe(false);
|
||||
pending.resolve(realtimeSuccess(undefined));
|
||||
|
||||
await expect(accepted).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "DROPPED",
|
||||
reason: "SCOPE_FENCED",
|
||||
},
|
||||
});
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
expect(harness.coordinator.inspect(STREAM_ID)).toMatchObject({
|
||||
freshness: "UNKNOWN",
|
||||
dedupeEntries: 0,
|
||||
queuedEvents: 0,
|
||||
awaitingTransportBarrier: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts and quiesces the active effect before queue-overflow recovery", async () => {
|
||||
const pending = deferred<RealtimeResult<void>>();
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const registry = createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxQueueEvents: 1,
|
||||
},
|
||||
});
|
||||
const harness = createHarness({
|
||||
registry,
|
||||
async apply(_profile, _value, _context, signal) {
|
||||
capturedSignal = signal;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const first = harness.coordinator.accept(harness.event());
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.effects).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const overflow = harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
expect(harness.recoveryReasons).toEqual(["INITIALIZE"]);
|
||||
pending.resolve(realtimeSuccess(undefined));
|
||||
await expect(first).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "DROPPED" },
|
||||
});
|
||||
await expect(overflow).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "QUEUE_OVERFLOW",
|
||||
},
|
||||
});
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
"QUEUE_OVERFLOW",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a current scope mismatch as a protocol violation, not a stale callback", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({ scopeBinding: "other-scope-binding" }),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "SCOPE_PROTOCOL_VIOLATION",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).not.toHaveBeenCalled();
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
"SCOPE_PROTOCOL_VIOLATION",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never claims CURRENT for a SNAPSHOT_ONLY stream without a barrier", async () => {
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SNAPSHOT_ONLY",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "NONE",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: false,
|
||||
});
|
||||
const harness = createHarness({
|
||||
registry,
|
||||
recover: async () =>
|
||||
realtimeSuccess({
|
||||
kind: "SNAPSHOT_RESET",
|
||||
checkpoint: {
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: null,
|
||||
snapshotRevision: "snapshot-revision-0",
|
||||
},
|
||||
}),
|
||||
});
|
||||
await harness.initialize();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
resumeCursor: null,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
});
|
||||
|
||||
it("requires an exact transport barrier before promoting a recovered stream to CURRENT", async () => {
|
||||
const harness = createHarness();
|
||||
const recovered = await harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
expect(recovered.ok).toBe(true);
|
||||
if (!recovered.ok) return;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(harness.coordinator.inspect(STREAM_ID)).toMatchObject({
|
||||
freshness: "STALE",
|
||||
awaitingTransportBarrier: true,
|
||||
});
|
||||
expect(
|
||||
harness.coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
Object.freeze({
|
||||
...recovered.value,
|
||||
}) as typeof recovered.value,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
const publicResumeState =
|
||||
harness.coordinator.getResumeState(STREAM_ID);
|
||||
expect(publicResumeState).not.toBe(recovered.value);
|
||||
expect(
|
||||
harness.coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
publicResumeState as typeof recovered.value,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(
|
||||
harness.coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
recovered.value,
|
||||
),
|
||||
).toEqual(realtimeSuccess(undefined));
|
||||
expect(harness.coordinator.inspect(STREAM_ID)).toMatchObject({
|
||||
freshness: "CURRENT",
|
||||
awaitingTransportBarrier: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a successful invalidation hint stale until authoritative refresh", async () => {
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SNAPSHOT_ONLY",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "CONNECT_BUFFER",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: false,
|
||||
});
|
||||
const harness = createHarness({
|
||||
registry,
|
||||
recover: async () =>
|
||||
realtimeSuccess({
|
||||
kind: "SNAPSHOT_RESET",
|
||||
checkpoint: {
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: null,
|
||||
snapshotRevision: "snapshot-revision-0",
|
||||
},
|
||||
}),
|
||||
});
|
||||
await harness.initialize();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"CURRENT",
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
resumeCursor: null,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
});
|
||||
|
||||
it("recovers instead of evicting and continuing after dedupe capacity", async () => {
|
||||
const harness = createHarness({
|
||||
registry: createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxDedupeEntries: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await harness.initialize();
|
||||
await harness.coordinator.accept(harness.event());
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "DEDUPE_OVERFLOW",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
expect(harness.coordinator.inspect(STREAM_ID).dedupeEntries).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a regressing same-epoch recovery checkpoint atomically", async () => {
|
||||
let checkpoint = "5";
|
||||
const harness = createHarness({
|
||||
recover: async () => realtimeSuccess(snapshotCommit(checkpoint)),
|
||||
});
|
||||
await harness.initialize();
|
||||
checkpoint = "4";
|
||||
|
||||
await expect(
|
||||
harness.coordinator.recover(STREAM_ID, "CURSOR_EXPIRED"),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces recovery, closes idempotently and rejects forged DTOs", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
const recover = vi.fn(async () => pending.promise);
|
||||
const harness = createHarness({ recover });
|
||||
|
||||
const first = harness.coordinator.recover(STREAM_ID, "INITIALIZE");
|
||||
const second = harness.coordinator.recover(STREAM_ID, "SEQUENCE_GAP");
|
||||
await vi.waitFor(() => expect(recover).toHaveBeenCalledOnce());
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
await expect(first).resolves.toMatchObject({ ok: true });
|
||||
await expect(second).resolves.toMatchObject({ ok: true });
|
||||
|
||||
harness.coordinator.close();
|
||||
harness.coordinator.close();
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
Object.freeze({}) as ValidatedRealtimeEventDto,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { outcome: "DROPPED", reason: "CLOSED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the exact shared checkpoint to an event waiting on active recovery", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
const recover = vi.fn(async () => pending.promise);
|
||||
const harness = createHarness({ recover });
|
||||
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await vi.waitFor(() => expect(recover).toHaveBeenCalledOnce());
|
||||
const accepting = harness.coordinator.accept(harness.event());
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
|
||||
const recovered = await recovering;
|
||||
const accepted = await accepting;
|
||||
expect(recovered.ok).toBe(true);
|
||||
expect(accepted.ok).toBe(true);
|
||||
if (!recovered.ok || !accepted.ok) return;
|
||||
expect(accepted.value).toMatchObject({
|
||||
outcome: "RECOVERED",
|
||||
reason: "INITIALIZE",
|
||||
});
|
||||
if (accepted.value.outcome !== "RECOVERED") return;
|
||||
expect(accepted.value.resumeState).toBe(recovered.value);
|
||||
});
|
||||
|
||||
it("aborts only an event waiter without cancelling shared recovery", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
let recoverySignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
recover: async (request) => {
|
||||
recoverySignal = request.signal;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await vi.waitFor(() => expect(recoverySignal).toBeDefined());
|
||||
const controller = new AbortController();
|
||||
const accepting = harness.coordinator.accept(
|
||||
harness.event(),
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
await expect(accepting).resolves.toEqual(
|
||||
realtimeFailure("ABORTED", "RECEIVE"),
|
||||
);
|
||||
expect(recoverySignal?.aborted).toBe(false);
|
||||
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
await expect(recovering).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("aborts the active recovery authority when closed", async () => {
|
||||
let recoverySignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
recover: async (request) => {
|
||||
recoverySignal = request.signal;
|
||||
return new Promise((resolve) => {
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(realtimeFailure("ABORTED", "RECOVER")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await vi.waitFor(() => expect(recoverySignal).toBeDefined());
|
||||
harness.coordinator.close();
|
||||
|
||||
expect(recoverySignal?.aborted).toBe(true);
|
||||
await expect(recovering).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "CLOSED", operation: "RECOVER" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not commit a non-cooperative recovery after caller cancellation", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
let recoveryRequest: RealtimeRecoveryRequest | undefined;
|
||||
const harness = createHarness({
|
||||
recover: async (request) => {
|
||||
recoveryRequest = request;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(recoveryRequest).toBeDefined());
|
||||
|
||||
controller.abort();
|
||||
expect(recoveryRequest?.isCurrent()).toBe(false);
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
|
||||
await expect(recovering).resolves.toEqual(
|
||||
realtimeFailure("ABORTED", "RECOVER"),
|
||||
);
|
||||
expect(recoveryRequest?.signal.aborted).toBe(true);
|
||||
expect(recoveryRequest?.isCurrent()).toBe(false);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"UNKNOWN",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits only closed redacted observations and ignores sink failure", async () => {
|
||||
const observations: RealtimeObservation[] = [];
|
||||
const harness = createHarness({
|
||||
observe(observation) {
|
||||
observations.push(observation);
|
||||
throw new Error("diagnostic sink unavailable");
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
await harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "sensitive-event-id",
|
||||
resumeCursor: "sensitive-cursor",
|
||||
payload: { value: "sensitive-payload" },
|
||||
}),
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(observations);
|
||||
expect(serialized).not.toContain("sensitive-event-id");
|
||||
expect(serialized).not.toContain("sensitive-cursor");
|
||||
expect(serialized).not.toContain("sensitive-payload");
|
||||
expect(serialized).not.toContain("scope-binding-0001");
|
||||
expect(observations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operation: "RECOVER",
|
||||
outcome: "RECOVERED",
|
||||
reason: "INITIALIZE",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
operation: "APPLY",
|
||||
outcome: "APPLIED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
type HarnessOptions = Readonly<{
|
||||
registry?: RealtimePolicyRegistry;
|
||||
mappers?: typeof TEST_MAPPERS | Readonly<Record<string, typeof TEST_MAPPER>>;
|
||||
apply?: RealtimeEventAuthority["effects"]["apply"];
|
||||
recover?: (
|
||||
request: RealtimeRecoveryRequest,
|
||||
) => Promise<RealtimeResult<RealtimeRecoveryCommit>>;
|
||||
observe?: (observation: RealtimeObservation) => void;
|
||||
}>;
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const registry = options.registry ?? createTestRealtimeRegistry();
|
||||
const codec = createTestRealtimeCodec(registry);
|
||||
let current = true;
|
||||
const recoveryReasons: string[] = [];
|
||||
let defaultRecoveryCount = 0;
|
||||
const effects = vi.fn(
|
||||
options.apply ??
|
||||
(async () => realtimeSuccess(undefined)),
|
||||
);
|
||||
const recover = vi.fn(
|
||||
options.recover ??
|
||||
(async (request: RealtimeRecoveryRequest) => {
|
||||
recoveryReasons.push(request.reason);
|
||||
defaultRecoveryCount += 1;
|
||||
return realtimeSuccess(
|
||||
snapshotCommit(
|
||||
"0",
|
||||
`stream-epoch-${String(defaultRecoveryCount).padStart(4, "0")}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const authority: RealtimeEventAuthority = {
|
||||
effects: { apply: effects },
|
||||
recovery: {
|
||||
async recover(request) {
|
||||
if (options.recover) recoveryReasons.push(request.reason);
|
||||
return recover(request);
|
||||
},
|
||||
},
|
||||
};
|
||||
const coordinator = createRealtimeStreamCoordinator({
|
||||
registry,
|
||||
mappers: options.mappers ?? TEST_MAPPERS,
|
||||
authority,
|
||||
scope: {
|
||||
generation: 7,
|
||||
scopeBinding: "scope-binding-0001",
|
||||
isCurrent: () => current,
|
||||
},
|
||||
now: () => 10_000,
|
||||
observe: options.observe,
|
||||
});
|
||||
|
||||
return {
|
||||
coordinator,
|
||||
effects,
|
||||
recoveryReasons,
|
||||
event(overrides: EventOverrides = {}) {
|
||||
const decoded = codec.decode(realtimeEventJson(overrides));
|
||||
if (!decoded.ok) {
|
||||
throw new Error(`test event failed: ${decoded.error.kind}`);
|
||||
}
|
||||
return decoded.value;
|
||||
},
|
||||
async initialize() {
|
||||
const result = await coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await Promise.resolve();
|
||||
if (
|
||||
result.ok &&
|
||||
coordinator.inspect(STREAM_ID).awaitingTransportBarrier
|
||||
) {
|
||||
const confirmed = coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
result.value,
|
||||
);
|
||||
if (!confirmed.ok) {
|
||||
throw new Error("test transport barrier failed");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
setCurrent(value: boolean) {
|
||||
current = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotCommit(
|
||||
sequence: string,
|
||||
streamEpoch = "stream-epoch-0001",
|
||||
): RealtimeRecoveryCommit {
|
||||
return Object.freeze({
|
||||
kind: "SNAPSHOT_RESET",
|
||||
checkpoint: Object.freeze({
|
||||
recoveryMode: "CURSOR",
|
||||
streamEpoch,
|
||||
lastAppliedSequence: sequence,
|
||||
resumeCursor: `cursor-snapshot-${sequence}`,
|
||||
snapshotRevision: `snapshot-revision-${sequence}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<Value>() {
|
||||
let resolve!: (value: Value) => void;
|
||||
const promise = new Promise<Value>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
REALTIME_WEBSOCKET_PROTOCOL,
|
||||
decodeWebSocketServerFrame,
|
||||
encodeWebSocketClientFrame,
|
||||
nextUnsignedSequence,
|
||||
type WebSocketAdvertisedLimits,
|
||||
type WebSocketSubscribeFrame,
|
||||
} from "../../../src/adapters/realtime/websocket/websocket-protocol.ts";
|
||||
|
||||
const LIMITS: WebSocketAdvertisedLimits = Object.freeze({
|
||||
maxFrameBytes: 65_536,
|
||||
maxSubscriptions: 32,
|
||||
maxInboundQueueCount: 256,
|
||||
maxInboundQueueBytes: 4 * 1_024 * 1_024,
|
||||
maxOutboundQueueCount: 128,
|
||||
maxOutboundQueueBytes: 256 * 1_024,
|
||||
maxBufferedAmountBytes: 256 * 1_024,
|
||||
maxEventsPerSecond: 128,
|
||||
});
|
||||
|
||||
function encode(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
describe("realtime WebSocket protocol", () => {
|
||||
it("decodes and freezes an exact WELCOME frame", () => {
|
||||
const result = decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "WELCOME",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
connectionId: "connection.0001",
|
||||
heartbeatMs: 15_000,
|
||||
heartbeatAckTimeoutMs: 5_000,
|
||||
limits: LIMITS,
|
||||
}),
|
||||
65_536,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
type: "WELCOME",
|
||||
limits: { maxSubscriptions: 32 },
|
||||
},
|
||||
});
|
||||
if (!result.ok) throw new Error("Expected WELCOME to decode.");
|
||||
if (result.value.type !== "WELCOME") {
|
||||
throw new Error("Expected the WELCOME discriminant.");
|
||||
}
|
||||
expect(Object.isFrozen(result.value)).toBe(true);
|
||||
expect(Object.isFrozen(result.value.limits)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extra keys, unknown frames, wrong versions and binary data", () => {
|
||||
const welcome = {
|
||||
type: "WELCOME",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
connectionId: "connection.0001",
|
||||
heartbeatMs: 15_000,
|
||||
heartbeatAckTimeoutMs: 5_000,
|
||||
limits: LIMITS,
|
||||
};
|
||||
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...welcome, credential: "must-not-cross" }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...welcome, protocol: "realtime.v2" }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "COMMAND",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
}),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "UNKNOWN_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(new Uint8Array([1, 2, 3]), 65_536),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "BINARY_FRAME" },
|
||||
});
|
||||
|
||||
const duplicateTopLevel = encode(welcome).replace(
|
||||
'{"type":"WELCOME",',
|
||||
'{"\\u0074ype":"WELCOME","type":"WELCOME",',
|
||||
);
|
||||
expect(
|
||||
decodeWebSocketServerFrame(duplicateTopLevel, 65_536),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
const duplicateNested = encode({
|
||||
type: "EVENT",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
envelope: { streamId: "orders.v1" },
|
||||
}).replace(
|
||||
'"streamId":"orders.v1"',
|
||||
'"streamId":"orders.v1","\\u0073treamId":"shadowed"',
|
||||
);
|
||||
expect(
|
||||
decodeWebSocketServerFrame(duplicateNested, 65_536),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces frame bytes and canonical uint64 sequences", () => {
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "SUBSCRIBED",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
acceptedCursor: "cursor.0001",
|
||||
nextExpectedSequence: "01",
|
||||
}),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "HEARTBEAT_ACK",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
nonce: "nonce.0001",
|
||||
}),
|
||||
8,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "FRAME_TOO_LARGE" },
|
||||
});
|
||||
expect(nextUnsignedSequence("0")).toBe("1");
|
||||
expect(nextUnsignedSequence("18446744073709551614")).toBe(
|
||||
"18446744073709551615",
|
||||
);
|
||||
expect(nextUnsignedSequence("18446744073709551615")).toBeNull();
|
||||
expect(nextUnsignedSequence("01")).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes only an exact UNSUBSCRIBED acknowledgement", () => {
|
||||
const acknowledgement = {
|
||||
type: "UNSUBSCRIBED",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
};
|
||||
const decoded = decodeWebSocketServerFrame(
|
||||
encode(acknowledgement),
|
||||
65_536,
|
||||
);
|
||||
|
||||
expect(decoded).toMatchObject({
|
||||
ok: true,
|
||||
value: acknowledgement,
|
||||
});
|
||||
if (!decoded.ok) {
|
||||
throw new Error("Expected UNSUBSCRIBED to decode.");
|
||||
}
|
||||
expect(Object.isFrozen(decoded.value)).toBe(true);
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...acknowledgement, released: true }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...acknowledgement, subscriptionId: "" }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects deeply nested or structurally excessive event envelopes without recursion", () => {
|
||||
let nested: unknown = "leaf";
|
||||
for (let depth = 0; depth < 40; depth += 1) {
|
||||
nested = [nested];
|
||||
}
|
||||
const event = (envelope: unknown) =>
|
||||
encode({
|
||||
type: "EVENT",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
envelope,
|
||||
});
|
||||
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
event({ payload: nested }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
event({
|
||||
payload: Array.from({ length: 4_097 }, () => ({})),
|
||||
}),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes only closed client frames without leaking arbitrary commands", () => {
|
||||
const frame: WebSocketSubscribeFrame = {
|
||||
type: "SUBSCRIBE",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
streamId: "orders.v1",
|
||||
cursor: "cursor.0001",
|
||||
scopeBinding: "scope-binding.0001",
|
||||
};
|
||||
const result = encodeWebSocketClientFrame(frame, 65_536);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("Expected SUBSCRIBE to encode.");
|
||||
expect(JSON.parse(result.value)).toEqual(frame);
|
||||
expect(
|
||||
encodeWebSocketClientFrame(
|
||||
{ ...frame, payload: "arbitrary" } as WebSocketSubscribeFrame,
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user