203 lines
6.2 KiB
TypeScript
203 lines
6.2 KiB
TypeScript
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<void>;
|
|
}>;
|
|
|
|
export type ServerStateScopeDependencies = Readonly<{
|
|
session: Pick<AuthSessionPort, "subscribe">;
|
|
queryInvalidation: Pick<QueryInvalidationCoordinator, "resetLocal">;
|
|
tokenFactory?: () => string;
|
|
participants?: readonly ScopeResetParticipant[];
|
|
activateNextGeneration?: () => void | Promise<void>;
|
|
}>;
|
|
|
|
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;
|
|
}
|