import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { holdGeneration, onViewerChange, viewerEpoch } from './writeThroughHold'; /** * WHOSE DATA IS IN THIS CACHE? * * Every patient-scoped service in this product keeps an app-wide cache that is keyed on nothing, and * clears it only when `patientState` goes falsy. Moving from one patient's chart to another never does * that, so the previous patient's bundle survives the switch and is returned as a cache hit. No request * goes out, no error appears, and the clinician reads somebody else's list under this patient's name. * * MEASURED 2026-08-29 across the plugin: 34 services carry that reset and ONE of them knew who its cache * was for. The lists involved are allergies, medications, conditions, procedures and the rest of the * clinical record, so a wrong one is a safety question rather than a cosmetic one. * * This is the small piece that was missing, extracted so the remaining services adopt an implementation * rather than each growing their own. Two values, deliberately: * * - `isFor(id)` reads a REF, because the cache check runs inside a fetch callback and must see the * current owner rather than whatever was captured when that callback was created. State would be one * render behind, which is precisely the bug that made an earlier appointment fix look correct and do * nothing. * - `ownerId` is STATE, because consumers re-render on it. That is what turns "this cache is not yours" * into an actual re-fetch instead of a fact nobody reads. * * It does not clear the data. A cache owner says who the data belongs to; deciding what to do about a * mismatch belongs to the service and its hook, which is where the difference between "stale" and * "somebody else's" is understood. */ /** One read that may publish into the cache: the viewer it began under and the hold generation it saw. */ export interface CacheOperation { number: number; viewer: number; generation: number; } export interface CacheOwner { /** The patient the cached data belongs to, or undefined when nothing is cached. Re-renders consumers. */ ownerId: string | undefined; /** Does the cache hold this patient's data? False when nothing is cached, which fails towards fetching. */ isFor: (patientId: string | undefined) => boolean; /** Record who the data just fetched belongs to, with the read that fetched it. Call it WITH the data. */ claim: (patientId: string | undefined, operation?: CacheOperation) => void; /** Forget the owner, for when the cache itself is dropped. */ release: () => void; /** Starts a read that may publish into the cache; a newer read or a change of viewer supersedes it. */ begin: () => CacheOperation; /** Whether this read may still publish: it is the newest begun and the viewer has not changed. */ publishes: (operation: CacheOperation) => boolean; /** * Whether the cache may answer for this patient without a read: nothing written, settled or expired since. * A yes is that answer, so it supersedes every read still in flight, as a newer read would. */ isCurrentFor: (patientId: string | undefined) => boolean; /** Counts claims and releases; a reader keeps the count it was made with, beside the list it reads. */ claimed: number; /** The same, for a reader by id that does not know the patient, given the count its list was published with. */ isCurrent: (claimed?: number) => boolean; /** What a superseded read hands its own caller: its answer under the same viewer, nothing under another. */ answerFor: (operation: CacheOperation, bundle: B) => B; } // The cache of one service for one resource type. On a change of viewer it forgets its owner, supersedes every // read in flight and calls reset, so the service drops what it shows. export const useCacheOwner = (resourceType?: string, reset?: () => void): CacheOwner => { const ownerRef = useRef(undefined); const stampRef = useRef<{ viewer: number; generation: number }>({ viewer: -1, generation: -1 }); const operationRef = useRef(0); const claimedRef = useRef(0); const [claimed, setClaimed] = useState(0); const resetRef = useRef(reset); resetRef.current = reset; const [ownerId, setOwnerId] = useState(undefined); const generationNow = useCallback((): number => (resourceType ? holdGeneration(resourceType) : 0), [resourceType]); const isFor = useCallback( // An unowned cache is never "for" anybody, so an empty owner reads as a miss and the caller // fetches. Failing towards a request is the safe direction: the cost is one call, and the cost // of the other answer is a clinician reading the wrong patient's record. (patientId: string | undefined): boolean => ownerRef.current !== undefined && ownerRef.current === patientId && stampRef.current.viewer === viewerEpoch(), [], ); const claim = useCallback((patientId: string | undefined, operation?: CacheOperation) => { ownerRef.current = patientId; stampRef.current = operation ? { viewer: operation.viewer, generation: operation.generation } : { viewer: viewerEpoch(), generation: generationNow() }; setClaimed(++claimedRef.current); setOwnerId(patientId); }, [generationNow]); const release = useCallback(() => { ownerRef.current = undefined; setClaimed(++claimedRef.current); setOwnerId(undefined); }, []); const begin = useCallback((): CacheOperation => ({ number: ++operationRef.current, viewer: viewerEpoch(), generation: generationNow(), }), [generationNow]); const publishes = useCallback((operation: CacheOperation): boolean => operation.number === operationRef.current && operation.viewer === viewerEpoch(), []); const isCurrent = useCallback((listClaimed?: number): boolean => ownerRef.current !== undefined && (listClaimed === undefined || listClaimed === claimedRef.current) && stampRef.current.viewer === viewerEpoch() && stampRef.current.generation === generationNow(), [generationNow]); const isCurrentFor = useCallback((patientId: string | undefined): boolean => { if (!isFor(patientId) || !isCurrent()) return false; operationRef.current += 1; return true; }, [isFor, isCurrent]); const answerFor = useCallback((operation: CacheOperation, bundle: B): B => (operation.viewer === viewerEpoch() ? bundle : { ...bundle, entry: [], ...(bundle.total === undefined ? {} : { total: 0 }) }), []); useEffect(() => onViewerChange(() => { ownerRef.current = undefined; operationRef.current += 1; setClaimed(++claimedRef.current); setOwnerId(undefined); resetRef.current?.(); }), []); // MEMOISED, and this is not a micro-optimisation. Services put this object in an effect's dependency // array; a fresh literal every render re-runs that effect forever and the process dies rather than // failing, which reads as a broken harness rather than an object that cannot hold still. return useMemo( () => ({ ownerId, claimed, isFor, claim, release, begin, publishes, isCurrentFor, isCurrent, answerFor }), [ownerId, claimed, isFor, claim, release, begin, publishes, isCurrentFor, isCurrent, answerFor], ); }; /** * What a reader of a shared per-patient cache may be handed. The cache outlives a change of patient, * so until it holds the patient asked about it yields nothing and reads as loading, never as the last * patient's record and never as an empty answer about this one. */ export interface PatientCacheGuard { owned: boolean; list: (list: T[]) => T[]; bundle: (bundle: B, empty: B) => B; /** An answer derived from the cache: false until the cache is this patient's. */ flag: (value: boolean) => boolean; loading: (loading: boolean, failed: boolean) => boolean; } // One empty list for every unowned read, so a page that depends on the list does not re-run on each render. const NOT_THIS_PATIENTS: readonly never[] = Object.freeze([]); export const patientCacheGuard = ( ownerId: string | undefined, patientId: string | undefined, ): PatientCacheGuard => { const owned = patientId !== undefined && ownerId === patientId; return { owned, list: (list) => (owned ? list : NOT_THIS_PATIENTS as never[]), bundle: (bundle, empty) => (owned ? bundle : empty), flag: (value) => owned && value, // A failed read is said by its error, not by a spinner that never ends. loading: (loading, failed) => loading || (patientId !== undefined && !owned && !failed), }; };