262 lines
9.8 KiB
TypeScript
262 lines
9.8 KiB
TypeScript
/**
|
|
* Typed freshness model for gateway-fetched collections (RI-5-001).
|
|
*
|
|
* A failed or stale fetch must never be indistinguishable from an empty
|
|
* healthy collection. Every fetched surface carries an explicit freshness
|
|
* state, a verified snapshot identity (source, workspace, version, age), and
|
|
* a mutation guard that refuses state-changing operations unless the data is
|
|
* verified current.
|
|
*/
|
|
|
|
/** Freshness states for fetched data. Never inferred from emptiness. */
|
|
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';
|
|
|
|
/**
|
|
* Reasons a snapshot is invalidated. An invalidated snapshot is treated as
|
|
* unavailable and is never rendered as current.
|
|
*/
|
|
export type InvalidationReason =
|
|
| 'cache-corruption'
|
|
| 'cross-workspace'
|
|
| 'schema-mismatch'
|
|
| 'version-regression';
|
|
|
|
/** Human-readable labels for invalidation reasons (UI + error messages). */
|
|
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
|
|
'cache-corruption': 'cached snapshot failed integrity checks',
|
|
'cross-workspace': 'data belongs to a different workspace',
|
|
'schema-mismatch': 'response did not match the expected schema',
|
|
'version-regression': 'snapshot version regressed below the accepted version',
|
|
};
|
|
|
|
/** A verified snapshot of fetched data with full provenance. */
|
|
export interface FreshSnapshot<T> {
|
|
readonly data: T;
|
|
/** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
|
|
readonly source: string;
|
|
/** Workspace scope the data belongs to. */
|
|
readonly workspace: string;
|
|
/** Monotonic snapshot sequence number for this surface. */
|
|
readonly version: number;
|
|
/** Schema version of the validator that accepted this snapshot. */
|
|
readonly schemaVersion: number;
|
|
/** Epoch ms at which the data was verified. */
|
|
readonly fetchedAt: number;
|
|
/** Integrity digest of `data`, used to detect cache corruption. */
|
|
readonly digest: string;
|
|
}
|
|
|
|
/** Provenance label rendered next to last-known data. */
|
|
export interface FreshnessLabel {
|
|
readonly source: string;
|
|
readonly version: number;
|
|
readonly fetchedAt: number;
|
|
}
|
|
|
|
/** Policy governing freshness for a surface. */
|
|
export interface FreshnessPolicy {
|
|
/** Active workspace scope. Snapshots from other scopes are invalidated. */
|
|
readonly workspace: string;
|
|
/** Schema version of the current validator. */
|
|
readonly schemaVersion: number;
|
|
/** Age after which a verified snapshot degrades from current to stale. */
|
|
readonly staleAfterMs: number;
|
|
}
|
|
|
|
export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
|
|
workspace: 'default',
|
|
schemaVersion: 1,
|
|
staleAfterMs: 60_000,
|
|
};
|
|
|
|
/** Payload returned by a successful schema validation. */
|
|
export interface FreshPayload<T> {
|
|
readonly data: T;
|
|
/**
|
|
* Workspace identity extracted from the payload itself when the collection
|
|
* carries one (e.g. a uniform `userId` on projects). `null` when the
|
|
* collection has no intrinsic workspace identity.
|
|
*/
|
|
readonly workspace: string | null;
|
|
}
|
|
|
|
/** Error thrown when a mutation is attempted on non-current data. */
|
|
export class StaleMutationError extends Error {
|
|
readonly freshness: FreshnessState;
|
|
|
|
constructor(freshness: FreshnessState) {
|
|
super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
|
|
this.name = 'StaleMutationError';
|
|
this.freshness = freshness;
|
|
}
|
|
}
|
|
|
|
/** Stable JSON digest used for snapshot integrity checks. */
|
|
export function computeDigest(value: unknown): string {
|
|
// FNV-1a 32-bit over the stable JSON serialization. This is an integrity
|
|
// check against corruption, not a cryptographic guarantee.
|
|
let hash = 0x811c9dc5;
|
|
for (const byte of stableStringify(value)) {
|
|
hash ^= byte.charCodeAt(0);
|
|
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
}
|
|
return hash.toString(16).padStart(8, '0');
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
return serialize(value);
|
|
}
|
|
|
|
function serialize(value: unknown): string {
|
|
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.filter(([, item]) => item !== undefined)
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
.map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
|
|
return `{${entries.join(',')}}`;
|
|
}
|
|
|
|
export type AcceptSnapshotResult<T> =
|
|
| { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
|
|
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
|
|
|
|
export interface AcceptSnapshotOptions<T> {
|
|
/** Raw fetched value (untrusted JSON). */
|
|
readonly value: unknown;
|
|
/** Schema validator; returns `null` when the value does not match. */
|
|
readonly validate: (value: unknown) => FreshPayload<T> | null;
|
|
/** Previously accepted snapshot for this surface, if any. */
|
|
readonly previous: FreshSnapshot<T> | null;
|
|
readonly policy: FreshnessPolicy;
|
|
readonly source: string;
|
|
/**
|
|
* Version carried by the incoming payload when the transport exposes one.
|
|
* Must not regress below the accepted snapshot's version.
|
|
*/
|
|
readonly incomingVersion?: number;
|
|
readonly now: number;
|
|
}
|
|
|
|
/**
|
|
* Validate and accept a fetched value as a snapshot, or invalidate it.
|
|
*
|
|
* Invalidation rules (each treated as unavailable, never rendered current):
|
|
* - schema mismatch: the payload fails validation
|
|
* - cross-workspace: the payload's workspace differs from the verified one
|
|
* - version regression: payload/schema version is below the accepted one
|
|
*/
|
|
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
|
|
const payload = options.validate(options.value);
|
|
if (payload === null) {
|
|
return { outcome: 'invalidated', reason: 'schema-mismatch' };
|
|
}
|
|
|
|
// Workspace identity: the payload's own scope wins; a collection with no
|
|
// intrinsic identity (e.g. an empty list after every project was deleted)
|
|
// keeps the previously verified scope rather than resetting to the policy
|
|
// default, so a legitimately empty response is not mistaken for a scope
|
|
// change.
|
|
const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
|
|
if (options.previous !== null && options.previous.workspace !== workspace) {
|
|
return { outcome: 'invalidated', reason: 'cross-workspace' };
|
|
}
|
|
if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
|
|
return { outcome: 'invalidated', reason: 'version-regression' };
|
|
}
|
|
if (
|
|
options.incomingVersion !== undefined &&
|
|
options.previous !== null &&
|
|
options.incomingVersion < options.previous.version
|
|
) {
|
|
return { outcome: 'invalidated', reason: 'version-regression' };
|
|
}
|
|
|
|
const snapshot: FreshSnapshot<T> = {
|
|
data: payload.data,
|
|
source: options.source,
|
|
workspace,
|
|
version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
|
|
schemaVersion: options.policy.schemaVersion,
|
|
fetchedAt: options.now,
|
|
digest: computeDigest(payload.data),
|
|
};
|
|
return { outcome: 'accepted', snapshot };
|
|
}
|
|
|
|
export interface ComputeFreshnessOptions {
|
|
readonly snapshot: FreshSnapshot<unknown> | null;
|
|
readonly policy: FreshnessPolicy;
|
|
readonly now: number;
|
|
/**
|
|
* True when the snapshot cannot be trusted as current regardless of age:
|
|
* the latest revalidation failed, or the snapshot was restored from cache
|
|
* and has not been verified by a fetch in this session.
|
|
*/
|
|
readonly degraded?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Compute the freshness state of a snapshot. A missing snapshot is
|
|
* `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
|
|
* `stale` (situational awareness only).
|
|
*/
|
|
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
|
|
const { snapshot, policy, now, degraded = false } = options;
|
|
if (snapshot === null) return 'unavailable';
|
|
if (degraded) return 'stale';
|
|
if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
|
|
return 'current';
|
|
}
|
|
|
|
/** Only verified-current data may back a state-changing action. */
|
|
export function canMutate(state: FreshnessState): boolean {
|
|
return state === 'current';
|
|
}
|
|
|
|
/** Defense in depth: reject the mutation call itself on non-current data. */
|
|
export function assertMutable(state: FreshnessState): void {
|
|
if (!canMutate(state)) {
|
|
throw new StaleMutationError(state);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Combine freshness across a multi-collection surface (primary + secondaries).
|
|
* The primary collection gates the surface: unknown while it loads,
|
|
* unavailable when it fails. Missing secondaries degrade the surface to
|
|
* `partial`; aged collections degrade it to `stale`.
|
|
*/
|
|
export function combineFreshness(
|
|
primary: FreshnessState,
|
|
secondaries: readonly FreshnessState[],
|
|
): FreshnessState {
|
|
if (primary === 'unavailable') return 'unavailable';
|
|
if (primary === 'unknown') return 'unknown';
|
|
if (secondaries.includes('unavailable')) return 'partial';
|
|
if (secondaries.includes('unknown')) return 'unknown';
|
|
if (secondaries.includes('stale') || primary === 'stale') return 'stale';
|
|
if (secondaries.includes('partial')) return 'partial';
|
|
return 'current';
|
|
}
|
|
|
|
/** Render-safe age label for snapshot provenance. */
|
|
export function formatAge(fetchedAt: number, now: number): string {
|
|
const ageMs = Math.max(0, now - fetchedAt);
|
|
if (ageMs < 10_000) return 'just now';
|
|
const minutes = Math.floor(ageMs / 60_000);
|
|
if (minutes < 1) return 'under a minute ago';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
/** Derived verdict placeholder for non-current inputs — never a green value. */
|
|
export const UNKNOWN_VERDICT = '?';
|
|
|
|
export function verdictValue(verified: boolean, value: string): string {
|
|
return verified ? value : UNKNOWN_VERDICT;
|
|
}
|