import { useEffect, useState } from "react"; import { writeThroughViewer } from "./writeThroughHold"; // A settings write that left for the server and never came back with an answer may have landed. It is held for the // viewer that sent it - their account, acted-as subject and practice, as AuthService names them - across a practice // switch, reopening the page and a reload of the tab, since none of those says whether it landed. Only what the // setting's own page establishes settles it. Nothing secret is held: only what kind of write it was and where it // stands. /** One change as the server orders it: its id, and which attempt of that change was sent. */ export interface SettingsAttempt { id: string; attempt: number; } export interface UnresolvedSettingsWrite { /** What was sent: for a page with more than one kind of write. */ kind: string; /** Unconfirmed, or being settled by a deliberate write the page will confirm. */ state: "unconfirmed" | "recovering"; /** What was sent, when it is public (an NPI): never a key, secret or other credential. */ detail?: string; /** The last attempt sent of the change being settled, for a server that orders attempts: the next one goes past it. */ sent?: SettingsAttempt; } const isAttempt = (value: unknown): value is SettingsAttempt => typeof (value as SettingsAttempt | undefined)?.id === "string" && Number.isInteger((value as SettingsAttempt).attempt); const STORAGE_KEY = "akashic.settingsWriteUnresolved"; const held = new Map(); const listeners = new Set<() => void>(); const changed = () => { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...held.entries()])); } catch { // Storage refused: the hold still stands for as long as the app runs. } listeners.forEach((listener) => listener()); }; /** Reads the holds this tab kept, as the app does when it starts. */ export const restoreUnresolvedSettingsWrites = (): void => { held.clear(); try { const kept = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "[]") as [string, UnresolvedSettingsWrite][]; if (Array.isArray(kept)) { kept.forEach(([holder, write]) => { if (typeof holder === "string" && typeof write?.kind === "string" && (write.state === "unconfirmed" || write.state === "recovering")) { held.set(holder, { kind: write.kind, state: write.state, ...(typeof write.detail === "string" ? { detail: write.detail } : {}), ...(isAttempt(write.sent) ? { sent: { id: write.sent.id, attempt: write.sent.attempt } } : {}), }); } }); } } catch { // Nothing readable was kept. } listeners.forEach((listener) => listener()); }; restoreUnresolvedSettingsWrites(); /** Whose write this is, for one setting: the viewer as it is when the write starts. */ export const settingsWriteHolder = (setting: string): string => `${setting}|${writeThroughViewer() ?? ""}`; export const unresolvedSettingsWrite = (holder: string): UnresolvedSettingsWrite | undefined => held.get(holder); /** Holds, changes or (with undefined) settles a viewer's write for one setting. */ export const holdSettingsWrite = (holder: string, write: UnresolvedSettingsWrite | undefined): void => { if (write) held.set(holder, write); else held.delete(holder); changed(); }; /** A fresh tab, for a test. */ export const forgetUnresolvedSettingsWrites = (): void => { held.clear(); changed(); }; export const useUnresolvedSettingsWrite = (holder: string): UnresolvedSettingsWrite | undefined => { const [, rerender] = useState(0); useEffect(() => { const listener = () => rerender((n) => n + 1); listeners.add(listener); return () => { listeners.delete(listener); }; }, []); return held.get(holder); }; /** A fresh id for one settings change. */ export const newSettingsOperationId = (): string => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; /** * The attempt to send now. While a write is held it is the next attempt of that same change, which the server runs only * after every earlier attempt and which fences them all, however many were lost; otherwise the first of a new change. */ export const nextSettingsAttempt = (write: UnresolvedSettingsWrite | undefined): SettingsAttempt => write?.sent ? { id: write.sent.id, attempt: write.sent.attempt + 1 } : { id: newSettingsOperationId(), attempt: 0 }; /** * Records how an attempt that did not settle its hold ended, against the hold as it is now: the attempt may have been * retired, and its page reopened and moved on, before it got here. * - Nothing has changed the hold since the attempt started: an unknown answer holds this attempt (`unknown`), and any * other end is carried by the hold, so the next attempt goes past it. * - The hold has changed meanwhile: only a later attempt of the change now held is recorded, raising what that hold * carries and nothing else. A change settled since is not held again, a newer change is never replaced, and the * hold's state and detail stay as they are. */ export const recordSettingsAttempt = ( holder: string, sent: SettingsAttempt, startedWith: UnresolvedSettingsWrite | undefined, unknown: Omit | undefined, ): void => { const now = held.get(holder); if (now === startedWith) { if (unknown) holdSettingsWrite(holder, { ...unknown, sent }); else if (now) holdSettingsWrite(holder, { ...now, sent }); return; } if (now?.sent?.id === sent.id && now.sent.attempt < sent.attempt) holdSettingsWrite(holder, { ...now, sent }); }; /** * One settings write, watched for whether it left and how it settled. Unconfirmed: it was handed to the network and * neither an answer nor a refusal that wrote nothing came back - a 5xx, a lost answer, a cancel or a deadline after it * left all may have landed. `refusedBeforeWriting` says which statuses the endpoint answers only before it changes * anything: by default any 4xx, which holds for the endpoints that use this today. */ export const watchSettingsWrite = ( write: (onSend: () => void) => Promise, refusedBeforeWriting: (status: number) => boolean = (status) => status < 500, ) => { let sent = false; let settled = false; return { run: () => write(() => { sent = true; }).then( (answer) => { settled = true; return answer; }, (error: unknown) => { const status = (error as { status?: unknown } | null)?.status; if (typeof status === "number" && refusedBeforeWriting(status)) settled = true; throw error; }, ), unconfirmed: () => sent && !settled, }; };