// Access changes the server never confirmed, kept so the decision can be finished as itself. // // A 503 or a lost answer may have been applied, so the only safe retry is the same request id with the same // payload and generation. That retry must survive the form it was made in, a reload, and a trip to another // context and back, including one made while the request was still on its way: otherwise the only identity that // can finish a pending decision is lost. So every change is RECORDED BEFORE IT IS SENT, under the viewer that // makes it, and only its answer settles it: confirmed, overtaken or refused removes it, and anything else (a 503, // no answer, or an answer that never arrives because the page went away) leaves it to be finished. The person's // access card offers that. // // SCOPED TO THE VIEWER THAT MADE IT: the account, acted-as link and practice (the write-through viewer). Another // viewer never sees, finishes or rebases it, and a late answer settles it for its own viewer, not the one now on // screen. Kept in this browser for a bounded time; it holds a link id, the choices and the request identity, // nothing about the person. import type { AuthorityIntent, AuthorityTier } from './relatedPersonAuthority'; import type { DesignationAttestationRequest } from './relatedPersonDesignation'; import { onViewerChange, writeThroughViewer } from '../utils/writeThroughHold'; import { authStorageArea } from './authStorage'; export interface UnconfirmedAuthoritySave { relatedPersonId: string; intent: AuthorityIntent; tier: AuthorityTier; allowMessaging: boolean; allowBillingView: boolean; allowBillingPay: boolean; attestation?: DesignationAttestationRequest; } /** A change recorded before it was sent. Its answer settles it, for the viewer that made it. */ export interface AuthoritySaveRecord { /** Confirmed, overtaken or refused: nothing is left to finish. */ settle: () => void; /** Its request came back, whatever it said: it is no longer on its way from this page. */ answered: () => void; } interface Kept { viewer: string; savedAt: number; save: UnconfirmedAuthoritySave } const STORAGE_KEY = 'akashic_unconfirmed_authority_saves'; // Long enough to come back to after a reload or a day away; not a store of old decisions. export const UNCONFIRMED_KEPT_MS = 7 * 24 * 60 * 60 * 1000; // A demo tab keeps its pending saves in its own sessionStorage, cleared with its visit. const storage = (): Storage | undefined => authStorageArea(); const readAll = (now: number): Kept[] => { try { const raw = storage()?.getItem(STORAGE_KEY); const all = raw ? (JSON.parse(raw) as Kept[]) : []; return Array.isArray(all) ? all.filter((kept) => kept && now - kept.savedAt < UNCONFIRMED_KEPT_MS) : []; } catch { return []; } }; const writeAll = (all: Kept[]): void => { try { if (all.length) storage()?.setItem(STORAGE_KEY, JSON.stringify(all)); else storage()?.removeItem(STORAGE_KEY); } catch { // Without storage it is kept for this page only; the card's fresh-choice recovery covers the rest. } }; // Requests this page sent that have not come back yet, counted per request: the same request finished again while // the first is still in the air is two flights, and one answering leaves the other on its way. Only these may be // described as being saved: a recorded change from a page that is gone (a reload, another tab) never answers // here, so it is offered to finish instead. A flight that has not answered by its deadline is no longer described // as being saved either: the change stays recorded and is offered to finish, as itself. const onTheirWay = new Map(); export const AUTHORITY_SAVE_DEADLINE_MS = 30_000; type Listener = (relatedPersonId: string) => void; const listeners = new Set(); const tell = (relatedPersonId: string) => listeners.forEach((listener) => listener(relatedPersonId)); /** Hear whenever a person's recorded change is sent, answered or settled. */ export const onAuthoritySaveChange = (listener: Listener): (() => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; /** Whether this request is still on its way from this page. */ export const isOnItsWay = (requestId: string | undefined): boolean => !!requestId && (onTheirWay.get(requestId) ?? 0) > 0; // This page's copy, for the viewer it was loaded for. const saves = new Map(); let loadedFor: string | null | undefined; const forThisViewer = (now = Date.now()): Map => { const viewer = writeThroughViewer(); if (loadedFor !== viewer) { loadedFor = viewer; saves.clear(); if (viewer) readAll(now).filter((kept) => kept.viewer === viewer).forEach((kept) => saves.set(kept.save.relatedPersonId, kept.save)); } return saves; }; onViewerChange(() => { loadedFor = undefined; saves.clear(); }); const settleFor = (viewer: string, relatedPersonId: string, requestId: string, now = Date.now()): void => { if (loadedFor === viewer && saves.get(relatedPersonId)?.intent.requestId === requestId) saves.delete(relatedPersonId); writeAll(readAll(now).filter((kept) => !(kept.viewer === viewer && kept.save.relatedPersonId === relatedPersonId && kept.save.intent.requestId === requestId))); tell(relatedPersonId); }; /** * Record a change before it is sent, under the viewer making it. Returns its record, whose answer settles it; * with no viewer (nobody signed in) there is nothing to keep. */ export const recordAuthoritySave = (save: UnconfirmedAuthoritySave, now = Date.now()): AuthoritySaveRecord | undefined => { const viewer = writeThroughViewer(); if (!viewer) return undefined; forThisViewer(now).set(save.relatedPersonId, save); writeAll([ ...readAll(now).filter((kept) => !(kept.viewer === viewer && kept.save.relatedPersonId === save.relatedPersonId)), { viewer, savedAt: now, save }, ]); const requestId = save.intent.requestId; onTheirWay.set(requestId, (onTheirWay.get(requestId) ?? 0) + 1); tell(save.relatedPersonId); let landed = false; const answered = () => { if (landed) return; landed = true; clearTimeout(deadline); const left = (onTheirWay.get(requestId) ?? 1) - 1; if (left > 0) onTheirWay.set(requestId, left); else onTheirWay.delete(requestId); tell(save.relatedPersonId); }; const deadline = setTimeout(answered, AUTHORITY_SAVE_DEADLINE_MS); return { settle: () => settleFor(viewer, save.relatedPersonId, requestId), answered, }; }; export const unconfirmedFor = (relatedPersonId: string | undefined, now = Date.now()): UnconfirmedAuthoritySave | undefined => relatedPersonId ? forThisViewer(now).get(relatedPersonId) : undefined;