155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
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<T> =
|
|
| { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot<T> }
|
|
| { readonly outcome: 'miss' }
|
|
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
|
|
|
|
export interface ReadSnapshotCacheOptions<T> {
|
|
readonly key: string;
|
|
readonly workspace: string;
|
|
readonly policy: FreshnessPolicy;
|
|
readonly validate: (value: unknown) => FreshPayload<T> | 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<string, unknown>;
|
|
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<T>(options: ReadSnapshotCacheOptions<T>): SnapshotCacheRead<T> {
|
|
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<T>(key: string, snapshot: FreshSnapshot<T>): 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.
|
|
}
|
|
}
|