@prisma/orm-extension-middleware-cache 8.0.0-rc.2-dev.2 → 8.0.0-rc.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @prisma/orm-extension-middleware-cache
2
2
 
3
- Opt-in query caching for Prisma Next runtimes, for both the SQL and Mongo families.
3
+ Opt-in query caching for Prisma 8 runtimes, for both the SQL and Mongo families.
4
4
 
5
5
  ```bash
6
6
  pnpm add @prisma/orm-extension-middleware-cache
@@ -9,15 +9,122 @@ pnpm add @prisma/orm-extension-middleware-cache
9
9
  The whole surface is the package root:
10
10
 
11
11
  ```ts
12
- import { cache, cacheAnnotation, type CacheStore } from '@prisma/orm-extension-middleware-cache';
12
+ import {
13
+ cacheAnnotation,
14
+ createCacheMiddleware,
15
+ createInMemoryCacheStore,
16
+ deriveKeyFromContentHash,
17
+ invalidateAnnotation,
18
+ type CacheStore,
19
+ } from '@prisma/orm-extension-middleware-cache';
13
20
  ```
14
21
 
15
22
  ## Responsibilities
16
23
 
17
- A runtime middleware that short-circuits repeated reads: on a hit it returns cached rows and never invokes the driver; on a miss it buffers the driver's rows and commits them to the store only when the execution completes successfully. Cache keys come from the family runtime's content hash of the execution, or from a per-query `cacheAnnotation({ key })` override; `ttl` and `skip` are per-query too. Connection- and transaction-scoped executions bypass the cache.
24
+ A read-through cache middleware. An annotated ORM call, such as `db.orm.public.User.first(...)`, is cached as a whole: on a hit the middleware supplies the call's result and the ORM sends no statement; on a miss it stores the value the call returned. An annotated statement, such as a SQL builder query, is cached as rows: on a hit it returns the cached rows and never invokes the driver; on a miss it buffers the driver's rows and stores them when the read completes. A read opts in with `cacheAnnotation({ key?, meta?, bypass? })`: `key` names the entry; otherwise an ORM call is keyed by its content hash, a digest of the terminal method and the statement it sends, and a statement by the `deriveKey` option (default: the family runtime's content hash, `deriveKeyFromContentHash`); `meta` is handed to the store with the entry; `bypass: true` skips the cache for that call. A read inside a transaction or on a held connection, such as a Supabase role session, is neither served nor stored. A write annotated with `invalidateAnnotation({ keys?, meta? })` removes those entries once its effects are final.
18
25
 
19
- It ships an in-memory LRU-with-TTL store and exposes the `CacheStore` interface so Redis, Memcached, or any other backend can be dropped in.
26
+ How long an entry lives is the store's policy. The default store, `createInMemoryCacheStore({ maxEntries?, ttlMs?, clock? })`, keeps up to 1000 values for 60 seconds each; `ttlMs: Infinity` never expires. A key's version is kept for `ttlMs` after the `unset` that moved it. Versions are not bounded by `maxEntries`. A forgotten version reads as 0, so with a finite `ttlMs` a read that takes longer than `ttlMs` and overlaps an `invalidate` can store stale rows. With `ttlMs: Infinity`, the versions of invalidated keys are never forgotten and grow with the number of distinct keys invalidated. A `maxEntries` that is not a positive integer or a `ttlMs` that is not positive throws `RUNTIME.ARGUMENT_INVALID`. Implement the `CacheStore` interface to use Redis, Memcached, or any other backend.
20
27
 
21
- ## Dependencies
28
+ ## The store
22
29
 
23
- `@prisma/orm-framework` at an exact lockstep version, and nothing else. It depends on no family runtime and no target, which is why one middleware serves every database.
30
+ The store is a cache of values; this middleware stores `CachedValue`, the raw rows of one statement or the result of one ORM call. The middleware tags each value `statement-rows` or `call-result` and serves it only to the same kind of read; under one key, a value with the other tag is a miss.
31
+
32
+ ```ts
33
+ interface CacheEntry<TMeta = unknown, TValue = CachedValue> {
34
+ readonly key: string;
35
+ readonly meta: TMeta | undefined;
36
+ readonly version: number;
37
+ readonly data: { readonly empty: true } | { readonly empty: false; readonly value: TValue };
38
+ }
39
+
40
+ interface CacheStore<TMeta = unknown, TValue = CachedValue> {
41
+ get(target: {
42
+ readonly key: string;
43
+ readonly meta?: TMeta | undefined;
44
+ }): Promise<CacheEntry<TMeta, TValue>>;
45
+ set(entry: CacheEntry<TMeta, TValue>, value: TValue): Promise<boolean>;
46
+ unset(target: {
47
+ readonly keys?: readonly string[] | undefined;
48
+ readonly meta?: TMeta | undefined;
49
+ }): Promise<void>;
50
+ }
51
+
52
+ type CachedRows = readonly Record<string, unknown>[];
53
+
54
+ type CachedValue =
55
+ | { readonly kind: 'statement-rows'; readonly rows: CachedRows }
56
+ | { readonly kind: 'call-result'; readonly result: unknown };
57
+ ```
58
+
59
+ The store keeps a version per key, even for a key that holds no value; a key never seen has version 0, and only `unset` changes it. `get({ key, meta })` always returns a `CacheEntry` with the key, the read annotation's `meta`, the current version and `data`, which is `{ empty: true }` on a miss. A store that matches `meta` folds the versions of whatever `meta` names into that version, and does the same when `set` compares, so an `invalidate({ meta })` refuses an overlapping read's `set` even for a key the store has never seen. `set(entry, value)` stores `value` if and only if the key's version still equals `entry.version`, and returns whether it stored; there is no unconditional write, so a prefill is `get` then `set`. `unset` removes every value named in `keys` (`undefined` or non-empty) and every value that matches `meta`, increments the version of every key it removes or would remove, and an `unset` by key also drops that key from any `meta` index. A field the caller leaves out of `get` or `unset` means the same as `undefined`, and a store must not tell the two apart. `set` must be atomic against `unset`: one synchronous step in memory, or one script on a server. Keep a moved version at least as long as a read can take; the default store keeps it for `ttlMs`. The store interprets `meta` in all three methods; the middleware never does. A store that cannot act on a `meta` given to `unset` must throw: the default store throws `RUNTIME.CACHE_STORE_META_UNSUPPORTED`. `unset` must not run queries through the runtime that uses the middleware. An error from `get` or `set` fails the read, for statements and ORM calls alike, even when the database answered it; nothing is stored, so the next read fails the same way while the store keeps throwing. 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. A store must give back a value equivalent to the one it was given, `kind` included. An ORM call's result holds the values codecs decoded, such as `Date`, `bigint`, Temporal values and extension class instances, so a store that serializes needs an application-supplied serializer that rebuilds those types; `JSON.stringify` throws on a `bigint` and turns a `Date` into a string. A deploy that changes what a codec returns needs a new key prefix for a shared store, because the key does not cover how values are decoded. The default store, `createInMemoryCacheStore<TMeta>()`, holds cached values. It keeps statement rows and ORM call results differently. Statement rows are copied one level deep on `set` and `get`: the array, and each row object with its prototype. The driver's values in them, such as BSON `ObjectId`s or a `Buffer`, are kept by reference and shared with every hit; the runtime decodes them on each hit, and 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 on `set` and on every `get`: plain and null-prototype objects, arrays (their elements only; holes read as `undefined`), `Map`, `Set`, `Date`, `RegExp`, `ArrayBuffer`, typed arrays (a subclass such as Node's `Buffer` keeps its class) and `DataView` (copied as a plain `DataView`). 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: `set` returns `false` only 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. A custom store decides for itself whether to copy and what it can store.
60
+
61
+ ## Typed meta
62
+
63
+ `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:
64
+
65
+ ```typescript
66
+ interface TagMeta {
67
+ tags: string[];
68
+ }
69
+
70
+ class TagStore implements CacheStore<TagMeta> {
71
+ // get({ key, meta }), set(entry, value) and unset({ keys, meta }), with meta: TagMeta | undefined
72
+ }
73
+
74
+ const cache = createCacheMiddleware({ store: new TagStore() }); // CacheMiddleware<TagMeta>
75
+ await cache.invalidate({ meta: { tags: ['users'] } }); // compiles
76
+ await cache.invalidate({ meta: { tag: 'users' } }); // type error
77
+ ```
78
+
79
+ 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:
80
+
81
+ ```typescript
82
+ export const cached = (o: CacheAnnotationOptions<TagMeta>) => cacheAnnotation<TagMeta>(o);
83
+ ```
84
+
85
+ ## Deriving keys
86
+
87
+ `createCacheMiddleware({ deriveKey })` computes the key of every cached statement whose annotation has no `key`. It takes the statement's execution plan, so it does not apply to an ORM call, which is keyed by its content hash. Build on the default to add a prefix:
88
+
89
+ ```ts
90
+ const cache = createCacheMiddleware({
91
+ deriveKey: async (exec, ctx) => `users:${await deriveKeyFromContentHash(exec, ctx)}`,
92
+ });
93
+ ```
94
+
95
+ An explicit annotation `key` is used literally and bypasses `deriveKey`, so a tenant or namespace prefix must be part of it too. An app that relies on `deriveKey` to separate tenants must also separate them for annotated ORM calls, for example with a tenant-prefixed `key`; otherwise one tenant's cached ORM read can be served to another. A key belongs to one query and one terminal method: if `first()` and `all()` share a key, the one that reads the other's stored result fails with `ORM.INTERCEPTED_RESULT_INVALID` or returns the wrong shape. A derivation that returns the same key for different plans serves one plan's rows to the other, and one that drops the content hash no longer changes on a schema migration. If `deriveKey` throws, the read fails. A builder query and an ORM call that share a key evict each other's entries, so give each its own key.
96
+
97
+ ## Invalidation
98
+
99
+ `invalidate({ keys?, meta? })` removes entries through one `store.unset({ keys, meta })` call, and does nothing when there are no keys and no `meta`.
100
+
101
+ ```ts
102
+ const cache = createCacheMiddleware();
103
+
104
+ await db.orm.public.User.first({ id: 1 }, (meta) =>
105
+ meta.annotate(cacheAnnotation({ key: 'user-1' })),
106
+ );
107
+
108
+ await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' });
109
+ await cache.invalidate({ keys: ['user-1'] });
110
+ ```
111
+
112
+ A read that missed before an `invalidate` and finishes after it does not store its rows. Its `store.get` returned an empty entry with, say, version 3; the `unset` moves the key to version 4; `store.set(entry, rows)` then returns `false`. This works across processes that share a store, because the store owns the version. Call `invalidate` after the write has committed: inside a transaction, another request can put the old rows back in the cache before the commit. An error from the store propagates.
113
+
114
+ A write can carry its invalidation instead. `invalidateAnnotation({ keys?, meta? })` takes the same `InvalidationTarget<TMeta>` as `invalidate`, and the middleware makes the same call from the runtime's `activityFinalized` event, once the write's effects are final:
115
+
116
+ ```ts
117
+ await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' }, (meta) =>
118
+ meta.annotate(invalidateAnnotation({ keys: ['user-1'] })),
119
+ );
120
+ ```
121
+
122
+ It applies to writes only: every SQL ORM write terminal and every SQL builder insert, update and delete accepts it, and a read rejects it at compile time and at run time. Outside a transaction it invalidates right after the write ends, 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)` it invalidates when the transaction ends; until then the entry is still served. A rolled-back write does not invalidate. An `unknown` outcome, such as a failed write or a `COMMIT` that errored, does, since the write may have landed. Several annotated writes in one transaction each invalidate when it ends. The middleware reads the annotation from whatever activity carries it, an ORM call, an annotated builder statement or any client activity, and does not check the kind or the transaction. An error from `unset` is passed to `ctx.log.error` with the event `middleware.activityFinalized.error` and swallowed; the write has already happened. `postgres()`, `sqlite()` and `supabase()` drop these events unless the application passes `log`, for example `postgres({ contract: contractFromJson<Contract>(contractJson), url, middleware: [cache], log: console })`, so pass it to see a failed invalidation. So on a store that cannot act on `meta`, such as the default store, an annotation with `meta` removes nothing, not even its `keys`, and each such write only logs `RUNTIME.CACHE_STORE_META_UNSUPPORTED`. The write's promise, and `db.transaction(fn)`'s, resolve only after `store.unset` has settled, so a custom store must time out its own `unset`. A write whose activity never finalizes, such as a builder write whose row iterator is dropped without `return()`, or a process that stops between the commit and the invalidation, does not invalidate; its entries stay until they expire. The Mongo ORM's write methods take no annotations, so on Mongo call `invalidate` after the write. 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, about 2.9 KB per statement, about 55 MB for a transaction of 20,000 statements, all released at the commit. Split a long batch import into several transactions, or run it on a client without the cache middleware.
123
+
124
+ A tag scheme is a store policy: `cacheAnnotation({ meta: { tags: ['users'] } })` on the read, a store that indexes `meta.tags` in `set`, and `cache.invalidate({ meta: { tags: ['users'] } })` after the write.
125
+
126
+ ## Scope
127
+
128
+ The middleware is a read-through cache with a control surface: keys and `deriveKey`, `meta`, `bypass`, `invalidate` and `invalidateAnnotation`, and the `CacheStore` interface. It carries data between the annotations and the store and never interprets it. Lifetime and the meaning of `meta` are the store's; 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 belong in extensions built on these primitives.
129
+
130
+ Serve-stale strategies such as stale-while-revalidate need the hit-or-miss decision, which only the middleware makes.
package/dist/index.d.mts CHANGED
@@ -1,219 +1,274 @@
1
- import { CrossFamilyMiddleware } from "@prisma/orm-framework/components/runtime";
1
+ import { ActivityContext, AnnotationHandle, AnnotationValue, CrossFamilyMiddleware, ExecutionPlan } from "@prisma/orm-framework/components/runtime";
2
2
  //#region ../../../3-extensions/middleware-cache/dist/index.d.mts
3
3
  //#region src/cache-annotation.d.ts
4
4
  /**
5
- * Payload accepted when calling the `cacheAnnotation` handle.
6
- *
7
- * - `ttl` — Time-to-live for the cached entry, in milliseconds. When
8
- * omitted, the cache middleware passes the query through untouched —
9
- * presence of the annotation alone is not sufficient to enable caching.
10
- * This makes the cache strictly opt-in per query.
11
- * - `skip` — When `true`, the cache middleware passes the query through
12
- * untouched even if a `ttl` is set. Useful for selectively bypassing
13
- * the cache on a per-call basis without removing the annotation
14
- * entirely (e.g. a "force refresh" knob in user code).
15
- * - `key` — Per-query override of the cache key. When supplied, replaces
16
- * the default `RuntimeMiddlewareContext.contentHash(exec)` digest.
17
- * The supplied string is stored as-is — the cache middleware does
18
- * **not** rehash it, so the caller is responsible for ensuring the
19
- * string is bounded in size and free of sensitive data they do not
20
- * want flowing into logs / Redis `KEYS` / persistence dumps.
21
- */
22
- interface CachePayload {
23
- readonly ttl?: number;
24
- readonly skip?: boolean;
25
- readonly key?: string;
5
+ * Options for a read annotated with `cacheAnnotation`. A read with the annotation, in runtime scope
6
+ * and not bypassed, is cached; how long the entry lives is the store's policy.
7
+ *
8
+ * - `key` — the entry's key. When absent, the middleware's `deriveKey` computes it. The key is
9
+ * stored and logged as given, so keep it bounded and free of secrets.
10
+ * - `meta` — any value, passed by reference to the store's `set` with the entry. The middleware
11
+ * never reads it. Stores use it to group entries (for example by tag) or to set a lifetime.
12
+ * - `bypass` — when `true`, the read neither reads from nor writes to the cache.
13
+ *
14
+ * `TMeta` is the shape of `meta`; pass the store's `TMeta` so the two agree.
15
+ */
16
+ interface CacheAnnotationOptions<TMeta = unknown> {
17
+ readonly key?: string | undefined;
18
+ readonly meta?: TMeta | undefined;
19
+ readonly bypass?: boolean | undefined;
26
20
  }
27
21
  /**
28
- * Read-only annotation handle for the cache middleware.
29
- *
30
- * Declared with `applicableTo: ['read']`. Write terminals supply
31
- * `K = 'write'` to the type-level `ValidAnnotations<'write', As>` gate
32
- * (and the runtime `assertAnnotationsApplicable(annotations, 'write', ...)`
33
- * check); the join `K extends Kinds` fails for this annotation, making
34
- * "cache a mutation" structurally impossible without an `as any` cast
35
- * bypass at both type *and* runtime levels.
22
+ * The type of `cacheAnnotation`: a call generic in `TMeta`, plus the read-only `namespace`,
23
+ * `applicableTo` and `read` of the underlying annotation handle.
24
+ */
25
+ type CacheAnnotationHandle = (<TMeta = unknown>(options: CacheAnnotationOptions<TMeta>) => AnnotationValue<CacheAnnotationOptions, 'read'>) & {
26
+ readonly namespace: string;
27
+ readonly applicableTo: ReadonlySet<'read'>;
28
+ readonly read: AnnotationHandle<CacheAnnotationOptions, 'read'>['read'];
29
+ };
30
+ /**
31
+ * Marks a read for the cache middleware. It applies to reads only: a write terminal refuses it at
32
+ * compile time and at run time. The middleware reads it from a statement plan's `meta.annotations.cache`, or from the annotations of a client activity, such as an ORM read, which is then cached as a whole.
36
33
  *
37
- * Stored under namespace `'cache'` in `plan.meta.annotations`. The cache
38
- * middleware reads it via `cacheAnnotation.read(plan)`.
34
+ * `TMeta` types `meta` for this call only. Nothing ties it to the store's `TMeta`, so a store
35
+ * package should export a wrapper typed with its own meta, such as
36
+ * `(o: CacheAnnotationOptions<TagMeta>) => cacheAnnotation<TagMeta>(o)`.
39
37
  *
40
38
  * @example
41
39
  * ```typescript
42
40
  * import { cacheAnnotation } from '@internal/middleware-cache';
43
41
  *
44
42
  * // ORM read terminal — accepts the read-only annotation via the meta callback.
45
- * const user = await db.User.first(
43
+ * const user = await db.orm.public.User.first(
46
44
  * { id },
47
- * (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 })),
45
+ * (meta) => meta.annotate(cacheAnnotation({ key: `user-${id}` })),
48
46
  * );
49
47
  *
50
48
  * // SQL DSL select builder — chainable.
51
49
  * const plan = db.sql
52
50
  * .from(tables.user)
53
- * .annotate(cacheAnnotation({ ttl: 60_000 }))
51
+ * .annotate(cacheAnnotation({}))
54
52
  * .select({ id: tables.user.columns.id })
55
53
  * .build();
56
54
  * ```
57
55
  */
58
- declare const cacheAnnotation: import("@prisma/orm-framework/components/runtime").AnnotationHandle<CachePayload, "read">;
56
+ declare const cacheAnnotation: CacheAnnotationHandle;
57
+ /**
58
+ * What to invalidate: the argument of the cache middleware's `invalidate`, and the payload of `invalidateAnnotation`, which the middleware passes to the same call once the write's effects are final.
59
+ *
60
+ * `TMeta` is the shape of `meta`; pass the store's `TMeta` so the two agree.
61
+ */
62
+ interface InvalidationTarget<TMeta = unknown> {
63
+ readonly keys?: readonly string[] | undefined;
64
+ readonly meta?: TMeta | undefined;
65
+ }
66
+ /**
67
+ * The type of `invalidateAnnotation`: a call generic in `TMeta`, plus the read-only `namespace`,
68
+ * `applicableTo` and `read` of the underlying annotation handle.
69
+ */
70
+ type InvalidateAnnotationHandle = (<TMeta = unknown>(options: InvalidationTarget<TMeta>) => AnnotationValue<InvalidationTarget, 'write'>) & {
71
+ readonly namespace: string;
72
+ readonly applicableTo: ReadonlySet<'write'>;
73
+ readonly read: AnnotationHandle<InvalidationTarget, 'write'>['read'];
74
+ };
75
+ /**
76
+ * Marks a write whose cache entries the cache middleware invalidates once the write's effects are final: right after the write outside a transaction, or when the outermost enclosing transaction ends. It does not invalidate when the write is rolled back, and does when the outcome is unknown. It applies to writes only: a read terminal refuses it at compile time and at run time.
77
+ *
78
+ * `TMeta` types `meta` for this call only. Nothing ties it to the store's `TMeta`, so a store package should export a wrapper typed with its own meta, such as `(o: InvalidationTarget<TagMeta>) => invalidateAnnotation<TagMeta>(o)`.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' }, (meta) =>
83
+ * meta.annotate(invalidateAnnotation({ keys: ['user-1'] })),
84
+ * );
85
+ * ```
86
+ */
87
+ declare const invalidateAnnotation: InvalidateAnnotationHandle;
59
88
  //#endregion
60
89
  //#region src/cache-store.d.ts
61
90
  /**
62
- * A cached set of rows produced by a single execution.
63
- *
64
- * - `rows` are stored raw (undecoded). The SQL runtime's `decodeRow` pass
65
- * wraps the orchestrator output, so intercepted rows go through the
66
- * same codec decoding as driver rows on the way to the consumer. The
67
- * cache stores wire-format values; decoding happens once per consumer
68
- * read regardless of where the rows came from.
69
- * - `storedAt` is the clock value at the moment the entry was committed
70
- * to the store. It is informational metadata for callers (debugging,
71
- * telemetry) and is **not** used by the in-memory store itself for
72
- * expiry — TTL is driven by the store's own clock plus the `ttlMs`
73
- * passed to `set`. Custom stores may use it differently.
74
- */
75
- interface CachedEntry {
76
- readonly rows: readonly Record<string, unknown>[];
77
- readonly storedAt: number;
91
+ * The rows one statement produced, stored raw (undecoded). The SQL runtime decodes intercepted rows
92
+ * the same way as driver rows, so a hit and a miss yield the same values to the consumer.
93
+ */
94
+ type CachedRows = readonly Record<string, unknown>[];
95
+ /**
96
+ * The value type the cache middleware stores: a statement's raw rows, or the result of a client activity, such as an ORM call. The middleware tags each value with what produced it: `statement-rows` for a statement's rows, `call-result` for a client activity's result. It serves `statement-rows` only to a statement and `call-result` only to a client activity; under one key, a value with the other tag is a miss.
97
+ */
98
+ type CachedValue = {
99
+ readonly kind: 'statement-rows';
100
+ readonly rows: CachedRows;
101
+ } | {
102
+ readonly kind: 'call-result';
103
+ readonly result: unknown;
104
+ };
105
+ /**
106
+ * One key of a `CacheStore`, as `get` saw it.
107
+ *
108
+ * - `key` and `meta` — what `get` was asked for. `meta` is the read annotation's `meta`, or
109
+ * `undefined`.
110
+ * - `version` — the key's version when `get` ran. A store that matches `meta` folds the versions
111
+ * of whatever `meta` names into it.
112
+ * - `data` — `{ empty: true }` when the store holds no live value for the key, otherwise that
113
+ * value.
114
+ */
115
+ interface CacheEntry<TMeta = unknown, TValue = CachedValue> {
116
+ readonly key: string;
117
+ readonly meta: TMeta | undefined;
118
+ readonly version: number;
119
+ readonly data: {
120
+ readonly empty: true;
121
+ } | {
122
+ readonly empty: false;
123
+ readonly value: TValue;
124
+ };
78
125
  }
79
126
  /**
80
- * Pluggable cache backend used by the cache middleware.
127
+ * A cache of values that the cache middleware reads from and writes to. The middleware stores
128
+ * `CachedValue`.
129
+ *
130
+ * The store keeps a version per key, an integer it keeps even for a key that holds no value. A key
131
+ * never seen, or forgotten, has version 0. Only `unset` changes a version.
81
132
  *
82
- * The default implementation is an in-memory LRU with TTL produced by
83
- * `createInMemoryCacheStore`. Users can supply Redis, Memcached, or any
84
- * other backend by implementing this interface.
133
+ * - `get` always returns an entry: the live value or `{ empty: true }`, and the current version.
134
+ * A store that matches `meta` folds the versions of whatever `meta` names into that version, and
135
+ * does the same when `set` compares, so an `unset` by `meta` refuses a read's `set` even for a
136
+ * key it has never seen.
137
+ * - `set(entry, value)` stores `value` under `entry.key` with `entry.meta`, if and only if the
138
+ * key's current version still equals `entry.version`, and returns whether it stored. `entry` is
139
+ * the one `get` returned, empty or not. There is no unconditional write: to prefill, `get` then
140
+ * `set`. The compare and the write must be atomic with respect to `unset`, for example one
141
+ * synchronous step in memory or one script on a server.
142
+ * - `unset` removes every value named in `keys` and every value that matches `meta`, and
143
+ * increments the version of every key it removes or would remove, including keys that hold no
144
+ * value. `keys` is `undefined` or non-empty. A field the caller leaves out of `get` or `unset`
145
+ * means the same as `undefined`, and a store must not tell the two apart. A store that cannot act on a `meta` it is given
146
+ * must throw rather than ignore it. An `unset` by key must also drop that key from any `meta`
147
+ * index the store keeps.
85
148
  *
86
- * The interface is intentionally minimal:
149
+ * `get` must give back a value equivalent to the one `set` was given, `kind` included. A call result holds the values codecs decoded, such as a `Date`, a `bigint`, a Temporal value or an extension's class instance, so a store that serializes needs a serializer that rebuilds those types; JSON does not.
87
150
  *
88
- * - `get` returns the entry if it exists and has not expired, or
89
- * `undefined` otherwise. Implementations that gate on TTL should
90
- * treat an expired entry as absent (return `undefined`) and may
91
- * evict it as a side effect.
92
- * - `set` writes the entry under the key with an associated TTL in
93
- * milliseconds. Implementations may evict other entries to make
94
- * room (LRU, LFU, etc.) and may treat the operation as fire-and-
95
- * forget at scale; the cache middleware does not rely on `set`
96
- * completing before subsequent `get`s.
151
+ * Lifetime, eviction and whether values are copied are the store's policy; the middleware hands `set` the value it returns to its caller, so a store that keeps references shares that object. A version that `unset` moved must be kept at least as long as a read can take, so that a read that started before the `unset` cannot store. `unset` must not run queries through the runtime that uses the middleware.
97
152
  *
98
- * Both methods are async to leave the door open for I/O-backed stores
99
- * (Redis, S3, etc.). The default in-memory store completes
100
- * synchronously and wraps the result in `Promise.resolve` for type
101
- * conformance.
153
+ * `TMeta` is the shape of `meta` the store understands. The middleware does not check that a read
154
+ * annotation's `meta` has this shape; see `cacheAnnotation`.
102
155
  */
103
- interface CacheStore {
104
- get(key: string): Promise<CachedEntry | undefined>;
105
- set(key: string, entry: CachedEntry, ttlMs: number): Promise<void>;
156
+ interface CacheStore<TMeta = unknown, TValue = CachedValue> {
157
+ get(target: {
158
+ readonly key: string;
159
+ readonly meta?: TMeta | undefined;
160
+ }): Promise<CacheEntry<TMeta, TValue>>;
161
+ set(entry: CacheEntry<TMeta, TValue>, value: TValue): Promise<boolean>;
162
+ unset(target: {
163
+ readonly keys?: readonly string[] | undefined;
164
+ readonly meta?: TMeta | undefined;
165
+ }): Promise<void>;
106
166
  }
107
167
  /**
108
- * Options accepted by `createInMemoryCacheStore`.
168
+ * Options for `createInMemoryCacheStore`.
109
169
  *
110
- * - `maxEntries` — hard cap on the number of live entries. Once the cap
111
- * is exceeded, the least recently used entry is evicted. Reads and
112
- * writes both count as "uses" for ordering purposes.
113
- * - `clock` — injectable time source for TTL math. Defaults to
114
- * `Date.now`. Tests inject a controlled clock to verify expiry without
115
- * real-time waits.
170
+ * - `maxEntries` — the most values kept, a positive integer; the least recently used is evicted
171
+ * first. Default 1000.
172
+ * - `ttlMs` — how long a value lives after its `set`, and how long a key's version is kept after
173
+ * the `unset` that moved it, a positive number of milliseconds. Default 60 000. Versions are not
174
+ * bounded by `maxEntries`. A forgotten version reads as 0, so a read that takes longer than
175
+ * `ttlMs` and overlaps an `invalidate` can store stale rows. `Infinity` never expires values or
176
+ * versions: the versions of invalidated keys are never forgotten, and grow with the number of
177
+ * distinct keys invalidated.
178
+ * - `clock` — the time source for expiry. Default `Date.now`.
116
179
  */
117
180
  interface InMemoryCacheStoreOptions {
118
- readonly maxEntries: number;
181
+ readonly maxEntries?: number;
182
+ readonly ttlMs?: number;
119
183
  readonly clock?: () => number;
120
184
  }
121
185
  /**
122
- * Default cache backend. An LRU with per-entry TTL, backed by a `Map`.
123
- *
124
- * Eviction policy:
125
- *
126
- * - On `set` of a fresh key whose insertion would push the live count
127
- * above `maxEntries`, the least recently used entry is evicted.
128
- * Setting an existing key updates the entry in place and refreshes its
129
- * recency without changing the live count.
130
- * - On `get` of an existing key, recency is bumped (so the entry is no
131
- * longer the LRU candidate).
132
- * - On `get` of an expired entry, the entry is removed from the map and
133
- * `undefined` is returned. The slot becomes available for new writes
134
- * without counting against `maxEntries`.
135
- *
136
- * `Map` insertion order is the LRU order: the first key is the LRU
137
- * candidate; the last key is the most recently used. Bumping recency is
138
- * a delete-then-set on the underlying map.
139
- *
140
- * The default store is **not** coherent across processes or replicas —
141
- * each process holds its own Map. Users who need a shared cache supply
142
- * their own `CacheStore` (Redis, Memcached, etc.).
186
+ * The default cache store: a least-recently-used map of `CachedValue` with one lifetime for every value, local to the process. Statement rows are copied one level deep on `set` and `get`: the array, and each row object with its prototype. The driver's values in them are kept by reference and shared with every hit; the runtime decodes them on each hit, and a codec that returns its wire value unchanged, such as a date or byte-array codec, hands that shared object to the caller. A call result has its plain structure copied on `set` and on every `get`: plain and null-prototype objects, arrays (their elements only; holes read as `undefined`), `Map`, `Set`, `Date`, `RegExp`, `ArrayBuffer`, typed arrays (a subclass such as Node's `Buffer` keeps its class) and `DataView` (copied as a plain `DataView`). Every other value in it, such as a class instance (a Temporal value, a BSON value, an error) or a function, is shared by reference. So after `set` returns, a change to the plain structure of the value `set` was given, or of a value `get` returned, reaches no later `get`; a change made to a shared value, or made before `set`, does. It never refuses a value: `set` returns `false` only when the version moved. A key's version is kept for `ttlMs` after the `unset` that moved it (see `InMemoryCacheStoreOptions`). It ignores `meta` in `get` and `set`, and its `unset` rejects any `meta`, including `null`, before changing anything. It throws `RUNTIME.ARGUMENT_INVALID` for a `maxEntries` or `ttlMs` outside the ranges above.
143
187
  */
144
- declare function createInMemoryCacheStore(options: InMemoryCacheStoreOptions): CacheStore;
188
+ declare function createInMemoryCacheStore<TMeta = unknown>(options?: InMemoryCacheStoreOptions): CacheStore<TMeta, CachedValue>;
145
189
  //#endregion
146
190
  //#region src/cache-middleware.d.ts
147
191
  /**
148
192
  * Options accepted by `createCacheMiddleware`.
149
193
  *
150
- * - `store` — pluggable cache backend. Defaults to an in-process LRU
151
- * produced by `createInMemoryCacheStore`. Users supply Redis,
152
- * Memcached, or any other backend by implementing the `CacheStore`
153
- * interface.
154
- * - `maxEntries` — only consulted when `store` is omitted. Sets the
155
- * `maxEntries` cap on the default in-memory store. Defaults to 1000.
156
- * - `clock` — injectable time source for `storedAt` stamping on
157
- * committed entries. Defaults to `Date.now`. Tests inject a controlled
158
- * clock to make commit-time observable. Note: TTL math lives inside
159
- * the store, not the middleware — supplying a clock here only affects
160
- * the `storedAt` field on committed `CachedEntry` values.
161
- */
162
- interface CacheMiddlewareOptions {
163
- readonly store?: CacheStore;
164
- readonly maxEntries?: number;
165
- readonly clock?: () => number;
194
+ * - `store` — the cache backend. Defaults to `createInMemoryCacheStore()`: 1000 values, 60 s.
195
+ * - `deriveKey` — computes the key of a cached statement whose annotation has no `key`. Defaults
196
+ * to `deriveKeyFromContentHash`. It runs on every such statement, hit or miss, and an error from
197
+ * it fails the statement. It must return different keys whenever the rows can differ; build on
198
+ * `deriveKeyFromContentHash` to keep the statement, parameters and storage hash. It does not
199
+ * apply to a client activity, such as an ORM call, which has no execution plan: a client
200
+ * activity whose annotation has no `key` is keyed by `activity.contentHash()`. A `deriveKey` that
201
+ * separates tenants does not separate ORM calls; give each annotated ORM call a tenant-prefixed
202
+ * `key`, or one tenant's cached ORM read can be served to another.
203
+ *
204
+ * `TMeta` is the store's meta type; `createCacheMiddleware` infers it from `store`. The store holds
205
+ * `CachedValue`.
206
+ */
207
+ interface CacheMiddlewareOptions<TMeta = unknown> {
208
+ readonly store?: CacheStore<TMeta, CachedValue>;
209
+ readonly deriveKey?: (exec: ExecutionPlan, ctx: ActivityContext) => string | Promise<string>;
166
210
  }
167
211
  /**
168
- * Creates a family-agnostic caching middleware.
169
- *
170
- * The middleware uses three hooks:
171
- *
172
- * - `interceptQuery` — on each execution, checks the cache. On a hit, returns
173
- * the cached raw rows; the runtime skips `runDriver` and `onRow`
174
- * (`beforeQuery` is not affected — it has already run for every
175
- * middleware before any `interceptQuery` is consulted) and yields the
176
- * cached rows to the consumer (which, in the SQL runtime, sees them
177
- * after the standard `decodeRow` pass — i.e. the cache stores
178
- * wire-format values). On a miss, records a pending buffer keyed on
179
- * the `exec` object identity and returns `undefined` (passthrough).
180
- * - `onRow` — on the miss path, appends each row yielded by the driver
181
- * to the pending buffer.
182
- * - `afterQuery` — on the miss path, commits the buffer to the store
183
- * if and only if `result.completed === true && result.source === 'driver'`.
184
- * Failed executions and middleware-served executions never populate
185
- * the cache. The pending buffer is cleared in all branches so a stale
186
- * `WeakMap` entry cannot leak between executions sharing an `exec`.
187
- *
188
- * The middleware bypasses the cache entirely when:
189
- * - the plan has no `cache` annotation, or
190
- * - the annotation has `skip: true`, or
191
- * - the annotation has no `ttl`, or
192
- * - `ctx.scope !== 'runtime'` (connection / transaction scopes opt out).
193
- *
194
- * Returns a cross-family `RuntimeMiddleware` (no `familyId` /
195
- * `targetId`). The package depends only on
196
- * `@internal/framework-components/runtime`; cache keys come from
197
- * `ctx.contentHash(exec)`, populated by the family runtime, so SQL and
198
- * Mongo runtimes both work out of the box.
212
+ * The cache middleware.
213
+ *
214
+ * `invalidate` removes entries through one `store.unset({ keys, meta })` call. It does nothing
215
+ * when `keys` is empty or absent and `meta` is absent. The store moves the version of every key it
216
+ * removes, so a read that missed before the call and finishes after it does not store its rows:
217
+ * its conditional `store.set` returns `false`. This holds across processes that share a store. An
218
+ * error from the store propagates.
219
+ *
220
+ * A write annotated with `invalidateAnnotation({ keys, meta })` makes the same call from `activityFinalized`, once the write's effects are final, unless they were rolled back.
221
+ *
222
+ * `TMeta` is the store's meta type, which `invalidate`'s `meta` must have.
223
+ */
224
+ type CacheMiddleware<TMeta = unknown> = CrossFamilyMiddleware & {
225
+ readonly invalidate: (target: InvalidationTarget<TMeta>) => Promise<void>;
226
+ };
227
+ /**
228
+ * The default `deriveKey`: the family runtime's content hash of the plan, which covers the
229
+ * statement, its parameters and the storage hash.
230
+ */
231
+ declare function deriveKeyFromContentHash(exec: ExecutionPlan, ctx: ActivityContext): Promise<string>;
232
+ /**
233
+ * Creates a read-through cache middleware that works with every family runtime. It also invalidates entries for writes annotated with `invalidateAnnotation`.
234
+ *
235
+ * It caches an activity that carries `cacheAnnotation` without `bypass` and runs in runtime scope,
236
+ * outside any transaction: never on a held connection, whose session may change what a read
237
+ * returns, and never inside a transaction, which must read its own uncommitted writes. Two kinds
238
+ * of activity are cached:
239
+ *
240
+ * - A statement whose plan carries the annotation, such as an annotated SQL builder query. Its key
241
+ * is the annotation's `key`, else `deriveKey(exec, ctx)`; its value is the raw rows.
242
+ * - A client activity that carries the annotation, such as an annotated ORM read. Its key is the
243
+ * annotation's `key`, else `await activity.contentHash()`; its value is the result the client
244
+ * ended the activity with. It is served as a whole, so a hit sends no statement.
245
+ *
246
+ * The middleware tags each stored value `statement-rows` or `call-result` and serves `statement-rows` only to a statement and `call-result` only to a client activity; under the same key, a value with the other tag is a miss, and the read stores its own value in its place. A key therefore belongs to one query and one terminal method: an ORM call served another method's result under a shared key fails with `ORM.INTERCEPTED_RESULT_INVALID` or returns the wrong shape.
247
+ *
248
+ * - `intercept.onQuery` and `intercept.onActivity` — on a hit, return the stored rows or result,
249
+ * and nothing runs. On a miss, record the pending entry.
250
+ * - `onRow` — collects each row of a statement miss.
251
+ * - `activityEnded` — when the driver completed the statement, or the client ended the call
252
+ * `succeeded` with a result (`null` is a result), calls `store.set(entry, value)` with the entry
253
+ * `store.get` returned, which stores only if the key's version has not moved. If an `unset` moved
254
+ * it meanwhile, `set` returns `false` and the middleware logs `middleware.cache.store-skipped`.
255
+ * - `activityFinalized` — for any activity that carries `invalidateAnnotation`, whatever its kind or transaction, calls `invalidate` with the annotation's `keys` and `meta`, unless the outcome is `rolled-back`. An `unknown` outcome invalidates, since the write may have landed. A store error is thrown, and the runtime logs it.
199
256
  *
200
257
  * @example
201
258
  * ```typescript
202
- * import { createCacheMiddleware, cacheAnnotation } from '@internal/middleware-cache';
259
+ * const cache = createCacheMiddleware();
260
+ * const db = postgres({ contract, url, middleware: [cache] });
203
261
  *
204
- * const db = postgres({
205
- * contractJson,
206
- * url: process.env['DATABASE_URL']!,
207
- * middleware: [createCacheMiddleware({ maxEntries: 1000 })],
208
- * });
209
- *
210
- * const user = await db.User.first(
211
- * { id },
212
- * (meta) => meta.annotate(cacheAnnotation({ ttl: 60_000 })),
262
+ * const user = await db.orm.public.User.first(
263
+ * { id: 1 },
264
+ * (meta) => meta.annotate(cacheAnnotation({ key: 'user-1' })),
265
+ * );
266
+ * await db.orm.public.User.where({ id: 1 }).update({ name: 'Alicia' }, (meta) =>
267
+ * meta.annotate(invalidateAnnotation({ keys: ['user-1'] })),
213
268
  * );
214
269
  * ```
215
270
  */
216
- declare function createCacheMiddleware(options?: CacheMiddlewareOptions): CrossFamilyMiddleware;
271
+ declare function createCacheMiddleware<TMeta = unknown>(options?: CacheMiddlewareOptions<TMeta>): CacheMiddleware<TMeta>;
217
272
  //#endregion
218
- export { type CacheMiddlewareOptions, type CachePayload, type CacheStore, type CachedEntry, type InMemoryCacheStoreOptions, cacheAnnotation, createCacheMiddleware, createInMemoryCacheStore };
273
+ export { type CacheAnnotationHandle, type CacheAnnotationOptions, type CacheEntry, type CacheMiddleware, type CacheMiddlewareOptions, type CacheStore, type CachedRows, type CachedValue, type InMemoryCacheStoreOptions, type InvalidateAnnotationHandle, type InvalidationTarget, cacheAnnotation, createCacheMiddleware, createInMemoryCacheStore, deriveKeyFromContentHash, invalidateAnnotation };
219
274
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../../../3-extensions/middleware-cache/dist/index.d.mts"],"mappings":";;;;;;;;;;;;;;;;;;;;;UAoBU;WACC;WACA;WACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAiCG,kEAAkE,iBAAiB;;;;;;;;;;;;;;;;;UAiBvF;WACC,eAAe;WACf;;;;;;;;;;;;;;;;;;;;;;;;;;UA0BD;EACR,IAAI,cAAc,QAAQ;EAC1B,IAAI,aAAa,OAAO,aAAa,gBAAgB;;;;;;;;;;;;UAY7C;WACC;WACA;;;;;;;;;;;;;;;;;;;;;;;;;iBAyBM,yBAAyB,SAAS,4BAA4B;;;;;;;;;;;;;;;;;;UAkBrE;WACC,QAAQ;WACR;WACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmDM,sBAAsB,UAAU,yBAAyB"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../../../3-extensions/middleware-cache/dist/index.d.mts"],"mappings":";;;;;;;;;;;;;;;UAcU,uBAAuB;WACtB;WACA,OAAO;WACP;;;;;;KAMN,0BAA0B,iBAAiB,SAAS,uBAAuB,WAAW,gBAAgB;WAChG;WACA,cAAc;WACd,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA4BpB,iBAAiB;;;;;;UAMrB,mBAAmB;WAClB;WACA,OAAO;;;;;;KAMb,+BAA+B,iBAAiB,SAAS,mBAAmB,WAAW,gBAAgB;WACjG;WACA,cAAc;WACd,MAAM,iBAAiB;;;;;;;;;;;;;;cAcpB,sBAAsB;;;;;;;KAO/B,sBAAsB;;;;KAItB;WACM;WACA,MAAM;;WAEN;WACA;;;;;;;;;;;;UAYD,WAAW,iBAAiB,SAAS;WACpC;WACA,MAAM;WACN;WACA;aACE;;aAEA;aACA,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAiCV,WAAW,iBAAiB,SAAS;EAC7C,IAAI;aACO;aACA,OAAO;MACd,QAAQ,WAAW,OAAO;EAC9B,IAAI,OAAO,WAAW,OAAO,SAAS,OAAO,SAAS;EACtD,MAAM;aACK;aACA,OAAO;MACd;;;;;;;;;;;;;;;UAeI;WACC;WACA;WACA;;;;;iBAKM,yBAAyB,iBAAiB,UAAU,4BAA4B,WAAW,OAAO;;;;;;;;;;;;;;;;;;;UAmBzG,uBAAuB;WACtB,QAAQ,WAAW,OAAO;WAC1B,aAAa,MAAM,eAAe,KAAK,6BAA6B;;;;;;;;;;;;;;;KAe1E,gBAAgB,mBAAmB;WAC7B,aAAa,QAAQ,mBAAmB,WAAW;;;;;;iBAM7C,yBAAyB,MAAM,eAAe,KAAK,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwCrE,sBAAsB,iBAAiB,UAAU,uBAAuB,SAAS,gBAAgB"}