import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { acceptSnapshot, assertMutable, computeFreshness, DEFAULT_FRESHNESS_POLICY, invalidationReasonLabels, type FreshPayload, type FreshSnapshot, type FreshnessPolicy, type FreshnessState, type InvalidationReason, StaleMutationError, } from './model'; import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache'; /** * Freshness-aware collection fetch hook (RI-5-001). * * One hook owns one gateway collection end to end: fetch, schema validation, * snapshot acceptance with provenance, session-scoped last-known caching, * aging, and the mutation guard. Pages consume `freshness` and never infer * health from emptiness. */ /** Why the latest validation did not produce a current snapshot. */ export type FreshnessFailure = | { readonly kind: 'fetch'; readonly message: string } | { readonly kind: 'invalidated'; readonly reason: InvalidationReason }; export interface UseFreshCollectionOptions { /** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */ readonly source: string; /** Performs the unvalidated fetch. The hook owns abort and verification. */ readonly fetcher: (signal: AbortSignal) => Promise; /** * Runtime schema validator. Returning `null` invalidates the payload * (`schema-mismatch`) instead of letting malformed JSON flow into render. */ readonly validate: (value: unknown) => FreshPayload | null; /** Overrides of the default freshness policy. */ readonly policy?: Partial; /** * Session cache key for last-known snapshots. `null`/omitted disables * restore. Restored snapshots are unverified: they render only as * labeled `stale` data until a fetch re-verifies them. */ readonly cacheKey?: string | null; /** Injectable clock for deterministic age transitions in tests. */ readonly clock?: () => number; /** Aging tick interval override (default derived from `staleAfterMs`). */ readonly tickMs?: number; /** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */ readonly enabled?: boolean; } export interface FreshCollection { /** Last verified (or restored-unverified) snapshot, or `null`. */ readonly snapshot: FreshSnapshot | null; /** Snapshot data or `null` — never a fabricated empty collection. */ readonly data: T | null; readonly freshness: FreshnessState; /** True while a validation request is in flight. */ readonly validating: boolean; /** Outcome of the latest failed validation, `null` when healthy. */ readonly failure: FreshnessFailure | null; /** False unless freshness is `current`; drives disabled UI affordances. */ readonly canMutate: boolean; /** Re-run the fetch and re-verify. Always allowed (it is a read). */ readonly revalidate: () => Promise; /** * Run a state-changing operation against verified-current data only. * Rejects with `StaleMutationError` on any other state — the guard fires * even if a disabled button was bypassed (defense in depth). */ readonly mutate: (operation: (data: T) => Promise) => Promise; } const defaultClock = (): number => Date.now(); function resolveTickMs(policy: FreshnessPolicy, override?: number): number { if (override !== undefined && override > 0) return override; return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4))); } function isAuthFailure(caught: unknown): boolean { return ( typeof caught === 'object' && caught !== null && 'statusCode' in caught && ((caught as { statusCode?: unknown }).statusCode === 401 || (caught as { statusCode?: unknown }).statusCode === 403) ); } function fetchFailureMessage(caught: unknown): string { if (caught instanceof Error && caught.message.trim().length > 0) return caught.message; return 'The request failed.'; } /** Human-readable summary of a failure for unavailable/stale notices. */ export function describeFailure(failure: FreshnessFailure | null): string | null { if (failure === null) return null; if (failure.kind === 'fetch') return failure.message; return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`; } export function useFreshCollection(options: UseFreshCollectionOptions): FreshCollection { const optionsRef = useRef(options); optionsRef.current = options; const policy = useMemo( () => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }), [options.policy], ); const policyRef = useRef(policy); policyRef.current = policy; const clockRef = useRef(options.clock ?? defaultClock); clockRef.current = options.clock ?? defaultClock; const [snapshot, setSnapshot] = useState | null>(null); const [failure, setFailure] = useState(null); const [unverified, setUnverified] = useState(false); const [validating, setValidating] = useState(options.enabled !== false); const [now, setNow] = useState(() => (options.clock ?? defaultClock)()); const snapshotRef = useRef(snapshot); snapshotRef.current = snapshot; const failureRef = useRef(failure); failureRef.current = failure; const unverifiedRef = useRef(unverified); unverifiedRef.current = unverified; const runRef = useRef(0); const abortRef = useRef(null); const revalidate = useCallback(async (): Promise => { const current = optionsRef.current; if (current.enabled === false) { setValidating(false); return; } const runId = ++runRef.current; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setValidating(true); let value: unknown; try { value = await current.fetcher(controller.signal); } catch (caught) { if (runRef.current !== runId || controller.signal.aborted) return; if (isAuthFailure(caught)) { // An unauthenticated viewer must not keep (or be served) the // previous user's last-known data. setSnapshot(null); setUnverified(false); if (current.cacheKey) clearSnapshotCache(current.cacheKey); } setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) }); setValidating(false); return; } if (runRef.current !== runId) return; const result = acceptSnapshot({ value, validate: current.validate, previous: snapshotRef.current, policy: policyRef.current, source: current.source, now: clockRef.current(), }); if (result.outcome === 'accepted') { setSnapshot(result.snapshot); setUnverified(false); setFailure(null); if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot); } else { if (result.reason === 'cross-workspace') { // Data verified for a different workspace must not linger as // last-known situational awareness either. setSnapshot(null); setUnverified(false); } if (current.cacheKey) clearSnapshotCache(current.cacheKey); setFailure({ kind: 'invalidated', reason: result.reason }); } setValidating(false); }, []); // Restore the last-known snapshot (unverified) and run the first fetch. useEffect(() => { if (optionsRef.current.enabled === false) { setValidating(false); return; } const cacheKey = optionsRef.current.cacheKey; if (cacheKey) { const restored = readSnapshotCache({ key: cacheKey, workspace: policyRef.current.workspace, policy: policyRef.current, validate: optionsRef.current.validate, }); if (restored.outcome === 'hit') { setSnapshot(restored.snapshot); setUnverified(true); } else if (restored.outcome === 'invalidated') { // A corrupted/foreign/regressed entry is dropped immediately; it must // never surface as data. The fetch decides the visible state. clearSnapshotCache(cacheKey); } } void revalidate(); return () => { abortRef.current?.abort(); }; // Mount-once by design: `revalidate` is stable and reads live options // through refs, so it never needs to re-run when options change. // Route-param pages remount this hook via an identity `key` instead. }, [revalidate]); // Aging tick: recomputes freshness as the snapshot ages past the policy. useEffect(() => { const interval = setInterval( () => { setNow(clockRef.current()); }, resolveTickMs(policyRef.current, optionsRef.current.tickMs), ); return () => clearInterval(interval); }, []); const freshness = useMemo(() => { if (snapshot === null) return validating ? 'unknown' : 'unavailable'; return computeFreshness({ snapshot, policy, now, degraded: failure !== null || unverified, }); // `now` from state covers age; refs inside computeFreshness are pure. }, [snapshot, validating, failure, unverified, now, policy]); const canMutate = freshness === 'current'; const mutate = useCallback(async (operation: (data: T) => Promise): Promise => { const currentSnapshot = snapshotRef.current; // No verified snapshot at all: with nothing verified there is nothing // current to mutate, regardless of the recorded failure. if (currentSnapshot === null) throw new StaleMutationError('unavailable'); const state = computeFreshness({ snapshot: currentSnapshot, policy: policyRef.current, now: clockRef.current(), degraded: failureRef.current !== null || unverifiedRef.current, }); assertMutable(state); return operation(currentSnapshot.data); }, []); return { snapshot, data: snapshot === null ? null : snapshot.data, freshness, validating, failure, canMutate, revalidate, mutate, }; }