Accepted

ADR 0021 — Schema cache shape and invalidation

A KV-backed compiled-schema cache fronts the content model, with a per-isolate L1 in front of it

The admin form, the headless REST surface, and the public renderer all need the compiled shape of a content type — the collection plus its ordered, fully-hydrated fields — on hot paths. Hydrating that from D1 (and from R2 spillover) on every request is the wrong cost. The schema cache is a denormalised, read-shaped projection of the content model. D1 remains the source of truth; the cache is best-effort and recoverable.

Decision

The cache lives at src/shared/cache/schema-cache.ts and exposes get, rebuild, purge, and list. It stores one logical record under two KV prefixes:

  • schema:collection:{id} — the full compiled record (CompiledSchema JSON): the hydrated collection, its fields ordered by position, a cacheVersion stamp, and a cachedAt timestamp in Unix seconds.
  • schema:slug:{slug} — a pointer holding the collection ID as plain text. A read by slug resolves the pointer, then fetches the record.

The ID-keyed record is the source of truth; the slug pointer is indirection. Because the ID is stable across renames, a slug change only rewrites the pointer — the primary record stays put under its unchanged key, which is what makes the two-prefix scheme tolerant of inconsistency. R2 spillover is resolved at compile time, so consumers never deal with config_r2_key; the compiled record carries fully-hydrated fields.

Invalidation: a post-commit hook in the repository

Each mutation method on the collections and field-definitions repositories calls rebuild (or purge, on collection delete) as its last step, after the versioned write returns successfully. The repository is the single chokepoint every mutation already passes through, so install seeding, imports, and admin routes all invalidate the cache without any caller-side code. The call is awaited, not fire-and-forget — the editor expects the change to take effect on the next read. A cascade-delete suppresses the per-field rebuilds and issues one purge at the end.

The GET /api/v1/_meta OpenAPI document is derived from the same compiled records, but it needs no cache invalidation: it is generated per request and scope-filtered to the presenting token, so it is served private, no-store rather than edge-cached. An earlier design edge-cached it under an openapi surrogate key that this rebuild hook purged; that machinery has since been removed, because a per-token, no-store body is never shared at the edge in the first place.

Staleness: best-effort writes, rebuild on stale read

The write path does not guard against version regression — racing against KV's eventual consistency at write time is a category mistake. Instead the read path detects staleness: it compares the record's cacheVersion against MAX(version_number) across the collection and its fields in D1, and rebuilds if the cache is behind. To keep that check off the hottest path, reads within a 5-second freshness window (CACHE_VERIFY_INTERVAL_MS) of cachedAt skip the D1 probe. The net steady-state cost is one verification query per collection per 5 seconds, not per request. A per-isolate L1 cache sits in front of KV, reusing the same freshness window, so warm isolates absorb the chain entirely.

The renderer reads the schema cache

The public render path, the site-artefacts module (sitemap and feeds), and the publish workflow's render step all read collection metadata through schemaCache.get and schemaCache.list rather than hitting the repository directly. Themes run at render time and ask the same question the admin and headless consumers ask — what fields does this collection have, in what order? — so themes inherit the cache as a contract rather than discovering it as an optimisation. The null contract is preserved end to end: if D1 has the row the cache returns it, and if D1 does not the cache returns null and the renderer's 404 path fires.

Consequences

Consumers get a single read path that resolves a slug or ID into a fully-hydrated, ordered compiled schema in sub-millisecond cache-hit time, with spillover hydration paid once per write instead of once per read. The trade-off is a bounded within-window staleness on the render path — up to 5 seconds between two unrelated reads — which is invisible in the normal edit-then-publish cycle because the publish workflow's surrogate-key purge clears the rendered HTML. A KV outage degrades the renderer to a D1 fall-through rather than a 500.

Surrogate-key cache scheme