import { viewerEpoch } from './writeThroughHold'; /** A read that was retired (its owner moved on) or ran out of time before it answered. */ export class ReadRetiredError extends Error { constructor(readonly reason: 'retired' | 'timedOut') { super(reason === 'timedOut' ? 'READ_TIMED_OUT' : 'READ_RETIRED'); this.name = 'ReadRetiredError'; } } export const isReadRetired = (error: unknown): error is ReadRetiredError => error instanceof ReadRetiredError || (error as { name?: unknown } | null)?.name === 'ReadRetiredError'; /** What a read hands its requests: cancel the one already sent, and refuse to send or publish once retired. */ export type ReadRequest = { signal: AbortSignal; admit: () => void }; /** * A read owned by whoever started it, with a deadline that always ends it. The work is raced against the deadline, * so a preparation that never settles (a token or proof wait) cannot hold the caller: the answer is unavailable, * the request is cancelled, and `admit` refuses anything the work still tries to send or publish afterwards. Once * `owns()` is false the read is retired the same way. `track` lets an owner cancel every read it started at once. */ export const ownedRead = ( work: (request: ReadRequest) => Promise, { deadlineMs, owns = () => true, track }: { deadlineMs: number; owns?: () => boolean; track?: Set }, ): Promise => { const controller = new AbortController(); track?.add(controller); let ended: ReadRetiredError | undefined; const admit = () => { if (ended) throw ended; if (!owns() || controller.signal.aborted) throw new ReadRetiredError('retired'); }; let timer: ReturnType | undefined; const deadline = new Promise((_resolve, reject) => { timer = setTimeout(() => { ended = new ReadRetiredError('timedOut'); controller.abort(); reject(ended); }, deadlineMs); }); const retired = new Promise((_resolve, reject) => { controller.signal.addEventListener('abort', () => reject(ended ?? new ReadRetiredError('retired'))); }); const answered = (async () => { admit(); const result = await work({ signal: controller.signal, admit }); // An answer for an owner that has moved on, or past its deadline, is never published. admit(); return result; })(); return Promise.race([answered, deadline, retired]).finally(() => { clearTimeout(timer); track?.delete(controller); }); }; /** * Ends an owner the moment the viewer, practice or acted-as subject changes, in that same turn and before any * render has caught up, so a request still preparing cannot be admitted under the context that replaced it. */ export const forThisViewer = (owns: () => boolean): (() => boolean) => { const epoch = viewerEpoch(); return () => viewerEpoch() === epoch && owns(); };