import { useEffect, useMemo, useRef } from 'react'; import { isRecordChanged } from './recordChanged'; import { mayHaveBeenWritten } from './fetchFailureMessage'; import { onViewerChange, viewerEpoch } from './writeThroughHold'; /** * One save, from the press that started it to the answer it shows. It belongs to the editor that started it, the * record that editor was opened on, and the person signed in (account, acted-for subject, practice) - and it has * a deadline. Once any of those has moved on, nothing more is sent for it, and nothing it learns is shown, * cached or installed anywhere: an answer for A never lands on B, and A -> B -> A does not bring it back. */ /** How long one save may take, start to finish, before it is given up. */ export const SAVE_DEADLINE_MS = 30_000; export const TIMED_OUT_NOTHING_SAVED = 'This took too long and was stopped. Nothing was saved. Try again.'; export const TIMED_OUT_MAY_HAVE_SAVED = 'The result could not be confirmed. Reload to check before trying again.'; /** The editor, record or person this save was started for is gone; nothing more is done or shown for it. */ export class SaveRetiredError extends Error { constructor() { super('This save no longer belongs to what is on screen.'); this.name = 'SaveRetiredError'; } } /** The deadline passed. `mayHaveSaved`: a write was in flight, so whether it landed is not known. */ export class SaveTimedOutError extends Error { // A protected member makes the class nominal: no other error, whatever its fields, passes as this one. protected readonly ownedNotice!: true; constructor(public readonly mayHaveSaved: boolean) { super(mayHaveSaved ? TIMED_OUT_MAY_HAVE_SAVED : TIMED_OUT_NOTHING_SAVED); this.name = 'SaveTimedOutError'; } } /** * A write whose outcome is not known: the save ran out of time with it sent, or nothing of ours answered (the * connection failed after the request may have left). It may have landed, so it is not offered again blind - what is * saved is checked first. A definite refusal, or a failure before anything was sent, is not this. */ export const outcomeUnknown = (error: unknown): boolean => (isSaveTimedOut(error) && error.mayHaveSaved) || mayHaveBeenWritten(error); export const isSaveRetired = (error: unknown): error is SaveRetiredError => error instanceof SaveRetiredError || (error as { name?: unknown } | null)?.name === 'SaveRetiredError'; export const isSaveTimedOut = (error: unknown): error is SaveTimedOutError => error instanceof SaveTimedOutError || (error as { name?: unknown } | null)?.name === 'SaveTimedOutError'; /** * What a save hands its requests, so the request itself is admitted: checked again after the token and proof waits, * immediately before it is sent, and before its answer is published to any shared cache; and cancelled (`signal`) * the moment the save is retired or runs out of time. See HttpService's `fetchWithAuth`. */ export interface SaveTransport { admit: () => void; signal: AbortSignal; /** * Called once the request is handed to the network, after its last admit: from then on it may reach the server * whatever becomes of the save. A request stopped before this was never sent. */ onSend?: () => void; /** Serializes final session validation and synchronous send after token/proof preparation. */ dispatch?: (prepare: () => Promise<() => Promise>) => Promise; } export interface SaveOperation { /** Still the save its editor, record and viewer started (the deadline aside). */ belongs(): boolean; /** For the requests this save sends. */ transport: SaveTransport; /** Retire it now: nothing more is sent and any request in flight is cancelled. */ retire(): void; /** Throws unless this save still belongs and is within its deadline. */ admit(): void; /** * Admits, starts `work`, waits for it no longer than the deadline, and admits again before its answer (or its * failure) is used. `write`: the work may change the record, so running out of time cannot say nothing was saved. */ step(work: () => Promise, write?: boolean): Promise; } export const startSaveOperation = (ours: () => boolean, deadlineMs = SAVE_DEADLINE_MS): SaveOperation => { const epoch = viewerEpoch(); const endsAt = Date.now() + deadlineMs; const controller = new AbortController(); let retiredHere = false; // A write sent and not refused: past the deadline, it may have landed. let unconfirmedWrite = false; const belongs = () => !retiredHere && ours() && viewerEpoch() === epoch; const retire = () => { retiredHere = true; controller.abort(); }; // The whole save ends at its deadline, whatever step it is in, and its requests are cancelled with it. const expiry = setTimeout(() => controller.abort(), Math.max(0, deadlineMs)); const admit = () => { if (!belongs()) throw new SaveRetiredError(); if (Date.now() >= endsAt) throw new SaveTimedOutError(unconfirmedWrite); }; const step = async (work: () => Promise, write = false): Promise => { admit(); if (write) unconfirmedWrite = true; let timer: ReturnType | undefined; const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new SaveTimedOutError(unconfirmedWrite)), Math.max(0, endsAt - Date.now())); }); try { const value = await Promise.race([work(), deadline]); admit(); return value; } catch (error) { if (isSaveTimedOut(error)) throw belongs() ? error : new SaveRetiredError(); // Cancelled under it: retired, or out of time. if (controller.signal.aborted) { if (!belongs()) throw new SaveRetiredError(); throw new SaveTimedOutError(unconfirmedWrite); } // A refusal is an answer: nothing was written. if (write && isRecordChanged(error)) unconfirmedWrite = false; admit(); throw error; } finally { clearTimeout(timer); } }; const transport: SaveTransport = { admit, signal: controller.signal }; controller.signal.addEventListener('abort', () => clearTimeout(expiry)); return { belongs, admit, step, transport, retire }; }; /** * One save attempt made from a longer-lived `owner` (a sheet's opening): its deadline starts now, at the submit, and it * belongs only while the owner does - retiring the owner retires it, wherever it is. */ export const startOwnedSave = (owner: SaveOperation, deadlineMs = SAVE_DEADLINE_MS): SaveOperation => { const attempt = startSaveOperation(() => owner.belongs(), deadlineMs); owner.transport.signal.addEventListener('abort', () => { if (!owner.belongs()) attempt.retire(); }); return attempt; }; /** * An editor's lifetime, for the saves it starts. `renew` (open, close, cancel) and a change of `subject` or of the * person signed in retire everything started before; `onRetired` is told, so the editor can close. */ export const useSaveScope = (subject: string | undefined, onRetired?: () => void) => { const generation = useRef(0); const retired = useRef(onRetired); retired.current = onRetired; // The subject as rendered NOW: a save started for another one stops belonging the moment the screen moves on, // not one effect later. const rendered = useRef(subject); rendered.current = subject; const live = useRef(new Set()); const retireAll = () => { generation.current += 1; live.current.forEach((operation) => operation.retire()); live.current.clear(); }; const seen = useRef(subject); useEffect(() => { if (seen.current === subject) return; seen.current = subject; retireAll(); retired.current?.(); }, [subject]); useEffect(() => onViewerChange(() => { retireAll(); retired.current?.(); }), []); useEffect(() => () => retireAll(), []); return useMemo(() => ({ renew: () => retireAll(), start: (deadlineMs?: number): SaveOperation => { const mine = generation.current; const forSubject = rendered.current; const operation = startSaveOperation(() => generation.current === mine && rendered.current === forSubject, deadlineMs); live.current.add(operation); operation.transport.signal.addEventListener('abort', () => live.current.delete(operation)); return operation; }, }), []); };