-
Notifications
You must be signed in to change notification settings - Fork 2
NORM Caching
An opt-in read-through cache over
@tundralibs/cacher. It is off by default: a
Norm caches nothing unless you pass a cache config, and even then
only entities that declare a per-entity cache TTL participate. When
it is on, non-transactional find, findOne, count, and getByPK
reads are served from the cache, keyed by the query, and any write to
a table prunes that table's cache.
- Enabling caching
- What gets cached
- Invalidation
- Bypassing the cache per call
- Manual clearing
- Views and queries
- Temporal and audit tables
- Encryption
- Cache engines
- Backend failures
- Events
- Rules and limits
- Related documentation
Two things must both be present: a cache config on the Norm, and a
cache TTL (in minutes) on each entity you want cached.
import { Column, Entity, Norm, Schema } from '@tundralibs/norm';
const App = Schema('App', {
Users: Entity('users', {
id: Column.integer(),
name: Column.varchar(40),
}, {
pk: ['id'],
cache: 5, // cache reads for 5 minutes (windowed — see below)
}),
});
const norm = new Norm({
name: 'app',
database: { dialect: 'sqlite', path: './data' },
cache: { engine: 'MEMORY' },
});
const db = norm.use(App);The TTL is windowed (fixed on WORKERS_KV, see
Workers KV): each cache hit resets the clock, so a hot query
stays cached as long as it keeps being read. cache: 0 (or omitting
it) turns caching off for that entity.
The Norm's name roots the cache namespace and is the isolation
boundary: two Norms pointed at the same cache engine must use
different names, or they would share (and cross-prune) each other's
entries. It defaults to norm-<n>, a per-process counter. That is fine
for MEMORY, whose store is private to the process, but every other
engine (REDIS, MEMCACHED, WORKERS_KV) requires an explicit name,
since every process would otherwise call itself norm-1; the
constructor throws INVALID_CACHE_CONFIG without one.
Cached: find, findOne, count, and getByPK, outside a
transaction, when the entity declares a cache TTL.
Not cached:
-
Reads that join another table. A joined result depends on more
than one table, which would break per-table invalidation. These emit
a
cache-skipwarningso the miss is diagnosable. Model a cached multi-table read as a VIEW instead. A single-table aggregate (GROUP BYon one table) is cached normally. - Reads inside a transaction. A transaction sees uncommitted data; serving it from, or writing it into, the shared cache would leak that view to other connections. In-transaction reads always hit the database.
-
raw()andquery(). The escape hatches bypass the typed pipeline entirely, caching included.
Any write to a table (insert, update, delete, upsert,
truncate) prunes that table's cache: the whole namespace, since the
cache is keyed by query, not by row.
await db.repo('Users').find(); // miss → database, then cached
await db.repo('Users').find(); // hit
await db.repo('Users').insert({ id: 2, name: 'Bo' }); // prunes the cache
await db.repo('Users').find(); // miss again → databaseInside a transaction the prune is deferred to commit. A rollback prunes nothing, since nothing changed, and the pruned entries never carry a transaction's uncommitted view.
Prune-on-write is not atomic with the database write, so a concurrent
reader can repopulate an entry in the window between the write landing
and the prune firing. That staleness is bounded to one read window.
External writes and raw() do not invalidate at all.
Pass noCache: true to skip the cache for a single read. It neither
reads a cached value nor populates one; the query still runs against
the database:
await db.repo('Users').find(undefined, { noCache: true });
await db.repo('Users').getByPK({ id: 1 }, { noCache: true });
await db.repo('Users').count(undefined, { noCache: true });await db.repo('Users').clearCache(); // drop one entity (and dependent views)
await db.clearCache(); // drop every entity's cache for this connectionrepo.clearCache() mirrors what a write to that model does: it drops
the model's own cache plus any VIEW or QUERY that reads from it.
db.clearCache() drops everything. Both are no-ops when no cache was
configured.
A schema migration never calls either of these.
Migrator.apply()androllback()run DDL only and do not touch the read cache. If theNorminstance you migrate against also hascacheconfigured, calldb.clearCache()afterward so rows cached under the old shape do not linger on an external engine (Redis/Memcached) that outlives the process.
VIEW and QUERY entities are cacheable too. Because they derive from base tables, norm resolves each one's stored query source tables at compose time, recursively through composed views, and prunes the view's cache whenever any of those tables is written.
const App = Schema('App', {
Orders: Entity('orders', {/* ... */}, { pk: ['id'] }),
RecentOrders: Entity('recent_orders', {/* ... */}, {
type: 'VIEW',
cache: 2,
query: { type: 'SELECT', table: 'orders' /* ... */ },
}),
});
// A write to Orders prunes the RecentOrders cache automatically.This is the sanctioned way to cache a multi-table read: model it as a VIEW and invalidation follows the dependencies precisely.
cache combines with temporal on the same
TABLE: insert, the only write verb a temporal table allows,
invalidates the cache like any other write. The one caveat is @AsOf:
a filter like find({ '@AsOf': new Date() }) bakes that exact
millisecond into the cache key, so consecutive calls almost never hit.
Filter on '@EffectiveTo': sentinel instead for a cacheable "current"
read (see Temporal → Common issues).
cache also combines with audit on the source
table; the mirror write into the replica does not change how the
source's own cache is invalidated. The generated replica itself can
never be cached, though: audit has no cache option, so
db.repo('<name>') always reads the database. Do not route around
this with a VIEW over the replica's physical table. A mirrored write
invalidates the source's cache namespace only, never the replica's, so
that VIEW's cache would never get pruned and would serve stale rows
past its TTL (see Audit → Common issues).
Caching stores the decrypted rows a read returns. On an external cache
(Redis / Memcached) that would put the plaintext of .encrypt()
columns at rest, defeating the point of encrypting them. So an entity
with encrypted columns may only be cached on the in-process MEMORY
engine; combining encrypted columns, cache > 0, and a non-MEMORY
engine throws a NormError (INVALID_CACHE_CONFIG) at use() time.
Any engine registered on the @tundralibs/cacher singleton works. norm
goes through cacher's unified API; the one engine-specific rule is the
fixed TTL on WORKERS_KV:
// In-process, no dependencies (the only engine that may cache encrypted
// entities):
cache: { engine: 'MEMORY' }
// Redis / Memcached — options are forwarded verbatim to cacher:
cache: {
engine: 'REDIS',
options: { host: '10.0.0.1', port: 6379, username: '', password: '…', db: 0 },
}
// Cloudflare Workers KV — the namespace binding from the Worker's env:
cache: { engine: 'WORKERS_KV', options: { binding: env.CACHE } }Each entity gets its own cache namespace (<name>__Entity, rooted at
the Norm's name), and every engine's namespace clear is scoped:
Redis deletes name:*, Memcached bumps a per-namespace version counter
(never a server-wide flush_all), and Memory clears its own map.
Pruning one table never disturbs another table's cache or another app
sharing the same server. Workers KV writes a new namespace version, like
Memcached.
WORKERS_KV suits read-mostly entities in a Worker. Its limits change
three things:
| Behaviour | On Workers KV |
|---|---|
| TTL | Fixed, not windowed: KV can extend a TTL only by rewriting the value. The entity's cache minutes still apply |
| Invalidation | A write's prune reaches other data centres within about 60 seconds, so reads elsewhere can be stale for that long |
| Write rate | Each prune writes one key, and KV accepts one write per second per key. On a table written faster than that, prunes fail with a cache-error warning and entries stay stale until their TTL |
Cache an entity on Workers KV only if it is written less than once a
second and a minute of cross-region staleness is acceptable. For
anything else, leave cache off on that entity. See
Cacher-WorkersKV
for the engine itself.
A cache backend is best-effort. If the cache engine is unreachable
mid-request, the query degrades to the database rather than failing: a
failed read is a miss (the row is fetched from the source), a failed
write or prune is skipped, and each surfaces a cache-error warning.
A Redis blip slows requests down; it never takes them down.
Wire these on the event bus:
-
cacheHit(entity, op, id): a read was served from the cache; nocallevent fires for it.idmatches the returnedNormResultenvelope. -
warning(entity, op, 'cache-skip', message): a joined read could not be cached. -
warning(entity, op, 'cache-error', message): a cache backend failed and the query fell back to the database.
- Both the
Normcacheconfig and a per-entitycacheTTL are required; either alone caches nothing. - A non-
MEMORYcache engine requires an explicitNormname, and anameused for caching must not contain:(cacher's reserved separator) or__(norm's entity separator). - Per-entity
cacheis a non-negative integer number of minutes, capped at 30 days (the cacher expiry ceiling); an invalid value throwsINVALID_CACHE_CONFIGatuse()time. - The cache key is derived from the compiled query, so two logically equal filters written with a different key order are a benign cache miss rather than a correctness problem.
-
Temporal tables:
cachecombined withtemporal, and the@AsOfcache-key caveat. -
Audit tables:
cacheon an audited source table; why the generated replica can never be cached. -
Migrations: the
Migratornever touches the read cache; calldb.clearCache()after a schema change. -
Schema definition: columns, entities, and the
cacheoption in context.