import { computeDigest, type FreshPayload, type FreshSnapshot, type FreshnessPolicy, type InvalidationReason, } from './model'; /** * Session-scoped last-known snapshot cache (RI-5-001). * * Restored snapshots are situational awareness only: they surface as `stale` * until a fetch re-verifies them. A cache entry that is corrupted, belongs to * another workspace, was written by a newer schema, or no longer validates is * invalidated (treated as unavailable, never rendered as current). */ const CACHE_PREFIX = 'mosaic:freshness:v1'; interface StoredSnapshot { data: unknown; source: string; workspace: string; version: number; schemaVersion: number; fetchedAt: number; digest: string; } export type SnapshotCacheRead = | { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot } | { readonly outcome: 'miss' } | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason }; export interface ReadSnapshotCacheOptions { readonly key: string; readonly workspace: string; readonly policy: FreshnessPolicy; readonly validate: (value: unknown) => FreshPayload | null; } function cacheKey(key: string): string { return `${CACHE_PREFIX}:${key}`; } function isStoredSnapshot(value: unknown): value is StoredSnapshot { if (typeof value !== 'object' || value === null) return false; const candidate = value as Record; return ( typeof candidate['data'] === 'object' && candidate['data'] !== null && typeof candidate['source'] === 'string' && typeof candidate['workspace'] === 'string' && typeof candidate['version'] === 'number' && typeof candidate['schemaVersion'] === 'number' && typeof candidate['fetchedAt'] === 'number' && typeof candidate['digest'] === 'string' ); } function getStorage(): Storage | null { try { return globalThis.sessionStorage ?? null; } catch { return null; } } /** * Restore a cached snapshot under the active workspace scope. Every failure * mode maps to an explicit invalidation reason or a miss — never to data * that renders as current. */ export function readSnapshotCache(options: ReadSnapshotCacheOptions): SnapshotCacheRead { const storage = getStorage(); if (storage === null) return { outcome: 'miss' }; let raw: string | null; try { raw = storage.getItem(cacheKey(options.key)); } catch { return { outcome: 'miss' }; } if (raw === null) return { outcome: 'miss' }; let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { outcome: 'invalidated', reason: 'cache-corruption' }; } if (!isStoredSnapshot(parsed)) { return { outcome: 'invalidated', reason: 'cache-corruption' }; } if (parsed.workspace !== options.workspace) { return { outcome: 'invalidated', reason: 'cross-workspace' }; } if (parsed.schemaVersion > options.policy.schemaVersion) { // Written by a newer build than the running client: version regression. return { outcome: 'invalidated', reason: 'version-regression' }; } const payload = options.validate(parsed.data); if (payload === null) { return { outcome: 'invalidated', reason: 'schema-mismatch' }; } if (computeDigest(payload.data) !== parsed.digest) { return { outcome: 'invalidated', reason: 'cache-corruption' }; } return { outcome: 'hit', snapshot: { data: payload.data, source: parsed.source, workspace: parsed.workspace, version: parsed.version, schemaVersion: parsed.schemaVersion, fetchedAt: parsed.fetchedAt, digest: parsed.digest, }, }; } /** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */ export function writeSnapshotCache(key: string, snapshot: FreshSnapshot): void { const storage = getStorage(); if (storage === null) return; const stored: StoredSnapshot = { data: snapshot.data, source: snapshot.source, workspace: snapshot.workspace, version: snapshot.version, schemaVersion: snapshot.schemaVersion, fetchedAt: snapshot.fetchedAt, digest: snapshot.digest, }; try { storage.setItem(cacheKey(key), JSON.stringify(stored)); } catch { // Quota or serialization failures simply skip caching. } } /** Drop a cached snapshot (used when a surface invalidates its cache entry). */ export function clearSnapshotCache(key: string): void { const storage = getStorage(); if (storage === null) return; try { storage.removeItem(cacheKey(key)); } catch { // Ignorable: a wedged storage entry is detected as corruption on read. } }