drizzle-page-cache
v0.1.2
Published
Tag-based HTTP page-cache invalidation for Drizzle ORM apps — Surrogate-Key headers derived from queries, purged on writes.
Maintainers
Readme
drizzle-page-cache
Tag-based HTTP page-cache invalidation for Drizzle ORM apps.
Put any Drizzle app behind a tag-aware HTTP cache (Caddy/Souin, Varnish xkey,
Fastly, OpenLiteSpeed — or plain nginx/Angie made tag-aware by this package's
Lua helper) and get automatic, event-driven invalidation: cache tags are
derived from the queries each request actually executes, emitted as a response
header in the cache's dialect (Surrogate-Key, xkey, X-LiteSpeed-Tag), and
purged when writes touch the same tables or rows.
[!NOTE] This is not Drizzle's built-in
cacheoption. That first-party feature caches SELECT query results (in Redis, e.g. Upstash) inside your app, saving database round-trips. This package operates a layer further out: it invalidates whole cached pages in the HTTP proxy/CDN in front of your app, saving the render as well as the queries. They solve different problems and can be used together.
Contents: Why a page cache? · Installation · Quickstart · What gets cached · Tag model · Security · Purgers · Performance · Observability · Namespacing · Development · License
Why a page cache?
Every request to a DB-backed page — /post/7, the front page, a listing —
re-runs the same queries and re-renders the same HTML until something actually
changes. A shared HTTP cache in front of your app serves those pages from memory
instead. In this repo's benchmarks, even a trivial one-query
page goes from ~11.5k req/s at 0.58 ms p50 (app rendering every request) to ~50k
req/s at ~0.11 ms behind a cache, at a third of the CPU per request — and the
more queries and rendering a page costs, the bigger the win.
The catch has always been invalidation. A plain TTL cache forces a bad trade: cache long and serve stale pages, or cache short and barely cache at all. Tag-aware caches fix this — every stored page carries tags, and you evict by tag the instant data changes — but then you must know, for every page, which rows it depends on, and remember to purge them on every write path. That bookkeeping is exactly what this package automates: it watches the Drizzle queries a request runs, tags the response with the tables and rows it read, and purges those tags when your writes touch them. Edit post 7, and precisely the pages that showed post 7 refresh — immediately, not at TTL expiry.
GET /post/7 GET /post/7 (cache miss)
browser ─────────────► caching proxy ─────────────────────► your app
◄───────────── stores + serves ◄─────────────────── Surrogate-Key: posts:7
<1 ms hits tagged pages (this package)
▲
└──── purge "posts:7" ◄──── db.update(posts)…(id = 7)It's a fit for pages that look the same for every (anonymous) visitor — content
sites, shops, docs, feeds. It does nothing for fully personalized apps:
responses carrying Set-Cookie or private are never cached (see
What gets cached), and a static site has nothing to
invalidate.
- Zero dependencies (peer:
drizzle-orm). Web-standardRequest/Responseonly. - No SQL parsing. Tags derive structurally from query builders and their results.
- Runtime-agnostic: Deno, Node, Bun (Cloudflare Workers needs
nodejs_compatforAsyncLocalStorage). - Fails safe: anything unrecognized over-tags — an unnecessary purge costs one re-render, a missed tag would serve stale content, so every fallback errs toward purging too much, never too little.
Installation
# Node / Bun
npm install drizzle-page-cache
# Deno
deno add npm:drizzle-page-cachedrizzle-orm (>=0.44 <1) is a peer dependency — you already have it. Nothing
else is pulled in.
Works on Deno, Node ≥ 18, and Bun. Cloudflare Workers needs the
nodejs_compat
flag (for AsyncLocalStorage).
Quickstart
Two halves: this package in your app, and a caching proxy in front of it (the proxy is what stores the pages — without one, nothing is cached).
In the app — wrap your drizzle instance and your fetch handler:
import { drizzle } from "drizzle-orm/better-sqlite3";
import { createPageCache } from "drizzle-page-cache/souin";
import * as schema from "./schema.ts"; // your drizzle schema
const pageCache = createPageCache({
schema,
ttl: 3600, // s-maxage, the backstop — tags do the real invalidation
site: "http://localhost", // the proxy's base URL, as reachable FROM the app
});
const db = pageCache.wrap(drizzle(client, { schema })); // same API in, same out
// your server — Hono, plain Deno.serve, anything (Request) => Response
export default { fetch: pageCache.middleware(app.fetch) };(On Node without a web-standard server, a ~40-line node:http adapter does the
(Request) => Response bridging — see e2e/app/server-node.ts.)
In front of the app — any of the five supported caches
(which one?). The smallest config is Caddy with Souin — but note
Souin is a Caddy plugin, so it takes a custom-built Caddy binary (a two-line
xcaddy build; see Souin / Caddy):
{
order cache before rewrite
order header before cache # run the strip below AFTER Souin reads the tags
cache {
ttl 300s
api { souin } # exposes the purge API at /souin-api/souin
}
}
:80 {
header -Surrogate-Key # tags name your tables + row IDs — keep them off clients
cache
reverse_proxy your-app:8000
}Your app now tags every cacheable response (see
What gets cached — by default GET and 2xx) with:
Surrogate-Key: posts:7 users dpc-all
Cache-Control: max-age=0, s-maxage=3600, stale-while-revalidate=30max-age=0 is deliberate: browsers can't be purged, so only shared caches
hold pages.
[!IMPORTANT] That
Surrogate-Keyheader names your tables and row IDs. It's for the cache, not the browser — the config above strips it before it reaches clients. For other proxies, see Security.
When a write executes — db.update(posts).set(...).where(eq(posts.id, 7)) — the
matching tags (posts:7, posts) are purged automatically, batched and
deduplicated, and (inside db.transaction) held until commit, dropped on
rollback.
See it work
curl -si http://localhost/post/7 | grep -i cache-status # first: miss
curl -si http://localhost/post/7 | grep -i cache-status # now: hit
# a write purges it — next read is a miss with the fresh body:
curl -X POST http://localhost/edit/7 -d title=updated # your write endpoint
curl -si http://localhost/post/7 | grep -i cache-status # miss againThe hit/miss header depends on the proxy: Souin sends Cache-Status,
nginx/Angie X-Cache-Status, LiteSpeed X-LiteSpeed-Cache. During development,
set debug: true to also get X-Cache-Tags on every response — it shows
exactly which data each page depends on.
No app to try it with yet? cd e2e && ./verify.sh caddy runs the full loop —
render → HIT → write → purge → fresh body → HIT — against a demo app in Docker.
What gets cached
Two layers decide whether a response gets tag + cache headers:
Safety gate — always enforced, not configurable. A response carrying
Set-Cookie, orprivate/no-store/no-cacheinCache-Control, is never tagged or made shareable — your app's non-shareable signals win, so a personalized page can't be promoted into a shared cache. (The nginx Lua helper independently refuses to store such responses, too.)Policy —
shouldTag, yours to change. Default:GET&& 2xx. Narrow it to keep sections out of the cache:createPageCache({ schema, site, // (or `purger` with the root API — shouldTag works the same everywhere) shouldTag: (req, res) => req.method === "GET" && res.ok && !new URL(req.url).pathname.startsWith("/admin/"), });or widen it — e.g. allow 404s, which entity-miss tags already invalidate when the row is later created.
For authenticated areas, the best fix isn't path exclusion here: have your auth
middleware send Cache-Control: private (or a session Set-Cookie) and the
safety gate handles it everywhere, including at the proxy. Use shouldTag as
the fallback when you can't change those responses.
Responses that ran no observed queries (no tags) always pass through untouched.
Tag model
| Query | Tags on the response | Purged by |
| --------------------------------------- | ---------------------------- | ---------------------- |
| single row by PK/unique equality (hit) | posts:7 | update/delete of row 7 |
| single row by PK/unique equality (miss) | posts:7 + posts | any write to posts |
| list / filtered / ordered reads | posts | any write to posts |
| joins & relational with | tags for each table involved | writes to either table |
| anything unrecognized | dpc-unknown | every purge |
| every tagged page (always) | dpc-all | purgeAll() only |
Two reserved tags (rename with unknownTag / allTag — any name except a
table name, rejected at init):
dpc-unknownis the unknown bucket: every purge batch carries it, so a page the wrapper couldn't read can never outlive a write.dpc-allrides every tagged response and no automatic purge — one deliberate purge of it flushes everything this cache made cacheable.
Escape hatches for pages the wrapper can't see through (raw SQL, computed pages):
pageCache.tag("posts:7"); // add a tag to the current request
pageCache.purgeBatch("posts"); // join the pending purge batch (fire-and-forget)
await pageCache.purge("posts"); // purge immediately — REJECTS on failuredb.batch([...]) is observed too: it derives purges from its statements, so a
batch of recognized writes purges exactly their tags (reads in a batch need
none). Only a statement the facade can't read structurally — a raw sql
statement, a relational-query builder, or a builder made on the unwrapped db —
falls back to the unknown bucket with an unobserved-write warning. Root-level raw
execution (db.execute/db.run with a raw sql statement) is likewise opaque;
pair those with pageCache.purgeBatch(...).
Deploys: purge everything
A release changes templates and assets, so cached pages are stale with no DB write to say so. Drizzle apps already run JS on deploy — purge right after migrating:
// deploy.ts — after `drizzle-kit migrate`
import { pageCache } from "./cache.ts"; // the same instance your app builds
await pageCache.purgeAll(); // immediate; throws → deploy fails loudlycreatePageCache needs no DB connection for this — schema is a plain import,
and purgeAll() only talks to the proxy. No JS runtime in the pipeline? Purge
the literal tag (mind your tagPrefix) with one request in each dialect, e.g.
nginx/Angie:
curl -X POST http://proxy/__dpc/purge -H 'Surrogate-Key: dpc-all'. Note the
scope: purgeAll() evicts what this cache tagged — pages a proxy cached by
its own config (no headers from us) only expire by TTL.
Security: the tag header leaks row IDs
The whole mechanism rides on a response header —
Surrogate-Key: posts:7 users:3 dpc-all (or X-LiteSpeed-Tag, or xkey,
depending on dialect) — that spells out your table names and primary-key
values. It's meant for the cache in front of your app, not the browser. Left
visible, it hands every visitor your schema and lets them enumerate row IDs
(worse with sequential integer PKs, where posts:7 says there are at least
seven posts).
Stripping it is the proxy's job — and it can't be done from the app, because
the proxy has to read the header to index the cached page, then remove it on
the way out. None of the self-hosted caches do it for you: Fastly defined the
convention with edge-stripping built in, but Souin, for one,
documents delivering the header to the client.
The reference configs in e2e/ are set up to strip it, and
e2e/verify.sh asserts the tag header never reaches the client (step 3):
| Cache | How to strip it | Notes |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| nginx / Angie | proxy_hide_header Surrogate-Key; in the proxied location | Purging still works: the Lua log phase reads $upstream_http_surrogate_key, which proxy_hide_header leaves untouched. |
| Caddy / Souin | header -Surrogate-Key plus order header before cache | The ordering is load-bearing — header must sit outside cache so its deferred delete runs after Souin indexes the tag. Wrong order silently breaks purging. (disable_surrogate_key is not the fix — it turns off tag indexing entirely, so every purge misses.) |
| Varnish (xkey) | unset resp.http.xkey; in vcl_deliver | vcl_deliver runs after the object is cached and indexed (in vcl_backend_response), so this is client-facing only. |
| Fastly / Surrogate-Key CDN | nothing — Fastly strips Surrogate-Key before delivery unless the request carries Fastly-Debug | Verify against your own service; other CDNs may differ. |
| OpenLiteSpeed | see below | Cannot be stripped at the OLS layer. |
OpenLiteSpeed is the exception worth reading carefully. OLS forwards
X-LiteSpeed-Tag to the client on cache misses (it strips it on hits, so
it's easy to miss in casual testing). Its only response-header operation,
extraHeaders unset X-LiteSpeed-Tag, also removes the tag the LSCache module
indexes on — so stripping the leak breaks tag purging (verified in the e2e
suite; verify.sh ols prints a WARN about this rather than failing). This is
a known OpenLiteSpeed limitation (an upstream fix is in progress). Two ways out:
strip it at a CDN/edge in front of OLS, or front OLS with the bundled nginx-LS
config (e2e/nginx/nginx-ls.conf), which hides
X-LiteSpeed-Tag cleanly because the Lua reads the $upstream_http_* copy the
strip doesn't touch.
Confirm it against your own edge — the only header that's safe is the one you've checked is gone from what clients actually receive:
curl -sI https://your-site.example/any-page |
grep -i 'surrogate-key\|x-litespeed-tag\|xkey'
# no output = not leakingStripping the wire header isn't the whole story: debug: true stamps the same
table and row identifiers onto a separate X-Cache-Tags header on every
response (see Observability), and your proxy strips
Surrogate-Key, not that one — so keep debug off in production.
Purgers
Which cache should you run? Two questions decide it:
- Already have one of the five in front of your app (or a
Surrogate-Key-speaking CDN like Fastly)? Use its entrypoint below — done. This especially means nginx: if it's already your edge, there is no decision to make. - Starting from scratch — how do you deploy?
- Docker: Caddy + Souin. Copy the two-stage build from
e2e/Dockerfile.caddyand the 10-line Caddyfile above; HTTPS is automatic and there's little left to misconfigure. The custom-binary cost disappears into an image rebuild. (It's also the slowest verified stack — but at ~37k hits/s that won't be your problem.) - Distro packages on a VPS: nginx or Angie + the bundled Lua helper.
Nothing custom to build or maintain — the lua module is an
apt/apkpackage — and it's the fastest verified stack. The trade is more config than Caddy, with semantics worth reading before going live. - Neither, and open to adopting a new web server: OpenLiteSpeed — tag support is native (nothing to build, no helper script) and it led the TLS benchmarks. The trades are learning a new server, and a purge model that is header-driven and eventually consistent by a few seconds.
- Docker: Caddy + Souin. Copy the two-stage build from
All of those are e2e-verified, Varnish included (e2e/verify.sh proves the
write → purge → fresh-content loop, and that the tag header is stripped, against
every pairing).
Each supported cache has a directory entrypoint — drizzle-adapter style — that
wires its purger from a site URL. Entrypoints are named by wire dialect,
with product aliases for discoverability:
drizzle-page-cache/surrogate-key—Surrogate-Keyheader + aPOSTpurge endpoint (the wire shape of Fastly's batch purge API; also serves any CDN that accepts it). Product aliases:/nginxand/angie(nginx family made tag-aware by this package's Lua helper, see below).drizzle-page-cache/xkey—xkeyheader +PURGE. Product alias:/varnish.drizzle-page-cache/souin— Souin's API (Caddy cache-handler).drizzle-page-cache/litespeed— OpenLiteSpeed's header-driven dialect (see below).
For anything else, use the root createPageCache with a purger — built in:
litespeedPurger, souinPurger, varnishPurger, nginxPurger,
webhookPurger — or implement Purger (one method) for your CDN.
Souin / Caddy
Souin is tag-native, but it is not in the stock Caddy binary — it's a plugin, so you build Caddy with it (or use an image that did):
xcaddy build \
--with github.com/darkweak/souin/plugins/caddy \
--with github.com/darkweak/storages/otter/caddyTwo traps the e2e bring-up hit, both baked into that command: build with
darkweak/souin/plugins/caddy, not the similarly-named
caddyserver/cache-handler module (it lags upstream and its Surrogate-Key
purging is broken — see the note in the Dockerfile); and add the otter
storage backend — Souin's default in-memory store manages ~2.6k req/s, otter is
~10× that (BENCHMARKS.md). The working build to copy is
e2e/Dockerfile.caddy.
Once built, there is nothing to add config-side except the purge API, which
you must enable server-side: the api { souin } line in the
Quickstart Caddyfile.
The entrypoint (site, plus apiPath — default /souin-api/souin) sends one
PURGE there with the tags in a Surrogate-Key header. Note Souin parses
Surrogate-Key as comma-separated — unlike the space-separated Fastly
convention — so this dialect emits and purges tags comma-joined (a
space-joined header would be stored as one composite key that no purge ever
matches); the header name and separator are dialect-controlled here.
If you drive Caddy by its JSON config (API/caddy adapt) rather than a
Caddyfile, the
cache-handler plugin needs the API turned on there too — see BENCHMARKS.md
findings 1–2 for the patched-JSON caveat. Working config: e2e/Caddyfile
(adapted to e2e/caddy.json by gen-caddy-json.sh); the write → tag-purge loop
is verified by e2e/verify.sh caddy (and caddy-node, caddy-bun).
Varnish (xkey)
The entrypoint emits the tags in an xkey response header — the one the
xkey vmod registers keys from automatically on import xkey; (it never reads
Surrogate-Key) — and sends one PURGE to site with the tags in an xkey
request header. So your VCL needs the xkey vmod and a PURGE handler:
vcl 4.1;
import xkey;
# Varnish applies no auth to PURGE — restrict it to your app's network.
acl purge_allow { "localhost"; /* + your app hosts */ }
sub vcl_recv {
if (req.method == "PURGE") {
if (client.ip !~ purge_allow) { return (synth(403, "Forbidden")); }
# invalidate every object tagged with any key in the xkey header
set req.http.n-purged = xkey.purge(req.http.xkey);
return (synth(200, "Purged " + req.http.n-purged));
}
}
sub vcl_deliver {
# Custom config REQUIRED to strip the tag header: vmod-xkey does not
# remove `xkey` from responses, and it names your tables and row IDs.
# vcl_deliver runs after the object was indexed (vcl_backend_response),
# so purging is unaffected.
unset resp.http.xkey;
}xkey.purge is a hard purge, which is what you want with this package's
stale-while-revalidate: Varnish maps that to grace, so xkey.softpurge would
keep serving the stale body until grace ran out — the purge would look like a
no-op. Note the unset resp.http.xkey is not optional hygiene: no part of
Varnish strips the tag header for you (see
Security).
The write → purge → fresh-content loop and the header strip are verified by
e2e/verify.sh varnish; the working config to copy is
e2e/varnish/default.vcl (including a
compose-network purge_allow ACL).
LiteSpeed / OpenLiteSpeed
OpenLiteSpeed (GPLv3) has native tag support with its own dialect: a different tag header, its own cache-control header, and header-driven purging (the purge instruction rides a backend response through the proxy). Use the dedicated entrypoint — drizzle-adapter style — which derives the whole dialect from three inputs:
[!WARNING] OLS leaks
X-LiteSpeed-Tagto clients on cache misses and can't strip it without breaking purging — the one case where the tag header can't be hidden at the cache layer. See Security for the two workarounds before going to production.
import { createPageCache } from "drizzle-page-cache/litespeed";
const pageCache = createPageCache({
schema,
site: "https://example.com", // the proxy's PUBLIC base URL
token: PURGE_TOKEN,
ttl: 300, // drives s-maxage AND X-LiteSpeed-Cache-Control together
});One token guards both halves of the purge loop (the middleware's echo route
and the purger that fetches it via the proxy — a purge header the proxy
never sees purges nothing); one site keeps their paths in agreement; one ttl
keeps the two cache-control headers coherent; and unknownTag/allTag must
never be * — a literal * purge flushes LiteSpeed's entire cache, so
the entrypoint refuses it (the defaults dpc-unknown/dpc-all are already
safe). The dialect-controlled options (header, headerSeparator,
cacheHeaders, purger, purgeEcho) are rejected at compile time; for a
custom setup, use the root createPageCache with those options explicitly:
createPageCache({
schema,
purger: litespeedPurger(
"https://example.com/__drizzle-page-cache/purge",
token,
),
header: "X-LiteSpeed-Tag",
headerSeparator: ",",
cacheHeaders: { "X-LiteSpeed-Cache-Control": "public, max-age=300" },
purgeEcho: { token },
});Note OpenLiteSpeed batches purges internally, so eviction is
eventually-consistent by a few seconds. Working OLS server config in e2e/ols/.
nginx-family (free nginx / Angie): tag purging via Lua
Stock nginx has no tag support — this package ships
nginx/purge.lua to add it, so purging is exactly as
row-precise as on the tag-native proxies and there is nothing to declare:
import { createPageCache } from "drizzle-page-cache/nginx"; // or /angie — identical
const pageCache = createPageCache({
schema,
site: "http://nginx",
});How it works: the middleware already stamps every cacheable response with its
tags (Surrogate-Key: posts posts:3). The Lua log phase records each cache
key's tags in a lua_shared_dict whenever nginx stores a response. A purge is
one POST <site>/__dpc/purge with the invalidated tags in a Surrogate-Key
header — the wire shape of Fastly's batch purge API — and later requests whose
recorded tags were purged set $skip_cache for proxy_cache_bypass, refreshing
the entry from upstream. The endpoint (its own nginx location) also serves
Fastly-style POST /__dpc/purge/<tag> and POST /__dpc/purge_all for curl and
ops tooling, with PURGE accepted as a method alias.
It runs on any nginx with lua-nginx-module — distro packages (Alpine
nginx-mod-http-lua, Debian/Ubuntu libnginx-mod-http-lua), OpenResty, or
Angie's official angie-module-lua — two load_module lines and two location
blocks (full config in the file header). No compiling.
Semantics worth knowing:
- A purge marks entries stale rather than deleting them — eviction happens
on the next request (
X-Cache-Status: BYPASS), not at purge time. - Unknown keys are refreshed, never trusted: a cache key the shared dicts
don't know (first sight, proxy restart, dict eviction) is fetched fresh and
re-recorded — stale-proof even when a disk cache outlives a restart, at the
cost of one upstream fetch. It reads
X-Cache-Status: MISS, which to the client it is;BYPASSmeans exactly "a purge evicted this". proxy_cache_keymust be declared as$uri$is_args$args— the Lua helper mirrors that exact key string.- Purge marks self-size — no lifetime to configure. A mark ("refresh this
entry on its next request") must outlive every page it may need to invalidate,
so each purge carries the answer: the purger sends
X-DPC-Mark-TTL: ttl + staleWhileRevalidateand the Lua keeps the mark exactly that long. The same app config that stamps page freshness sizes the marks, so the two can't drift. Headerless purges (curl, ops tooling) are remembered for 30 days; the purge response'smarkTtlfield echoes what was applied. One edge: a deploy that lowersttlleaves entries stamped under the old, longer config under-covered by new marks — follow it with onePOST /__dpc/purge_all. proxy_hide_header Surrogate-Keyis fine (recommended in production — tags leak schema names): the log phase reads the upstream header, not the client-facing one.- Purges only enter through the dedicated endpoint location — guard that one
block with
allow/denyand/orset $dpc_purge_token "…"(clients must then send a matchingX-Purge-Token; the entrypoint'spurgeTokenoption does). The e2e configs leave it open on purpose.
Working server configs in e2e/nginx/nginx.conf (Alpine nginx +
nginx-mod-http-lua, built by e2e/Dockerfile.nginx) and
e2e/nginx/angie.conf (Angie's lua module); the write → tag-purge loop —
including row precision (editing post 3 must NOT evict post 2) — is verified by
e2e/verify.sh nginx and e2e/verify.sh angie.
Second flavor — the LiteSpeed dialect on plain nginx. The sibling script
nginx/purge_litespeed.lua speaks LSCache instead
of Surrogate-Key: it honors X-LiteSpeed-Cache-Control (public/max-age decides
what nginx stores and for how long), records X-LiteSpeed-Tag tags per cache
key, and executes X-LiteSpeed-Purge headers riding any response through the
proxy (tag=…, url=…, * — LiteSpeed has no PURGE verb). That makes plain
nginx a public-page-cache backend for anything written for LiteSpeed — including
WordPress with the
LiteSpeed Cache plugin, and
this package's own /litespeed entrypoint, which the e2e suite runs against it
UNCHANGED (e2e/verify.sh nginx-ls; config in e2e/nginx/nginx-ls.conf). Scope
honesty vs a real LiteSpeed server: public cache only — no private/per-user
cache (private purges are ignored), no ESI (keep it off in LSCWP), no vary
beyond bypassing the _lscache_vary login cookie, no crawler.
Performance
See BENCHMARKS.md — five open-source cache stacks measured
(hits, uncached passthrough, TLS handshakes) with the bugs we found on the way,
and e2e/bench.sh to rerun everything locally. Short version: a cache hit is
~4.5× the throughput of the (deliberately tiny) demo app at a third of the CPU
per request; OLS, Varnish, Angie, and nginx sit at statistical parity on hits —
with the Lua tag transport active on the nginx family, so tag purging costs
nothing measurable — and OLS leads TLS full handshakes.
Observability
Quiet by default, except the two signals that can mean stale pages: purge
failures (console.error — your purge endpoint is down, TTL is now the only
backstop) and unobserved writes (console.warn — a write the facade
couldn't attribute to a table, so tagged pages won't be purged by it).
For everything else, supply onEvent (it then receives ALL events and the
default logging is disabled):
onEvent: (e) => {
// 'unobserved-read' a read was opaque → over-purging (safe); deduped by reason
// 'unobserved-write' a write was opaque → possible staleness (fix these)
// 'purge-batch' what was purged, when — debugging gold
// 'purge-error' purger threw
// 'header-overflow' row tags collapsed to table tags (safe)
logger.info(e);
},Debugging staleness locally: set debug: true to expose the tags as
X-Cache-Tags on every response (including uncacheable ones) — on cacheable
responses it mirrors the wire header exactly, dpc-all and all — and log
purge-batch; together they answer "why did(n't) this page refresh." Never
enable debug in production; it leaks schema names.
Namespacing (tagPrefix)
Running several apps or drizzle instances behind one shared cache/CDN? Without
namespacing, both apps tagging posts would purge each other's pages. A prefix
is applied to every tag — derived, manual, and the dpc-unknown bucket — and
to
every purge, so reads and purges always agree:
createPageCache({ schema, purger, tagPrefix: "shop_" });
// → Surrogate-Key: shop_posts:7 shop_users shop_dpc-all
// → purges: shop_posts:7 shop_posts shop_dpc-unknownPrefer a non-: separator (like shop_) so tag→table mapping in purgers keeps
working. Note: shared-table multi-tenancy usually needs no prefix — row tags
are already globally unique, and table-tag purges crossing tenants only
over-purge, which is the safe direction. Per-request (dynamic) prefixes are a
possible future addition if per-tenant table tags ever matter.
Development
Deno-first repo; the npm package is generated from it by dnt.
deno task test # unit test matrix (tests/)
deno task check # typecheck all entrypoints
deno task build:npm # build the npm package into ./npm
cd e2e && ./verify.sh all # full write → tag-purge sweep against real proxies
cd e2e && ./bench.sh # rerun the BENCHMARKS.md measurementsThe e2e suite needs Docker; it brings the proxy stack up and down itself
(e2e/docker-compose.yml). Issues and PRs welcome — a failing test or a
verify.sh transcript is the fastest way to get a bug fixed.
Releasing
Bump version in deno.json, merge to main, then tag and push:
git tag vX.Y.Z && git push origin vX.Y.ZCI (.github/workflows/publish.yml) verifies the tag is on main and matches
the deno.json version, runs the checks and tests, and publishes to npm
with provenance via OIDC
(trusted publishing — no tokens
stored anywhere). JSR publishing is currently disabled in the workflow
(commented out). The npm package is built by scripts/build_npm.ts (dnt),
which reads the version from deno.json; to build or publish it by hand:
deno task build:npm && cd npm && npm publish.
License
MIT © Hotsauce Team
