import { describe, expect, it, vi } from "vitest"; import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts"; import type { ClientScopeLifecycleEvent } from "../../src/contracts/server-state-scope.ts"; describe("server-state session generation runtime", () => { it("fences the old generation synchronously and publishes READY after the reset order", async () => { let sessionListener: () => void = () => {}; let completeReset: () => void = () => {}; const reset = new Promise((resolve) => { completeReset = resolve; }); const resetLocal = vi.fn(() => reset); const participantOrder: string[] = []; let tokenSequence = 0; const runtime = createServerStateScopeRuntime({ session: { subscribe(listener) { sessionListener = listener; return () => {}; }, }, queryInvalidation: { resetLocal, }, participants: [ { order: 9, label: "realtime", close: () => void participantOrder.push("realtime"), }, { order: 4, label: "admission", close: () => void participantOrder.push("admission"), }, ], tokenFactory: () => `scope-token-${String(tokenSequence++).padStart(8, "0")}`, }); const changed = vi.fn(); const lifecycle: ClientScopeLifecycleEvent[] = []; runtime.subscribe(changed); runtime.subscribeLifecycle((event) => lifecycle.push(event)); const before = runtime.getSnapshot(); const identity = before.identities.intern({ id: "private" }); identity.acquire(); sessionListener(); // §10.6 steps 1-3 are synchronous: the old snapshot is immediately stale, // FENCED is published, and subscribers are notified before any await. expect(before.isCurrent()).toBe(false); expect(before.signal.aborted).toBe(true); expect(runtime.getPhase()).toBe("FENCED"); expect(lifecycle[0]).toEqual({ kind: "FENCED", previousGeneration: 1 }); expect(changed).toHaveBeenCalledOnce(); // Nothing may read as current while the scope is fenced. expect(runtime.getSnapshot().isCurrent()).toBe(false); await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce()); // Participants close in §10.6 step order, before the local cache reset. expect(participantOrder).toEqual(["admission", "realtime"]); completeReset(); await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY")); const after = runtime.getSnapshot(); expect(after.generation).toBe(before.generation + 1); expect(after.fingerprint).not.toBe(before.fingerprint); expect(after.isCurrent()).toBe(true); expect(after.signal.aborted).toBe(false); expect(after.signal).not.toBe(before.signal); expect(before.identities.inspect().closed).toBe(true); expect(lifecycle.at(-1)).toMatchObject({ kind: "READY" }); expect(changed).toHaveBeenCalledTimes(2); runtime.dispose(); expect(runtime.getPhase()).toBe("DISPOSED"); expect(after.identities.inspect().closed).toBe(true); expect(after.isCurrent()).toBe(false); }); it("does not let a throwing snapshot subscriber prevent reset", async () => { let sessionListener: () => void = () => {}; const resetLocal = vi.fn(async () => {}); const runtime = createServerStateScopeRuntime({ session: { subscribe(listener) { sessionListener = listener; return () => {}; }, }, queryInvalidation: { resetLocal, }, tokenFactory: () => "scope-listener-token-0001", }); runtime.subscribe(() => { throw new Error("subscriber defect"); }); expect(() => sessionListener()).not.toThrow(); await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(runtime.getPhase()).toBe("READY")); }); it("remains failed when a mandatory participant cannot close", async () => { let sessionListener: () => void = () => {}; const resetLocal = vi.fn(async () => {}); const runtime = createServerStateScopeRuntime({ session: { subscribe(listener) { sessionListener = listener; return () => {}; }, }, queryInvalidation: { resetLocal, }, participants: [ { order: 4, label: "mandatory-admission", close: async () => { throw new Error("close failed"); }, }, ], tokenFactory: () => "scope-participant-token-0001", }); sessionListener(); await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(runtime.getPhase() as string).toBe("FAILED"), ); expect(runtime.getSnapshot().isCurrent()).toBe(false); }); it("remains failed when local cache reset rejects", async () => { let sessionListener: () => void = () => {}; const runtime = createServerStateScopeRuntime({ session: { subscribe(listener) { sessionListener = listener; return () => {}; }, }, queryInvalidation: { resetLocal: async () => { throw new Error("reset failed"); }, }, tokenFactory: () => "scope-reset-token-0000001", }); sessionListener(); await vi.waitFor(() => expect(runtime.getPhase() as string).toBe("FAILED"), ); expect(runtime.getSnapshot().isCurrent()).toBe(false); }); it("activates the next generation after reset and fails closed on activation error", async () => { let sessionListener: () => void = () => {}; const steps: string[] = []; const runtime = createServerStateScopeRuntime({ session: { subscribe(listener) { sessionListener = listener; return () => {}; }, }, queryInvalidation: { resetLocal: async () => { steps.push("reset"); }, }, activateNextGeneration: async () => { steps.push("activate"); throw new Error("activation failed"); }, tokenFactory: () => "scope-activation-token-001", } as Parameters[0] & { activateNextGeneration(): Promise; }); sessionListener(); await vi.waitFor(() => expect(steps).toEqual(["reset", "activate"])); await vi.waitFor(() => expect(runtime.getPhase() as string).toBe("FAILED"), ); }); });