282 lines
9.8 KiB
TypeScript
282 lines
9.8 KiB
TypeScript
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<T> {
|
|
/** 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<unknown>;
|
|
/**
|
|
* Runtime schema validator. Returning `null` invalidates the payload
|
|
* (`schema-mismatch`) instead of letting malformed JSON flow into render.
|
|
*/
|
|
readonly validate: (value: unknown) => FreshPayload<T> | null;
|
|
/** Overrides of the default freshness policy. */
|
|
readonly policy?: Partial<FreshnessPolicy>;
|
|
/**
|
|
* 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<T> {
|
|
/** Last verified (or restored-unverified) snapshot, or `null`. */
|
|
readonly snapshot: FreshSnapshot<T> | 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<void>;
|
|
/**
|
|
* 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: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
|
|
}
|
|
|
|
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<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
|
|
const optionsRef = useRef(options);
|
|
optionsRef.current = options;
|
|
|
|
const policy = useMemo<FreshnessPolicy>(
|
|
() => ({ ...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<FreshSnapshot<T> | null>(null);
|
|
const [failure, setFailure] = useState<FreshnessFailure | null>(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<AbortController | null>(null);
|
|
|
|
const revalidate = useCallback(async (): Promise<void> => {
|
|
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<T>({
|
|
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<FreshnessState>(() => {
|
|
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 <R>(operation: (data: T) => Promise<R>): Promise<R> => {
|
|
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,
|
|
};
|
|
}
|