| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 2 天前 | ||
| 2 天前 | ||
| 2 天前 | ||
| 2 个月前 | ||
| 2 个月前 | ||
| 1 天前 | ||
| 2 个月前 | ||
| 2 个月前 | ||
| 2 个月前 | ||
| 2 个月前 |
@internal/middleware-cache
A read-through cache middleware for Prisma 8 runtimes, for both the SQL and Mongo families.
It caches two kinds of annotated read:
- An ORM call, such as
db.orm.public.User.first(...), cached as a whole through theintercept.onActivityhandler. Every ORM terminal method is oneorm-callactivity that carries the user's annotations. On a hit, the middleware supplies the call's result and the ORM sends no statement. On a miss,activityEndedstores the value the call returned. - A statement, such as an annotated SQL builder query, through the
intercept.onQueryhandler. On a hit, the middleware returns the cached rows and the driver is never invoked. On a miss,onRowbuffers the rows from the driver in the statement'sctx.state, andactivityEndedstores them when the read succeeded.
A write annotated with invalidateAnnotation removes entries once its effects are final (see Invalidating with a write).
The package depends on no SQL or Mongo package: its runtime dependencies are @internal/framework-components and @internal/utils. The default key of a statement is ActivityContext.contentHash(exec), which the family runtime populates, and the default key of an ORM call is the call's activity.contentHash(), so SQL and Mongo runtimes both work out of the box.
Responsibilities
- Provide a caching
RuntimeMiddlewarethat serves repeated ORM calls through theintercept.onActivityhandler and repeated statements through theintercept.onQueryhandler. - Define the read-only
cacheAnnotationhandle that lane terminals (SQL DSL.annotate(...), ORM read terminals) use to opt a read in, withkey,metaandbypass. - Resolve the cache key: the annotation's
key; otherwise, for a statement, thederiveKeyoption, which defaults toActivityContext.contentHash(exec), and for an ORM call,activity.contentHash(). - Store a statement's rows only when the driver completed it (
outcome: 'succeeded',source: 'driver'), and an ORM call's result only when the call succeeded and returned a value (nullcounts). - Tag stored values, so a statement's rows and an ORM call's result never stand in for each other under the same key.
- Bypass the cache inside a transaction and on a held connection.
- Remove entries on request through
invalidate({ keys, meta }), and stop a read that overlapped it from storing its rows. - Define the write-only
invalidateAnnotationhandle, and make the sameinvalidatecall fromactivityFinalizedonce an annotated write's effects are final, unless they were rolled back. - Define the
CacheStoreinterface, and ship a default in-memory store with a size bound and one lifetime for every entry.
Quick start
import postgres from '@internal/postgres/runtime';
import { contractFromJson } from '@internal/postgres/static';
import {
cacheAnnotation,
createCacheMiddleware,
invalidateAnnotation,
} from '@internal/middleware-cache';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
const cache = createCacheMiddleware(); // default store: in memory, 1000 entries, 60 s
const db = postgres({
contract: contractFromJson<Contract>(contractJson),
url: process.env['DATABASE_URL']!,
middleware: [cache],
});
// First call: hits the database and stores the call's result under 'user-1'.
const first = await db.orm.public.User.first({ id: 1 }, (meta) =>
meta.annotate(cacheAnnotation({ key: 'user-1' })),
);
// Second call: served from the cache; the ORM sends no statement.
const second = await db.orm.public.User.first({ id: 1 }, (meta) =>
meta.annotate(cacheAnnotation({ key: 'user-1' })),
);
// The write removes the entry once it has committed, so the next read sees it.
await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' }, (meta) =>
meta.annotate(invalidateAnnotation({ keys: ['user-1'] })),
);
// Un-annotated reads are never cached.
const fresh = await db.orm.public.User.first({ id: 1 });
Opt-in by annotation
The middleware caches a read that carries cacheAnnotation, runs in runtime scope outside any transaction, and does not set bypass. For an ORM read the annotation is on the call; the statements the call sends carry none, so the call is cached as a whole and its statements are not cached separately. How long the entry lives is the store's policy; the annotation has no lifetime option.
| Annotation state | Behavior |
|---|---|
No cacheAnnotation on the call or plan |
Pass through; never cached. |
cacheAnnotation({}) |
Cache lookup under the derived key; store on miss and success. |
cacheAnnotation({ key }) |
As above, under the supplied key, used verbatim. |
cacheAnnotation({ meta }) |
As above; meta is passed to store.get, and to store.set on the entry get returned (see meta). |
cacheAnnotation({ bypass: true }) |
Pass through: neither read from nor write to the cache. |
The annotation is read-only: it declares applicableTo: ['read'], so a write terminal rejects it both at compile time and at run time. The middleware itself has no way to cache a write.
// ✓ ORM read terminal accepts the read-only annotation via the meta callback.
await db.orm.public.User.first({ id }, (meta) => meta.annotate(cacheAnnotation({})));
// ✓ Pass `undefined` as the filter to attach an annotation without narrowing further.
await db.orm.public.User.first(undefined, (meta) => meta.annotate(cacheAnnotation({})));
// ✗ Type error: a write terminal rejects the read-only annotation.
await db.orm.public.User.create(input, (meta) => meta.annotate(cacheAnnotation({})));
// ✓ SQL DSL: chainable on select / grouped builders.
const plan = db.sql
.from(tables.user)
.select({ id: tables.user.columns.id })
.annotate(cacheAnnotation({}))
.build();
Cache key composition
Three-tier resolution:
- Per-query override.
cacheAnnotation({ key })— the supplied string is used verbatim, andderiveKeyis not called. The cache middleware does not rehash user-supplied keys; the caller is responsible for keeping the string bounded in size and free of sensitive data they do not want flowing into debug logs, RedisKEYSoutput, persistence dumps, or any user-suppliedCacheStore. User-supplied keys also bypass the storage-hash discrimination below — if you fix a key, prefix it with something tied to your schema version (e.g.`${storageHash}:my-key`) to avoid serving stale-schema entries after a migration. A key belongs to one query and one terminal method: iffirst()andall()share a key, the one that reads the other's stored result fails withORM.INTERCEPTED_RESULT_INVALIDor, for a method that accepts the same shape, returns the wrong value. A builder query and an ORM call that share a key evict each other's entries: each misses on the other's value and stores its own in its place. Give each its own key. deriveKey, for statements.createCacheMiddleware({ deriveKey })computes the key of every cached statement whose annotation has nokey(see Deriving keys). It takes the statement's execution plan, so it does not apply to an ORM call. An app that relies onderiveKeyto separate tenants must also separate them for annotated ORM calls, for example with a tenant-prefixedkey; otherwise one tenant's cached ORM read can be served to another.- Default. For an ORM call,
await activity.contentHash(): a digest oform-call, the terminal method and the content hash of the statement the call sends first (for a prepared collection, of the statement with that execution's parameters). It is computed only when the cache asks for it. For a statement,deriveKeyFromContentHash(exec, ctx), which returnsActivityContext.contentHash(exec)— the family runtime owns this. The SQL and Mongo runtimes today composemeta.storageHash + '|' + …and pipe the result throughhashContent(SHA-512), producing a bounded, opaque digest of the formsha512:HEXDIGEST. The cache middleware uses the returned string directly as the store key.
Two consequences worth pinning (both properties of the default key path — user-supplied keys above opt out of both, and a deriveKey keeps them only if it builds on the content hash):
- Storage-hash discrimination. A schema migration changes
meta.storageHash, which changescontentHash, so entries cached under the old schema are no longer found. Stale-schema reads cannot leak across migrations. - AST rewrites are part of the key. Middleware that rewrite the plan via
beforeCompile(e.g. soft-delete) run upstream of the cache. The cache sees the post-lowering plan, so the rewritten SQL is part of the content hash. Adding or removing abeforeCompilemiddleware changes which entries hit. An ORM call's content hash also runsbeforeCompile, with the scope of the queryable the call starts on and fresh state, so abeforeCompilethat readsctx.parentor state can give the call a different digest than the statement it sends.
Deriving keys
deriveKey(exec, ctx) returns the key (or a promise of it) for a read that is being cached and has no annotation key. It runs on every such read, hit or miss, and never on a read the cache bypasses. Compose it with the exported default to add a prefix:
import { createCacheMiddleware, deriveKeyFromContentHash } from '@internal/middleware-cache';
const cache = createCacheMiddleware({
deriveKey: async (exec, ctx) => `users:${await deriveKeyFromContentHash(exec, ctx)}`,
});
- An explicit annotation
keybypassesderiveKey. It is used literally, so a tenant or namespace prefix thatderiveKeyadds must also be part of every explicit key. - Keys must differ whenever the rows can differ. A derivation that returns the same key for two different plans serves one plan's rows to the other.
- Keep the content hash. A derivation that drops it loses the storage-hash discrimination above, so a migration no longer invalidates cached entries.
- Errors fail the read. If
deriveKeythrows or rejects, the read fails; the cache does not fall back to the database. Its latency is added to every cached read, including hits.
CacheStore
The store is a cache of values. This middleware stores CachedValue: the raw rows of one statement, or the result of one ORM call.
interface CacheEntry<TMeta = unknown, TValue = CachedValue> {
readonly key: string;
readonly meta: TMeta | undefined;
readonly version: number;
readonly data: { readonly empty: true } | { readonly empty: false; readonly value: TValue };
}
interface CacheStore<TMeta = unknown, TValue = CachedValue> {
get(target: {
readonly key: string;
readonly meta?: TMeta | undefined;
}): Promise<CacheEntry<TMeta, TValue>>;
set(entry: CacheEntry<TMeta, TValue>, value: TValue): Promise<boolean>;
unset(target: {
readonly keys?: readonly string[] | undefined;
readonly meta?: TMeta | undefined;
}): Promise<void>;
}
type CachedRows = readonly Record<string, unknown>[];
type CachedValue =
| { readonly kind: 'statement-rows'; readonly rows: CachedRows }
| { readonly kind: 'call-result'; readonly result: unknown };
The middleware tags each value it stores with what produced it, statement-rows or call-result, and serves it only to the same kind of read: under one key, an ORM call's result is a miss for a statement and a statement's rows are a miss for an ORM call. A store treats the value as opaque.
A store must give back a value equivalent to the one it was given, kind included. A statement's rows hold driver values. An ORM call's result holds the values the ORM returns, after codecs decoded them: Date, bigint, Temporal values, decimals, and any class an extension codec returns. A store that serializes values therefore needs a serializer that rebuilds the application's types. JSON is not enough: JSON.stringify throws on a bigint, and a Date comes back as a string that the ORM then returns typed as Date.
A deploy that changes what a codec returns needs a new key prefix for a shared store. The key covers the schema, the statement and its parameters, but not how values are decoded, so without a new prefix the new code is served values the old codecs produced until they expire.
The store keeps a version per key: an integer it keeps even for a key that holds no value. A key never seen has version 0. Only unset changes a version.
get({ key, meta })always returns aCacheEntry: the key, themetathe read supplied (the read annotation'smeta, orundefined), the key's current version, anddata.data.emptyistrueon a miss; otherwisedata.valueis the stored value. A store that matchesmetafolds the versions of whatevermetanames into the version it returns, and does the same whensetcompares, so aninvalidate({ meta })refuses an overlapping read'sseteven for a key the store has never seen.set(entry, value)storesvalueunderentry.keywithentry.metaif and only if the key's current version still equalsentry.version, and returns whether it stored.entryis the onegetreturned, empty or not; refreshing a value passes the non-empty entry.setnever changes a version. There is no unconditional write: to prefill the cache, callgetand thenset.unsetremoves every value named inkeysand every value that matchesmeta, in one call so the store can batch them, and increments the version of every key it removes or would remove, including keys that hold no value.keysis eitherundefinedor non-empty.- A field the caller leaves out of
getorunsetmeans the same asundefined. A store must not tell the two apart, for example with'meta' in target.
The rules a store must follow:
- The store interprets
meta; the middleware never does. A store that does not indexmetamust throw whenunsetreceives ametait cannot act on, soinvalidaterejects rather than doing nothing. An invalidation that a write carries ininvalidateAnnotationis different: the error is only logged, and the write succeeds (see Invalidating with a write). - An
unsetby key also drops that key from anymetaindex the store keeps. - Lifetime and eviction are the store's policy. A store that wants per-entry lifetimes reads them from
meta. setmust be atomic againstunset. Comparing the version and writing the value must be one step that nounsetof the same key can fall between: one synchronous step in memory, or one script (for example a Lua script) on a server. The middleware's guard against overlapping reads relies on this.- Keep a moved version at least as long as a read can take. A version that falls back to 0 too early lets a read that started before the
unsetstore its rows. unsetmust not run a query through the runtime that uses the middleware.- An error from
getorsetfails the read. The middleware does not catch store errors, for statements and ORM calls alike. A read whosesetthrows fails even though the database answered it, and since nothing was stored, the next read fails the same way for as long as the store keeps throwing: for example, a store that serializes with plain JSON and is given abigint. - Compare
metaby value. Themetapassed tounsetis a different object from the one passed togetandset. A store shared between processes must serialisemetaitself.
A store used with this middleware holds cached values: createCacheMiddleware takes a CacheStore<TMeta, CachedValue>, and a store of any other value type, unknown included, is a type error. TValue defaults to CachedValue, so CacheStore<TMeta> is a store of cached values; it exists so the same store type can serve other caches.
The default store lives in one process and is not shared across replicas. For shared caching, supply a custom store. The guard for overlapping reads then works across every process that uses the store, because the store owns the versions:
import type { CachedValue, CacheStore } from '@internal/middleware-cache';
// Supplied by the application: it must rebuild every type its codecs return, such as Date,
// bigint, Temporal values and extension classes. JSON.stringify and JSON.parse do not.
interface ValueSerializer {
serialize(value: CachedValue): string;
deserialize(text: string): CachedValue;
}
const SET_IF_VERSION = `
if tonumber(redis.call('GET', KEYS[2]) or '0') ~= tonumber(ARGV[2]) then return 0 end
redis.call('SET', KEYS[1], ARGV[1], 'PX', 60000)
return 1`;
function redisStore(serializer: ValueSerializer): CacheStore<unknown, CachedValue> {
return {
async get({ key, meta }) {
const [text, version] = await redisClient.mget(`value:${key}`, `version:${key}`);
return {
key,
meta,
version: Number(version ?? 0),
data: text ? { empty: false, value: serializer.deserialize(text) } : { empty: true },
};
},
async set(entry, value) {
const keys = [`value:${entry.key}`, `version:${entry.key}`];
const text = serializer.serialize(value);
return (await redisClient.eval(SET_IF_VERSION, 2, ...keys, text, entry.version)) === 1;
},
async unset({ keys, meta }) {
if (meta !== undefined) {
throw new Error('This store does not index meta');
}
for (const key of keys ?? []) {
await redisClient
.multi()
.del(`value:${key}`)
.incr(`version:${key}`)
.pexpire(`version:${key}`, 60_000)
.exec();
}
},
};
}
const redis = redisStore(appSerializer);
const cache = createCacheMiddleware({ store: redis });
The default store
createInMemoryCacheStore<TMeta>({ maxEntries?, ttlMs?, clock? }) is what createCacheMiddleware() uses when no store is given. It holds cached values (CachedValue).
- Size. At most
maxEntriesvalues, a positive integer (default 1000). Reads and writes both count as a use; the least recently used value is evicted first. - Lifetime. Every value lives
ttlMsafter itsset, a positive number of milliseconds (default 60 000).ttlMs: Infinitynever expires. A key's version is kept forttlMsafter theunsetthat moved it. Versions are not bounded bymaxEntries. A forgotten version reads as 0, so with a finitettlMsa read that takes longer thanttlMsand overlaps aninvalidatecan store stale rows. WithttlMs: Infinity, the versions of invalidated keys are never forgotten and grow with the number of distinct keys invalidated. Any othermaxEntriesorttlMs, such as0orNaN, throwsRUNTIME.ARGUMENT_INVALID. Expiry is measured withclock(defaultDate.now); an expired value reads as empty and is dropped. - Copies. It keeps statement rows and ORM call results differently. Statement rows are copied one level deep on
setandget: the array, and each row object with its prototype. The driver's values in them, such as BSONObjectIds or aBuffer, are kept by reference and shared with every hit. The runtime decodes them on each hit; a codec that returns its wire value unchanged, such as a date or byte-array codec, hands every caller that same shared object. An ORM call's result has its plain structure copied onsetand on everyget: plain and null-prototype objects, arrays (their elements only; holes read asundefined),Map,Set,Date,RegExp,ArrayBuffer, typed arrays (a subclass such as Node'sBufferkeeps its class) andDataView(copied as a plainDataView). Every other value in it, such as a class instance (a Temporal value, a decimal, a vector, a BSON value, an error) or a function, is shared by reference with the first caller and every hit. So, once the call has ended, changing the plain objects and arrays of a result changes no later hit; changing a shared value in place does, so codecs should return values that are not mutated in place. The store never refuses a value:setreturnsfalseonly when the key's version moved. A miss copies the result once and each hit copies it again: about the work of decoding rows, linear in the size of the result. meta.getandsetignore it.unset({ keys })removes those keys;unsetwith anymetaother thanundefined, includingnull, throwsRUNTIME.CACHE_STORE_META_UNSUPPORTEDand removes nothing.
To change the defaults, create the store yourself:
const cache = createCacheMiddleware({
store: createInMemoryCacheStore({ maxEntries: 10_000, ttlMs: 5 * 60_000 }),
});
meta
cacheAnnotation({ meta }) hands any value to the store with the entry. The middleware never reads it. Stores use it to group entries, for example by tag, or to give an entry its own lifetime.
- Passed by reference. The store's
getreceives the object you passed when the read starts, and itssetreceives it again on the entry, after the read's rows have all arrived. Do not mutate it in between. - Visible to other middleware.
metasits in the annotations of the ORM call or the plan, so any middleware or telemetry that serialises annotations sees it. Keep secrets out.
A tag-based policy lives in the store. set indexes meta.tags; unset({ meta: { tags } }) removes every entry with one of those tags:
await db.orm.public.User.first({ id: 1 }, (meta) =>
meta.annotate(cacheAnnotation({ meta: { tags: ['users'] } })),
);
await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' });
await cache.invalidate({ meta: { tags: ['users'] } });
Typed meta
CacheStore<TMeta>, createCacheMiddleware and cacheAnnotation<TMeta> take the shape of meta as a type parameter; it defaults to unknown. createCacheMiddleware infers it from the store, so invalidate accepts only that shape:
interface TagMeta {
tags: string[];
}
class TagStore implements CacheStore<TagMeta> {
// get({ key, meta }), set(entry, value) and unset({ keys, meta }), with meta: TagMeta | undefined
}
const cache = createCacheMiddleware({ store: new TagStore() }); // CacheMiddleware<TagMeta>
await cache.invalidate({ meta: { tags: ['users'] } }); // compiles
await cache.invalidate({ meta: { tag: 'users' } }); // type error
Nothing ties the annotation's TMeta to the store's at compile time, because the annotation is written where the query is and the store where the middleware is set up. A store package should therefore export a wrapper typed with its own meta, so the two agree:
export const cached = (o: CacheAnnotationOptions<TagMeta>) => cacheAnnotation<TagMeta>(o);
Invalidation
invalidate({ keys?, meta? }) removes entries through one store.unset({ keys, meta }) call.
keysare compared literally with the key each entry was stored under: the annotation'skey, or whatderiveKeyreturned.metais passed to the store as is. The default store rejects it (see The default store).- With empty or absent
keysand nometa,invalidatedoes nothing.
Rules:
- Call it after the write has committed.
invalidateworks from any scope, but if you call it inside a transaction, another request can read the old rows before the commit and put them back in the cache. Invalidate once the transaction has returned, or annotate the write withinvalidateAnnotation, which waits for the commit. - Overlapping reads. A read that missed the cache before an
invalidateand finishes after it does not store its rows, because they may predate the write. For example: the read'sstore.getreturns an empty entry with version 3; theinvalidatemakesunsetmove the key to version 4; when the read completes,store.set(entry, rows)compares 3 with 4 and returnsfalse. Only reads for keys theunsetremoves or would remove are affected. Each skipped store is logged at debug level asmiddleware.cache.store-skippedwith the key. - Across processes. The guard works across processes that share a store, because the store owns the version. Calling the store's
unsetdirectly moves the versions too. - Until
invalidateresolves, reads can still return entries it has not removed yet. - Store errors propagate. If
unsetrejects,invalidaterejects with that error. Whether overlapping reads still store depends on how far the store got.
Invalidating with a write
A write can carry its invalidation. invalidateAnnotation({ keys?, meta? }) takes the same InvalidationTarget<TMeta> as invalidate, and the middleware makes the same invalidate call once the write's effects are final:
await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' }, (meta) =>
meta.annotate(invalidateAnnotation({ keys: ['user-1'] })),
);
await db.transaction(async (tx) => {
await tx.orm.public.User.where({ id: 2 }).delete((meta) =>
meta.annotate(invalidateAnnotation({ meta: { tags: ['users'] } })), // needs a store that indexes meta
);
}); // invalidates here, once the transaction has committed
await db.execute(
db.sql.public.users
.update({ name: 'Alicia' })
.where((f, fns) => fns.eq(f.id, 1))
.annotate(invalidateAnnotation({ keys: ['user-1'] }))
.build(),
);
-
Writes only. The annotation declares
applicableTo: ['write']. Every SQL ORM write terminal and every SQL builder insert, update and delete accepts it; a read rejects it at compile time and at run time. -
When. Outside a transaction, right after the write ends, including after the write's own transaction when the ORM call runs one, such as an update with a relation callback or a create on a multi-table-inheritance variant. Inside
db.transaction(fn), when the transaction ends. The middleware acts in the runtime'sactivityFinalizedevent. Reads inside the transaction, or in other requests, are still served from the cache until then. A write whose activity never finalizes does not invalidate: a builder write whose row iterator is dropped withoutreturn(), or a process that stops between the commit and the invalidation. Its entries stay until they expire. -
The write waits for the store. The write's promise, and
db.transaction(fn)'s, resolve only afterstore.unsethas settled, so its latency adds to theirs, once per annotated write. A store that never settles leaves a committed write unresolved, so a custom store must time out its ownunset. -
Outcome. A
committedwrite invalidates. Arolled-backwrite does not: the entry stays. Anunknownoutcome, such as a write that failed or aCOMMITthat errored, invalidates, since the write may have landed and an unneeded invalidation costs one miss. -
Each write on its own. Several annotated writes in one transaction each invalidate when it ends. With empty or absent
keysand nometa, nothing happens, as forinvalidate. -
What carries it. The middleware reads the annotation from whatever activity carries it: an ORM call, an annotated SQL builder statement, or any client activity. It does not check the kind or the transaction. An ORM write's annotation lives on the call, and the statements the call sends carry none, so a write invalidates once.
-
Store errors are only logged. An error from
unsetis thrown fromactivityFinalized; the runtime passes it toctx.log.errorwith the eventmiddleware.activityFinalized.errorand swallows it. The write has already happened, so the entry stays until it expires.postgres(),sqlite()andsupabase()drop these events unless the application passeslog, so pass it to see a failed invalidation:const db = postgres({ contract: contractFromJson<Contract>(contractJson), url, middleware: [cache], log: console }); -
metaneeds a store that indexes it. The default store refuses anymetabefore removing anything, so on it an annotation withmetaremoves nothing, not even itskeys, and every such write logsRUNTIME.CACHE_STORE_META_UNSUPPORTEDthroughmiddleware.activityFinalized.error. Nothing fails, so passlogto the client and check it. -
Not on Mongo ORM writes. The Mongo ORM's write methods take no annotations. On Mongo, call
invalidateafter the write. -
Cost. Because the middleware implements
activityFinalized, a runtime that uses it keeps every transaction's finished statements until the transaction ends, whether or not any write is annotated. Measured on Postgres, that is about 2.9 KB per statement, about 55 MB for a transaction of 20,000 statements, all released at the commit. Any middleware withactivityFinalizedcosts about the same. Split a long batch import into several transactions, or run it on a client without the cache middleware. The middleware also checks each activity for the annotation once it is finalized.
Transaction-scope guard
The middleware neither serves nor stores a read when ActivityContext.scope is not 'runtime' or ctx.inTransaction() is true. This holds for ORM calls and statements alike.
- Inside a transaction, such as an ORM call on
txindb.transaction(async (tx) => ...), the caller expects to read their own writes. Serving would hand the transaction rows from before its own writes, and storing would cache rows that a rollback then erases. - On a held connection, such as
runtime.connection()or a Supabase role session, the session can change what a read returns. Supabase sets the user's database role on the connection, and the key does not include the role, so serving would leak one user's rows to another.
Caveats
- The default store is not coherent across replicas. Use a custom
CacheStore(Redis, etc.) for cross-process coherence. - Concurrent misses both populate the store. Two concurrent first-time reads of the same key both run the driver and both store their rows; last writer wins. Request coalescing is out of this package's scope (see Scope).
- A write invalidates only what it names. The middleware does not work out which entries a write affects. Annotate the write with
invalidateAnnotation, callinvalidateafter it, rely on the store's lifetime to bound staleness, or passcacheAnnotation({ bypass: true })on a read that must be current. - The default store shares class instances in an ORM call's result. It copies the result's plain objects and arrays, but a class instance a codec returned, such as a Temporal value or a decimal, is the same object for the first caller and every hit. Do not mutate such values in place. See The default store. A custom store decides for itself whether to copy and what it can store; one that keeps references shares the first caller's object with every later hit.
maxEntriescounts entries, not bytes. A large result set takes one slot like a small one.
Scope
This package caches annotated reads in a store and passes data between the annotations and the store without interpreting it; the store decides how to cache. ADR 266 records this design and the alternatives it rejected. Its primitives are:
- keys: the annotation
key, and thederiveKeyoption with its defaultderiveKeyFromContentHash; metaon the annotation, handed to the store'sgetandset;invalidate({ keys, meta }), handed to the store'sunset, andinvalidateAnnotation, which makes the same call once a write's effects are final;bypasson the annotation;- the
CacheStoreinterface, and the default in-memory store.
How long an entry lives, and what meta means (tags, groups, per-entry lifetimes), are the store's business. The middleware decides when the invalidation a write carries runs: once the write's effects are final. Deciding what a write should invalidate, for example by table, request coalescing, and routing between several stores are policy, and belong in extensions built on these primitives. Such extensions invalidate through invalidate or the store's unset; either moves the versions that stop overlapping reads from storing rows that predate the write.
What the primitives do not cover yet:
- Serve-stale strategies such as stale-while-revalidate. Only the middleware decides hit or miss, so nothing outside it can serve an expired entry while it re-runs the query.
See also
- Runtime & Middleware Framework for the SPI and middleware lifecycle (including the
intercept.onActivityandintercept.onQueryhandlers the cache uses). - ADR 204 — Single-tier runtime for why the cache middleware is family-agnostic by construction.