import type { AuthSessionPort } from "../../application/ports/auth-session-port.ts"; import type { QueryInvalidationCoordinator } from "../../contracts/query-invalidation.ts"; import { createRuntimeIdentityRegistry, type RuntimeIdentityRegistry, } from "../../contracts/query-keys.ts"; import type { CacheScopeSnapshot, ClientScopeLifecycleEvent, ClientScopePhase, ServerStateScopeRuntime, } from "../../contracts/server-state-scope.ts"; /** * Steps 4-11 of §10.6 that this runtime does not own directly. Each optional * capability registers its own closer so the ordering lives in one place rather * than being re-derived by every subsystem. */ export type ScopeResetParticipant = Readonly<{ /** Lower runs earlier; the §10.6 step number is used as the rank. */ order: number; label: string; close(): void | Promise; }>; export type ServerStateScopeDependencies = Readonly<{ session: Pick; queryInvalidation: Pick; tokenFactory?: () => string; participants?: readonly ScopeResetParticipant[]; activateNextGeneration?: () => void | Promise; }>; export function createServerStateScopeRuntime( dependencies: ServerStateScopeDependencies, ): ServerStateScopeRuntime { const listeners = new Set<() => void>(); const lifecycleListeners = new Set< (event: ClientScopeLifecycleEvent) => void >(); const participants = [...(dependencies.participants ?? [])].sort( (left, right) => left.order - right.order, ); let generation = 1; let identities = newIdentityRegistry(dependencies.tokenFactory); let fingerprint = scopeFingerprint(dependencies.tokenFactory); let generationLifetime = new AbortController(); let phase: ClientScopePhase = "READY"; let disposed = false; let resetChain = Promise.resolve(); function createSnapshot(): CacheScopeSnapshot { const capturedGeneration = generation; const capturedIdentities = identities; return Object.freeze({ generation: capturedGeneration, fingerprint, identities: capturedIdentities, signal: generationLifetime.signal, isCurrent: () => !disposed && phase === "READY" && generation === capturedGeneration && identities === capturedIdentities, }); } let currentSnapshot = createSnapshot(); function publishLifecycle(event: ClientScopeLifecycleEvent): void { for (const listener of [...lifecycleListeners]) { try { listener(event); } catch { // One subscriber defect cannot stop the fence from propagating. } } } function publishSnapshot(): void { for (const listener of [...listeners]) { try { listener(); } catch { // Subscriber defects are isolated from the mandatory reset sequence. } } } const unsubscribe = dependencies.session.subscribe(() => { if (disposed) return; const previousIdentities = identities; const previousGeneration = generation; // §10.6 steps 1-3 are synchronous: increment the generation, invalidate the // old snapshot, publish FENCED. Nothing between here and READY may render a // value that belonged to the previous identity. generationLifetime.abort(); const targetGeneration = ++generation; phase = "FENCED"; currentSnapshot = createSnapshot(); publishLifecycle( Object.freeze({ kind: "FENCED" as const, previousGeneration }), ); publishSnapshot(); resetChain = resetChain .catch(() => {}) .then(async () => { let failed = false; // Steps 4-11: close admission, cancel and clear, release leases. for (const participant of participants) { try { await participant.close(); } catch { failed = true; } } try { await dependencies.queryInvalidation.resetLocal(); } catch { failed = true; } previousIdentities.close(); if (disposed || generation !== targetGeneration) return; if (!failed) { try { await dependencies.activateNextGeneration?.(); } catch { failed = true; } } if (disposed || generation !== targetGeneration) return; if (failed) { phase = "FAILED"; currentSnapshot = createSnapshot(); publishLifecycle( Object.freeze({ kind: "FAILED" as const, generation: targetGeneration, }), ); publishSnapshot(); return; } // Steps 12-15: new identity registry, READY, notify, reopen admission. identities = newIdentityRegistry(dependencies.tokenFactory); fingerprint = scopeFingerprint(dependencies.tokenFactory); generationLifetime = new AbortController(); phase = "READY"; currentSnapshot = createSnapshot(); publishLifecycle( Object.freeze({ kind: "READY" as const, snapshot: currentSnapshot }), ); publishSnapshot(); }); }); return Object.freeze({ getSnapshot: () => currentSnapshot, getPhase: () => phase, subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, subscribeLifecycle(listener: (event: ClientScopeLifecycleEvent) => void) { lifecycleListeners.add(listener); return () => lifecycleListeners.delete(listener); }, dispose() { if (disposed) return; disposed = true; phase = "DISPOSED"; generationLifetime.abort(); unsubscribe(); publishLifecycle(Object.freeze({ kind: "DISPOSED" as const })); listeners.clear(); lifecycleListeners.clear(); identities.close(); }, }); } function newIdentityRegistry( tokenFactory: (() => string) | undefined, ): RuntimeIdentityRegistry { return createRuntimeIdentityRegistry({ ...(tokenFactory ? { tokenFactory } : {}), }); } function scopeFingerprint(tokenFactory: (() => string) | undefined): string { const candidate = tokenFactory?.() ?? crypto.randomUUID(); if (!/^[A-Za-z0-9._:-]{16,128}$/.test(candidate)) { throw new TypeError("Invalid cache scope fingerprint."); } return candidate; }