Which caches exist where#
External integrators repeatedly misattribute staleness — this is the truth table (verified live 2026-08-04):
| surface | caches in play |
|---|---|
Admin previews (the Studio / document editor at inmake.dev) |
The renderer's own source store, in the browser (since 2026-09-04; the hosted /preview server ran live: true before): saved values are served for 30 s and refetched on the admin's own writes, drafts win over saved. A store write from that tab is visible at once; one from elsewhere within 30 s. A "stale" page here is the document's templateVersion pin or a broken placement — never a render cache. |
| Uploaded store files (everywhere) | GCS edge (~minutes). In live mode /assets/** 302s to the storage URL, so the object rides GCS's cache. Never overwrite an object in place when freshness matters — use a content-addressed name. |
| The browser | Whatever it was already given. Assets served by this package carry short max-age + ETag; live-mode HTML is no-store. |
| Self-hosted, non-live server (this package as the store's own renderer) | Everything in the options table above. With hookKey: every hook-covered cache 4 h, evicted by its push (data, files, templates, elements, setup, slug); misses 60 s. Without hookKey: render 30 min, data 60 min, meta 60 s, template 30 min, assets + slug index 60 min. |
Read/push races. A push that lands while a read of the same key is in
flight never loses to that read: the data sidecar rules above, plus an
EvictionGuard (exported from cache/) around render, meta, template
and the slug index — every delete/clear marks, and each read caches
through setUnlessEvicted(key, value, ticket) with a ticket taken before it
started. A raced render answers once with cached: false and caches / indexes
nothing; the next request re-renders. The {{t}} translations map discards a
warm that a push overtook.
The invariant to state in any store project's docs: wire the multi-event
server hook (receiveEventHook) and pass hookKey — every edit then
evicts eagerly and the caches can run 4 h; without the feed they ride the
short legacy TTLs. The document's templateVersion pin moves together with
any version bump. Run one replica per hook endpoint with 4 h TTLs (a push
reaches one process) — or share render + deps (below).
Running more than one replica (caches)#
Every cache slot defaults to in-memory, which is correct for one process and
wrong for a fleet: the backend POSTs each data hook to one endpoint, so
only the replica that took the hook evicts anything. The rest serve stale HTML
until renderTtlMs — 4 h with a hookKey, so a fleet without shared
render + deps should lower feedTtlMs.
Share render and deps together — neither alone makes invalidation
correct:
new WebSite({
...,
caches: {
render: new RedisCache<string>({ttlMs: 5 * 60_000}), // rendered HTML
deps: new RedisCache<string[]>({ttlMs: 5 * 60_000}), // reverse dep index
data: new RedisCache(), // optional
},
});renderalone evicts the pushed document's own page fleet-wide. That's the bulk of the win.depsis the reverse index — which pages depend on this document — and it is what lets a replica that never rendered page X evict X when a dependency of X is pushed (a referenced doc, or any doc in a referenced collection). Without it those evictions degrade to the render TTL everywhere but the one replica that took the hook. A shell-doc (header/footer) write, which should drop every page, is exactly this case.
Any Cache implementation works — five async methods (get/set/delete/
has/clear). The deps value is a plain string[], so it round-trips JSON.
The read/push race protection splits along the same line: the data rules live
in the shared {key}#v sidecar (skew-tolerant, so they hold across replicas),
but the EvictionGuard marks are per process — a push taken by replica A
does not stop replica B's in-flight read from caching into a shared render
/ meta slot. Rare (a read and a push for the same key in the same few
hundred ms, on different replicas), and bounded by the TTL; a shared
#evicted sidecar would close it if a fleet ever needs that.
⚠️
depsmust outliverender: give it a TTL ≥ the render TTL. An index entry that expires before the HTML it points at leaves that HTML unevictable until its own TTL runs out. Injected caches own their own eviction policy, so this one is on you.
Two properties of the design, so they're not surprises in production: the index append is read-modify-write, so two replicas indexing different pages under the same dependency in the same instant can lose one link (cost: a missed eviction on one page, which falls back to the TTL — i.e. today's behaviour, for one page in a rare race rather than for every page always); and stale links are tolerated on purpose — a page that re-renders with different deps keeps its old links, so invalidation may delete a render key that's already gone. Over-eviction costs a re-render; under-eviction serves stale HTML.
Disk-backed caches (DiskCache)#
RAM is the expensive tier; for a single-process server that holds many stores (the tenant server), every slot can live on local disk instead:
// Node-only — deliberately NOT on the package index (the index's module
// graph is bundled into the admin SPA, where `fs` would break the build):
import {DiskCache, textCodec, assetCodec} from 'drop-core-server/lib/cache/disk_cache';
new WebSite({
...,
caches: {
render: new DiskCache<string>({dir: '/tmp/cache/render', ttlMs: 5 * 60_000,
maxBytes: 32 * 2 ** 20, extension: '.html', codec: textCodec}),
deps: new DiskCache<string[]>({dir: '/tmp/cache/deps', ttlMs: 5 * 60_000}),
// data / meta / template: default JSON codec; assets: assetCodec
// (raw bytes on disk + a `.meta.json` sidecar — an image entry is
// just the image file).
},
});One file per entry, laid out along the key segments (a render entry for
cs_CZ/web/index is {dir}/cs_CZ/web/index.html — inspectable with ordinary
file tools). TTL rides the file's mtime; maxBytes bounds each directory
(oldest-written evicted first, an over-budget single entry is not stored —
same contract as ByteBudgetCache); writes are atomic (temp + rename); every
failure degrades to a cache miss. Entries survive process restarts — a warm
disk turns a redeploy's first requests into cache hits instead of re-renders.
Note it is still per-machine storage: the multi-replica sharing story
above (render+deps on a shared backend) is a different concern that disk
does not solve.