@prisma/orm-extension-middleware-cache 8.0.0-rc.2-dev.3 → 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 +113 -6
- package/dist/index.d.mts +217 -162
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +308 -167
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @prisma/orm-extension-middleware-cache
|
|
2
2
|
|
|
3
|
-
Opt-in query caching for Prisma
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
28
|
+
## The store
|
|
22
29
|
|
|
23
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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({
|
|
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({
|
|
51
|
+
* .annotate(cacheAnnotation({}))
|
|
54
52
|
* .select({ id: tables.user.columns.id })
|
|
55
53
|
* .build();
|
|
56
54
|
* ```
|
|
57
55
|
*/
|
|
58
|
-
declare const cacheAnnotation:
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
99
|
-
*
|
|
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(
|
|
105
|
-
|
|
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
|
|
168
|
+
* Options for `createInMemoryCacheStore`.
|
|
109
169
|
*
|
|
110
|
-
* - `maxEntries` —
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `
|
|
115
|
-
*
|
|
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
|
|
181
|
+
readonly maxEntries?: number;
|
|
182
|
+
readonly ttlMs?: number;
|
|
119
183
|
readonly clock?: () => number;
|
|
120
184
|
}
|
|
121
185
|
/**
|
|
122
|
-
*
|
|
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
|
|
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` —
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
readonly
|
|
165
|
-
readonly
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
* -
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* `
|
|
198
|
-
*
|
|
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
|
-
*
|
|
259
|
+
* const cache = createCacheMiddleware();
|
|
260
|
+
* const db = postgres({ contract, url, middleware: [cache] });
|
|
203
261
|
*
|
|
204
|
-
* const
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
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):
|
|
271
|
+
declare function createCacheMiddleware<TMeta = unknown>(options?: CacheMiddlewareOptions<TMeta>): CacheMiddleware<TMeta>;
|
|
217
272
|
//#endregion
|
|
218
|
-
export { type
|
|
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
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../../../3-extensions/middleware-cache/dist/index.d.mts"],"mappings":"
|
|
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"}
|