zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

CachingSupported

There is no CacheModule and no implicit query cache — a hidden cache is state you did not ask for, and the wrong answer served fast is worse than the right answer served slowly. Caching here is explicit, and the layer you choose matters more than the code.

Where to cache#

LayerKeyed onGood for
CDN / reverse proxyURL + varypublic, unauthenticated responses
A store you injectwhatever you decideper-tenant or per-user results
In-process Mapsamesmall, hot, tolerant of staleness

The first row is where most caching belongs, and it needs no application code. A CDN caches once and serves everyone; an application cache repeats the work per instance.

An interceptor#

Interceptor.intercept(ctx, next) returns Promise<unknown> — it wraps the handler's return value, not a WebResponse:

import type { Interceptor } from '@zmdb/web/middleware';

export function cached(store: KV, ttlMs = 5_000): Interceptor {
  return {
    async intercept(ctx, next) {
      if (ctx.method !== 'GET') return next();

      const key = cacheKey(ctx);
      const hit = await store.get(key);
      if (hit !== undefined) return JSON.parse(hit);

      const result = await next();
      await store.set(key, JSON.stringify(result), ttlMs);
      return result;
    },
  };
}
⚠️ Warning

The router does not call runChain. Registering a controller applies no interceptors — you invoke the chain inside the handler. See Request Lifecycle.

@Get('/')
list(ctx: Ctx<Record<never, string>, unknown>) {
  return runChain({ guards: [], pipes: [], interceptors: [cached(store)], filters: [] }, ctx, () =>
    this.repo.list({ page: { limit: 20 } }),
  );
}

For an x-cache: HIT marker, return the response explicitly instead of a plain value:

return json(hit, { headers: { 'x-cache': 'HIT' } });

The cache key is the whole risk#

function cacheKey(ctx: Ctx<Record<string, string>, unknown>): string {
  const viewer = viewerFrom(ctx.headers); // authenticated identity
  return `${ctx.method}:${ctx.path}:${viewer.tenant}:${viewer.id}`;
}
⚠️ Warning

A key that omits the authenticated identity serves one user's data to another. This is the most common caching vulnerability and it is invisible in testing, because a single-user test always hits its own entry. If a response depends on who asked, the asker is part of the key.

Two related traps:

adapter and pass it through, or key on ctx.path only. See Typed Request Context.

Cache rows, not responses#

Usually better. A row cache has a natural key and a natural invalidation point:

export function cachingDriver(inner: Driver, store: KV, ttlMs: number): Driver {
  return {
    ...inner,
    async execute(query, options) {
      if (!/^\s*SELECT/i.test(query.text)) return inner.execute(query, options);
      const key = `q:${hash(query.text)}:${hash(JSON.stringify(query.parameters))}`;
      const hit = await store.get(key);
      if (hit !== undefined) return JSON.parse(hit);
      const rows = await inner.execute(query, options);
      await store.set(key, JSON.stringify(rows), ttlMs);
      return rows;
    },
  };
}

Because it is a Driver, it composes with the other wrappers and covers every surface — handlers, workers, a CLI backfill. Build it per request with the tenant baked into the driver and the tenant is in the key by construction rather than by remembering.

Hash the parameters into the key rather than storing them; do not log either.

Invalidation#

Pick one, deliberately:

Events.

An in-process Map with no eviction is a memory leak. Cap it, or use a store that expires.

Do not cache#

A write response, anything personalised without the identity in the key, or a result you cannot afford to be stale. When in doubt, narrow the query with select instead — a fast query needs no cache. See Query Performance.

---

See also: Interceptors · Query Performance · Request Context