@alxia/cache 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +143 -2
- package/dist/cache.d.ts +74 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/control.d.ts +16 -0
- package/dist/control.d.ts.map +1 -0
- package/dist/flight.d.ts +24 -0
- package/dist/flight.d.ts.map +1 -0
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +276 -0
- package/dist/index.js.map +18 -0
- package/dist/keep.d.ts +19 -0
- package/dist/keep.d.ts.map +1 -0
- package/dist/keys.d.ts +10 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/lookup.d.ts +7 -0
- package/dist/lookup.d.ts.map +1 -0
- package/dist/respond.d.ts +4 -0
- package/dist/respond.d.ts.map +1 -0
- package/dist/store.d.ts +43 -0
- package/dist/store.d.ts.map +1 -0
- package/docs/README.md +16 -0
- package/docs/guide/caching.md +326 -0
- package/docs/guide/invalidation.md +192 -0
- package/docs/guide/keys-and-vary.md +219 -0
- package/docs/guide/stores.md +273 -0
- package/docs/roadmap.md +67 -0
- package/docs/troubleshooting.md +491 -0
- package/package.json +51 -5
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Invalidation
|
|
2
|
+
|
|
3
|
+
This page covers emptying the cache when the data behind it changes: by
|
|
4
|
+
path, by tag, from a route or elsewhere, and across processes.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { alxia } from '@alxia/core';
|
|
8
|
+
import { cache } from '@alxia/cache';
|
|
9
|
+
|
|
10
|
+
const products = cache({ ttl: 300, tags: () => ['products'] });
|
|
11
|
+
|
|
12
|
+
const app = alxia()
|
|
13
|
+
.post('/products', async ({ reply }) => {
|
|
14
|
+
// … save it
|
|
15
|
+
await products.invalidateTag('products'); // every product page runs again
|
|
16
|
+
return reply(201, { saved: true });
|
|
17
|
+
})
|
|
18
|
+
.use(products)
|
|
19
|
+
.get('/products', ({ reply }) => reply(200, []))
|
|
20
|
+
.get('/products/:id', ({ params, reply }) => reply(200, { id: params.id }));
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Without invalidation, a response is served until `ttl +
|
|
24
|
+
staleWhileRevalidate` has passed. With it, the next request after a write
|
|
25
|
+
runs the route.
|
|
26
|
+
|
|
27
|
+
## By tag: `invalidateTag`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
invalidateTag(tag: string): Promise<void>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Forgets every response that carries `tag`, whatever its key. A response
|
|
34
|
+
carries:
|
|
35
|
+
|
|
36
|
+
- the tags of the plugin's `tags(ctx)`, computed for each response kept —
|
|
37
|
+
`cache<{ user: User }>(…)` lets it read what an earlier plugin added
|
|
38
|
+
([Reading the app's context](keys-and-vary.md#reading-the-apps-context));
|
|
39
|
+
- the tags its route added with `ctx.cache.tag(…)`.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { alxia } from '@alxia/core';
|
|
43
|
+
import { cache } from '@alxia/cache';
|
|
44
|
+
|
|
45
|
+
const catalogue = cache({
|
|
46
|
+
ttl: 300,
|
|
47
|
+
tags: ({ url }) => (url.pathname.startsWith('/products') ? ['products'] : []),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const app = alxia()
|
|
51
|
+
.use(catalogue)
|
|
52
|
+
.get('/products', ({ reply }) => reply(200, []))
|
|
53
|
+
.get('/products/:id', ({ params, cache, reply }) => {
|
|
54
|
+
cache.tag(`product:${params.id}`);
|
|
55
|
+
return reply(200, { id: params.id });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await catalogue.invalidateTag('product:1'); // only /products/1, in every language and query
|
|
59
|
+
await catalogue.invalidateTag('products'); // every page of the catalogue
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A tag is on the response, not in its key, so it reaches every query
|
|
63
|
+
string and every `vary` value. Tag what a write changes — the entity, its
|
|
64
|
+
collection — and invalidate those tags from the write.
|
|
65
|
+
|
|
66
|
+
## By path: `invalidate`
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
invalidate(path: string): Promise<void>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Forgets every response kept for `path`, whatever its key: each `vary`
|
|
73
|
+
value, a `key` of your own. `path` is the path and query as the request
|
|
74
|
+
asked them, prefix included.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { alxia } from '@alxia/core';
|
|
78
|
+
import { cache } from '@alxia/cache';
|
|
79
|
+
|
|
80
|
+
const products = cache({ ttl: 300, vary: ['accept-language'] });
|
|
81
|
+
const app = alxia({ prefix: '/api' })
|
|
82
|
+
.use(products)
|
|
83
|
+
.get('/products', ({ reply }) => reply.ok([]));
|
|
84
|
+
|
|
85
|
+
await products.invalidate('/api/products'); // GET /api/products, in every language
|
|
86
|
+
await products.invalidate('/api/products?page=2'); // GET /api/products?page=2, and only that
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
It works by a tag: every response is kept with `alxia:path:<path and
|
|
90
|
+
query>` among its tags — `pathTag(path)` builds it — and `invalidate(path)`
|
|
91
|
+
is `store.deleteTag(pathTag(path))`.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { pathTag } from '@alxia/cache';
|
|
95
|
+
|
|
96
|
+
pathTag('/api/products?page=2'); // 'alxia:path:/api/products?page=2'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Tags starting `alxia:` are the plugin's: do not give one of yours that
|
|
100
|
+
prefix. A store of your own must remember each response's `tags`, or
|
|
101
|
+
`invalidate` reaches nothing ([Writing a store](stores.md#writing-a-store)).
|
|
102
|
+
|
|
103
|
+
The path is matched exactly, so it does **not** reach:
|
|
104
|
+
|
|
105
|
+
| A response kept… | because its path is | Use instead |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| at another query | `/products?page=2` is not `/products` | each path, or a tag |
|
|
108
|
+
| at the same query reordered | `/products?b=2&a=1` is not `/products?a=1&b=2` | a tag |
|
|
109
|
+
| with the app's prefix | `/api/products` is not `/products` | the full path |
|
|
110
|
+
|
|
111
|
+
A `key` of your own that several paths share — `key: (ctx) =>
|
|
112
|
+
ctx.url.pathname`, which `/products` and `/products?page=2` both write — is
|
|
113
|
+
reached by the path that wrote it last: `MemoryCacheStore` replaces the
|
|
114
|
+
entry with its tags, so `invalidate('/products')` misses it once
|
|
115
|
+
`/products?page=2` has written it. `redisCacheStore` keeps every path that
|
|
116
|
+
wrote it, and forgets it from either. Give such a key a tag, and use
|
|
117
|
+
`invalidateTag`.
|
|
118
|
+
|
|
119
|
+
`invalidate` resolves when nothing was kept for `path`. It rejects when the
|
|
120
|
+
store does — see [When the store cannot answer](stores.md#when-the-store-cannot-answer).
|
|
121
|
+
|
|
122
|
+
## From the store
|
|
123
|
+
|
|
124
|
+
`cache()` exposes its store: `products.store`. Deleting one key you
|
|
125
|
+
computed yourself forgets that response alone:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
await products.store.delete('/api/products|accept-language=fr'); // the French one only
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Several caches, one store
|
|
132
|
+
|
|
133
|
+
Each `cache()` without a `store` gets a memory store of its own, and its
|
|
134
|
+
`invalidateTag` empties only that store. Give two caches one store, and a
|
|
135
|
+
tag invalidated through either is forgotten in both:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { alxia } from '@alxia/core';
|
|
139
|
+
import { cache, MemoryCacheStore } from '@alxia/cache';
|
|
140
|
+
|
|
141
|
+
const store = new MemoryCacheStore();
|
|
142
|
+
const shortLived = cache({ ttl: 10, store, tags: () => ['products'] });
|
|
143
|
+
const longLived = cache({ ttl: 600, store, tags: () => ['products'] });
|
|
144
|
+
|
|
145
|
+
const app = alxia()
|
|
146
|
+
.group((g) => g.use(shortLived).get('/products/stock', ({ reply }) => reply(200, 5)))
|
|
147
|
+
.group((g) => g.use(longLived).get('/products', ({ reply }) => reply(200, [])));
|
|
148
|
+
|
|
149
|
+
await shortLived.invalidateTag('products'); // both routes run again
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Two caches on one store must not keep the same key: give them different
|
|
153
|
+
paths, as here, or keys of their own.
|
|
154
|
+
|
|
155
|
+
## Across processes
|
|
156
|
+
|
|
157
|
+
The memory store lives in one process. With more than one — a cluster,
|
|
158
|
+
several containers — invalidating in the process that handled the write
|
|
159
|
+
leaves every other process serving its old copy until it expires.
|
|
160
|
+
`@alxia/redis`'s `redisCacheStore` keeps the responses and their tags in a
|
|
161
|
+
Redis every process shares, so one `invalidateTag` reaches all of them
|
|
162
|
+
([Stores](stores.md#in-redis)).
|
|
163
|
+
|
|
164
|
+
## In a test
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { expect, test } from 'bun:test';
|
|
168
|
+
import { alxia } from '@alxia/core';
|
|
169
|
+
import { cache } from '@alxia/cache';
|
|
170
|
+
|
|
171
|
+
test('invalidated by path, and by tag', async () => {
|
|
172
|
+
let runs = 0;
|
|
173
|
+
const products = cache({ ttl: 60, tags: () => ['products'] });
|
|
174
|
+
const app = alxia()
|
|
175
|
+
.use(products)
|
|
176
|
+
.get('/products', ({ reply }) => reply(200, { runs: ++runs }));
|
|
177
|
+
|
|
178
|
+
await app.request('/products');
|
|
179
|
+
await products.invalidate('/products');
|
|
180
|
+
await app.request('/products');
|
|
181
|
+
expect(runs).toBe(2);
|
|
182
|
+
|
|
183
|
+
await products.invalidateTag('products');
|
|
184
|
+
await app.request('/products');
|
|
185
|
+
expect(runs).toBe(3);
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## See also
|
|
190
|
+
|
|
191
|
+
- [Keys and Vary](keys-and-vary.md): what a key is made of.
|
|
192
|
+
- [Stores](stores.md): what `delete` and `deleteTag` do in each store.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Keys and Vary
|
|
2
|
+
|
|
3
|
+
This page covers what makes two requests "the same" to the cache: the
|
|
4
|
+
default key, `vary`, a `key` of your own, reading what an earlier plugin
|
|
5
|
+
added, and keeping personal responses out.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { alxia } from '@alxia/core';
|
|
9
|
+
import { cache } from '@alxia/cache';
|
|
10
|
+
|
|
11
|
+
const app = alxia()
|
|
12
|
+
.use(cache({ ttl: 60, vary: ['accept-language'] }))
|
|
13
|
+
.get('/hello', ({ request, reply }) =>
|
|
14
|
+
reply(200, request.headers.get('accept-language')?.startsWith('fr') ? 'Bonjour' : 'Hello'),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// GET /hello with accept-language: fr → "Bonjour", kept under "/hello|accept-language=fr"
|
|
18
|
+
// GET /hello with accept-language: en → "Hello", kept under "/hello|accept-language=en"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The default key
|
|
22
|
+
|
|
23
|
+
The request's path and query, as they arrive — prefix included — then the
|
|
24
|
+
value of each `vary` header:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
defaultKey(path: string, vary: readonly string[], headers: Headers): string
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { defaultKey } from '@alxia/cache';
|
|
32
|
+
|
|
33
|
+
defaultKey('/api/products?page=2', [], new Headers());
|
|
34
|
+
// '/api/products?page=2'
|
|
35
|
+
|
|
36
|
+
defaultKey('/hello', ['accept-language'], new Headers({ 'accept-language': 'fr' }));
|
|
37
|
+
// '/hello|accept-language=fr'
|
|
38
|
+
|
|
39
|
+
defaultKey('/hello', ['accept-language'], new Headers());
|
|
40
|
+
// '/hello|accept-language=' — a missing header is its own key
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The query is taken as written: `?a=1&b=2` and `?b=2&a=1` are two keys, and
|
|
44
|
+
two misses. Nothing else of the request is in the key — no cookie, no
|
|
45
|
+
`Authorization` — unless it is in `vary`, or in a `key` of your own.
|
|
46
|
+
|
|
47
|
+
## `vary`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
cache({ ttl: 60, vary: ['accept-language'] });
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Each header named is:
|
|
54
|
+
|
|
55
|
+
- part of the default key, by its exact value;
|
|
56
|
+
- appended to the response's `Vary`, so a browser or a CDN in front keys
|
|
57
|
+
its own copy the same way.
|
|
58
|
+
|
|
59
|
+
Names are matched case-insensitively and written lowercased.
|
|
60
|
+
|
|
61
|
+
**The response's own `Vary` is not read.** Only the names in `vary` are in
|
|
62
|
+
the default key. A response that varies by a header of its own — `app.static` with
|
|
63
|
+
`precompressed` varies by `Accept-Encoding` — is kept once and served to
|
|
64
|
+
every client, whatever that header says. Name it here:
|
|
65
|
+
`vary: ['accept-encoding']` ([troubleshooting](../troubleshooting.md#a-client-that-sent-no-accept-encoding-gets-gzip-bytes)).
|
|
66
|
+
|
|
67
|
+
**Exact value means exact.** A browser sends its whole preference list —
|
|
68
|
+
`Accept-Language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7` — and two visitors
|
|
69
|
+
whose lists differ by one entry are two keys, though your route answers
|
|
70
|
+
both in French. curl sends no `Accept-Language` at all, so every curl
|
|
71
|
+
request shares the one key `…|accept-language=`: a test with curl shows hits
|
|
72
|
+
that real traffic will not. When the route reduces a header to a few
|
|
73
|
+
values, key by those values instead — [below](#a-key-of-your-own).
|
|
74
|
+
|
|
75
|
+
`vary: ['cookie']` is rarely what you want: a browser sends every cookie of
|
|
76
|
+
the site, analytics included, so almost every visitor has a key of their
|
|
77
|
+
own and the cache keeps one copy per visitor. Keep personal responses
|
|
78
|
+
[out of the cache](#personal-responses) instead.
|
|
79
|
+
|
|
80
|
+
## A key of your own
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
key?: (ctx: BaseContext & Requires) => string | undefined;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`key` replaces the default key whole. It is synchronous and reads the
|
|
87
|
+
`BaseContext` — `request`, `url`, `ip`, `server`, `route`, `pathParams` —
|
|
88
|
+
and `Requires`, empty by default. To key by what an earlier plugin added,
|
|
89
|
+
see [Reading the app's context](#reading-the-apps-context).
|
|
90
|
+
|
|
91
|
+
Keyed by the language the route actually answers in, two visitors who both
|
|
92
|
+
prefer French share one copy:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { alxia } from '@alxia/core';
|
|
96
|
+
import { cache } from '@alxia/cache';
|
|
97
|
+
|
|
98
|
+
const supported = ['en', 'fr', 'de'] as const;
|
|
99
|
+
|
|
100
|
+
function language(header: string | null): string {
|
|
101
|
+
const first = (header ?? '').split(',')[0]?.split('-')[0]?.trim().toLowerCase() ?? '';
|
|
102
|
+
return (supported as readonly string[]).includes(first) ? first : 'en';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const app = alxia()
|
|
106
|
+
.use(
|
|
107
|
+
cache({
|
|
108
|
+
ttl: 60,
|
|
109
|
+
vary: ['accept-language'], // still says `Vary: accept-language`
|
|
110
|
+
key: ({ url, request }) =>
|
|
111
|
+
`${url.pathname}${url.search}|${language(request.headers.get('accept-language'))}`,
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
.get('/hello', ({ request, reply }) =>
|
|
115
|
+
reply(200, language(request.headers.get('accept-language')) === 'fr' ? 'Bonjour' : 'Hello'),
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Two rules keep a custom key honest:
|
|
120
|
+
|
|
121
|
+
- **The route must choose by what the key reads.** Here both call the same
|
|
122
|
+
`language()`. A route that reads more than the key would be kept under
|
|
123
|
+
one key with two different bodies, and serve whichever came first.
|
|
124
|
+
- **`vary` no longer feeds the key** once `key` is set: it only writes
|
|
125
|
+
`Vary`. Use `defaultKey` inside your key to keep both:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { cache, defaultKey } from '@alxia/cache';
|
|
129
|
+
|
|
130
|
+
cache({
|
|
131
|
+
ttl: 60,
|
|
132
|
+
vary: ['accept-language'],
|
|
133
|
+
key: ({ url, request }) =>
|
|
134
|
+
request.headers.has('authorization')
|
|
135
|
+
? undefined
|
|
136
|
+
: defaultKey(`${url.pathname}${url.search}`, ['accept-language'], request.headers),
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Whatever your key, [`invalidate(path)`](invalidation.md#by-path-invalidate)
|
|
141
|
+
still forgets every response kept for a path: it deletes by the path's tag,
|
|
142
|
+
not by key.
|
|
143
|
+
|
|
144
|
+
## Reading the app's context
|
|
145
|
+
|
|
146
|
+
To key or tag by what an earlier plugin added, such as a signed-in `user`
|
|
147
|
+
and its tenant, name it as `cache`'s type argument. `key` and `tags` then
|
|
148
|
+
read it, and the cache is a [`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
|
|
149
|
+
plugin: an app that does not give `user` before it cannot use it.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const perTenant = cache<{ user: { tenantId: string } }>({
|
|
153
|
+
ttl: 60,
|
|
154
|
+
key: ({ user, url }) => `${user.tenantId}:${url.pathname}${url.search}`,
|
|
155
|
+
tags: ({ user }) => [`tenant:${user.tenantId}`],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const auth = alxia().derive(({ request }) => ({
|
|
159
|
+
user: { tenantId: request.headers.get('x-tenant') ?? 'public' }, // your session plugin
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const app = alxia()
|
|
163
|
+
.use(auth)
|
|
164
|
+
.use(perTenant)
|
|
165
|
+
.get('/dashboard', ({ user, reply }) => reply(200, { tenant: user.tenantId }));
|
|
166
|
+
|
|
167
|
+
await perTenant.invalidateTag('tenant:acme'); // one tenant's pages, every path
|
|
168
|
+
|
|
169
|
+
alxia().use(perTenant);
|
|
170
|
+
// error: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The rule of [a key of your own](#a-key-of-your-own) still holds: the route
|
|
174
|
+
must choose by what the key reads, here the tenant, and nothing more
|
|
175
|
+
personal.
|
|
176
|
+
|
|
177
|
+
## Personal responses
|
|
178
|
+
|
|
179
|
+
The default key does not read who is asking. A route that answers by the
|
|
180
|
+
`Cookie` or `Authorization` header, behind a cache with the default key,
|
|
181
|
+
and says nothing about it, serves the first visitor's answer to every later
|
|
182
|
+
one. curl, sending no cookie, never shows it; a signed-in browser does.
|
|
183
|
+
|
|
184
|
+
A personal response that says so is never kept, nor shared with a
|
|
185
|
+
concurrent request: it answers `Cache-Control: private` (or `no-store`),
|
|
186
|
+
or sets a cookie. Three ways, from the cheapest:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// 1. Declare them before the cache: it never sees them, and no lookup is made.
|
|
190
|
+
alxia()
|
|
191
|
+
.get('/me', ({ reply }) => reply(200, { name: 'Grace' }))
|
|
192
|
+
.use(cache({ ttl: 60 }))
|
|
193
|
+
.get('/products', ({ reply }) => reply(200, []));
|
|
194
|
+
|
|
195
|
+
// 2. No key for a request that carries a session: it is neither looked up nor kept.
|
|
196
|
+
cache({
|
|
197
|
+
ttl: 60,
|
|
198
|
+
key: ({ url, request }) =>
|
|
199
|
+
request.headers.has('cookie') || request.headers.has('authorization')
|
|
200
|
+
? undefined
|
|
201
|
+
: `${url.pathname}${url.search}`,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// 3. Behind the cache, the route says its response is private: it is never kept.
|
|
205
|
+
app.get('/me', ({ reply }) =>
|
|
206
|
+
reply(200, { name: 'Grace' }, { headers: { 'cache-control': 'private' } }),
|
|
207
|
+
);
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The second skips the cache for **every** request with a cookie, which in a
|
|
211
|
+
browser is most of them once any cookie is set: prefer it for an API whose
|
|
212
|
+
callers sign every request. The third is enough on its own, and worth
|
|
213
|
+
sending anyway, for the browser and any proxy in front; the first also
|
|
214
|
+
saves the store lookup.
|
|
215
|
+
|
|
216
|
+
## See also
|
|
217
|
+
|
|
218
|
+
- [Caching responses](caching.md): which responses are kept at all.
|
|
219
|
+
- [Invalidation](invalidation.md): forgetting a key, or a tag.
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# Stores
|
|
2
|
+
|
|
3
|
+
This page covers where responses are kept: the memory store and its
|
|
4
|
+
limits, the Redis store from `@alxia/redis`, and writing a store of your
|
|
5
|
+
own.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { alxia } from '@alxia/core';
|
|
9
|
+
import { cache, MemoryCacheStore } from '@alxia/cache';
|
|
10
|
+
|
|
11
|
+
const store = new MemoryCacheStore({ maxEntries: 5_000, maxBytes: 128 * 1024 * 1024 });
|
|
12
|
+
|
|
13
|
+
const app = alxia()
|
|
14
|
+
.use(cache({ ttl: 60, store }))
|
|
15
|
+
.get('/products', ({ reply }) => reply(200, []));
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The plugin does not know which store it was given: every store answers the
|
|
19
|
+
same `CacheStore` contract, and freshness — `ttl`, `staleWhileRevalidate` —
|
|
20
|
+
is decided by the plugin, not the store.
|
|
21
|
+
|
|
22
|
+
## In memory: `MemoryCacheStore`
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
new MemoryCacheStore(options?: MemoryCacheOptions)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Option | Type | Default | Effect |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `maxEntries` | `number` | `1_000` | the most responses kept |
|
|
31
|
+
| `maxBytes` | `number` | `64 * 1024 * 1024` (64 MiB) | the most bytes of bodies kept; headers are not counted |
|
|
32
|
+
|
|
33
|
+
Past either limit, the **least recently read** response goes first: reading
|
|
34
|
+
a response moves it to the back of the queue. A single body larger than
|
|
35
|
+
`maxBytes` is never kept, so its route answers `MISS` every time.
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { expect, test } from 'bun:test';
|
|
39
|
+
import { MemoryCacheStore } from '@alxia/cache';
|
|
40
|
+
|
|
41
|
+
const entry = (bytes: number) => ({
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: [],
|
|
44
|
+
body: new Uint8Array(bytes),
|
|
45
|
+
storedAt: Date.now(),
|
|
46
|
+
ttl: 1000,
|
|
47
|
+
stale: 0,
|
|
48
|
+
tags: [],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the least recently read goes first', () => {
|
|
52
|
+
const store = new MemoryCacheStore({ maxEntries: 2 });
|
|
53
|
+
store.set('a', entry(10), 1000);
|
|
54
|
+
store.set('b', entry(10), 1000);
|
|
55
|
+
store.get('a'); // a is now the most recently read
|
|
56
|
+
store.set('c', entry(10), 1000); // b goes
|
|
57
|
+
expect(store.get('b')).toBeUndefined();
|
|
58
|
+
expect(store.get('a')).toBeDefined();
|
|
59
|
+
expect(store.size).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`size` reads how many responses are kept. A response is dropped once
|
|
64
|
+
`ttl + staleWhileRevalidate` has passed, when it is next read.
|
|
65
|
+
|
|
66
|
+
The memory store is the default: each `cache()` without a `store` creates
|
|
67
|
+
its own. It is right for one process; with several, each keeps its own copy
|
|
68
|
+
and its own invalidations ([Invalidation](invalidation.md#across-processes)).
|
|
69
|
+
|
|
70
|
+
## In Redis
|
|
71
|
+
|
|
72
|
+
`@alxia/redis` ships `redisCacheStore`, a `CacheStore` on `@nxgt/redis`:
|
|
73
|
+
every process sharing the Redis serves what one of them kept, and one
|
|
74
|
+
`invalidateTag` reaches them all.
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
bun add @alxia/redis @nxgt/redis @nxgt/redis-guard zod
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { cache } from '@alxia/cache';
|
|
82
|
+
import { redisCacheStore } from '@alxia/redis';
|
|
83
|
+
import { connectRedis } from '@nxgt/redis';
|
|
84
|
+
|
|
85
|
+
const connection = await connectRedis(Bun.env.REDIS_URL!);
|
|
86
|
+
|
|
87
|
+
const products = cache({
|
|
88
|
+
ttl: 60,
|
|
89
|
+
staleWhileRevalidate: 300,
|
|
90
|
+
store: redisCacheStore(connection.client, { name: 'shop' }), // keys start with "shop:"
|
|
91
|
+
tags: () => ['products'],
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`name` is prepended to every key the store writes: one per app or
|
|
96
|
+
deployment sharing the Redis. See `@alxia/redis`'s README for its
|
|
97
|
+
connection and its other options.
|
|
98
|
+
|
|
99
|
+
Two things change with Redis:
|
|
100
|
+
|
|
101
|
+
- **Concurrent misses** run the route once per process, not once overall.
|
|
102
|
+
- **A Redis that is down** is a store that cannot answer: the routes still
|
|
103
|
+
answer, uncached ([below](#when-the-store-cannot-answer)).
|
|
104
|
+
|
|
105
|
+
## Writing a store
|
|
106
|
+
|
|
107
|
+
A store implements four methods. Each may answer synchronously or with a
|
|
108
|
+
promise:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
interface CacheStore {
|
|
112
|
+
get(key: string): Promise<CachedResponse | undefined> | CachedResponse | undefined;
|
|
113
|
+
/** Keeps `value` for `keepFor` milliseconds: its freshness and its staleness together. */
|
|
114
|
+
set(key: string, value: CachedResponse, keepFor: number): Promise<void> | void;
|
|
115
|
+
delete(key: string): Promise<void> | void;
|
|
116
|
+
/** Forgets every response tagged `tag`. */
|
|
117
|
+
deleteTag(tag: string): Promise<void> | void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface CachedResponse {
|
|
121
|
+
readonly status: number;
|
|
122
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
123
|
+
readonly body: Uint8Array;
|
|
124
|
+
readonly storedAt: number; // milliseconds since the epoch
|
|
125
|
+
readonly ttl: number; // milliseconds fresh, from storedAt
|
|
126
|
+
readonly stale: number; // milliseconds served stale after that
|
|
127
|
+
readonly tags: readonly string[];
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
What the plugin relies on:
|
|
132
|
+
|
|
133
|
+
| Method | Must |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `get` | return what `set` was given, or `undefined` — never `null` — when nothing is kept, or it expired |
|
|
136
|
+
| `set` | keep `value` under `key` for `keepFor` **milliseconds**, replacing what was there, and remember its `tags` — `invalidate(path)` relies on them too |
|
|
137
|
+
| `delete` | forget `key`; a key that is not there is not an error |
|
|
138
|
+
| `deleteTag` | forget every key whose response carries `tag` |
|
|
139
|
+
|
|
140
|
+
`get` need not check freshness: the plugin reads `storedAt`, `ttl` and
|
|
141
|
+
`stale` itself. Expiring at `keepFor` only bounds what the store holds.
|
|
142
|
+
|
|
143
|
+
A store over any key-value service — here a plain `Map`, standing in for
|
|
144
|
+
one that serialises — looks like this:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import type { CachedResponse, CacheStore } from '@alxia/cache';
|
|
148
|
+
|
|
149
|
+
interface Row {
|
|
150
|
+
value: CachedResponse;
|
|
151
|
+
expiresAt: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function mapCacheStore(): CacheStore {
|
|
155
|
+
const rows = new Map<string, Row>();
|
|
156
|
+
const tags = new Map<string, Set<string>>();
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
get(key) {
|
|
160
|
+
const row = rows.get(key);
|
|
161
|
+
if (row === undefined || row.expiresAt <= Date.now()) return undefined;
|
|
162
|
+
return row.value;
|
|
163
|
+
},
|
|
164
|
+
set(key, value, keepFor) {
|
|
165
|
+
rows.set(key, { value, expiresAt: Date.now() + keepFor });
|
|
166
|
+
for (const tag of value.tags) {
|
|
167
|
+
const keys = tags.get(tag) ?? new Set<string>();
|
|
168
|
+
keys.add(key);
|
|
169
|
+
tags.set(tag, keys);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
delete(key) {
|
|
173
|
+
rows.delete(key);
|
|
174
|
+
},
|
|
175
|
+
deleteTag(tag) {
|
|
176
|
+
for (const key of tags.get(tag) ?? []) rows.delete(key);
|
|
177
|
+
tags.delete(tag);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A service that stores text keeps `body` as base64 and `headers` as an array
|
|
184
|
+
of pairs, and rebuilds the `Uint8Array` on `get` — as `redisCacheStore`
|
|
185
|
+
does.
|
|
186
|
+
|
|
187
|
+
Every kept response carries, among its `tags`, the tag of its path —
|
|
188
|
+
`alxia:path:/products?page=2`, built by `pathTag` — and `invalidate(path)`
|
|
189
|
+
is `deleteTag` of it. A store that drops `tags` leaves `invalidate` and
|
|
190
|
+
`invalidateTag` reaching nothing.
|
|
191
|
+
|
|
192
|
+
### Testing a store
|
|
193
|
+
|
|
194
|
+
The plugin's own behaviour is the best test of a store: run an app on it.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { expect, test } from 'bun:test';
|
|
198
|
+
import { alxia } from '@alxia/core';
|
|
199
|
+
import { cache } from '@alxia/cache';
|
|
200
|
+
import { mapCacheStore } from './map-cache-store';
|
|
201
|
+
|
|
202
|
+
test('the store serves, and forgets by tag and by path', async () => {
|
|
203
|
+
let runs = 0;
|
|
204
|
+
const products = cache({ ttl: 60, store: mapCacheStore(), tags: () => ['products'] });
|
|
205
|
+
const app = alxia()
|
|
206
|
+
.use(products)
|
|
207
|
+
.get('/products', ({ reply }) => reply.ok({ runs: ++runs }));
|
|
208
|
+
|
|
209
|
+
await app.request('/products');
|
|
210
|
+
expect((await app.request('/products')).headers.get('x-cache')).toBe('HIT');
|
|
211
|
+
|
|
212
|
+
await products.invalidateTag('products');
|
|
213
|
+
expect((await app.request('/products')).headers.get('x-cache')).toBe('MISS');
|
|
214
|
+
|
|
215
|
+
await products.invalidate('/products'); // the path's tag: kept by `set` too
|
|
216
|
+
expect((await app.request('/products')).headers.get('x-cache')).toBe('MISS');
|
|
217
|
+
expect(runs).toBe(3);
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## When the store cannot answer
|
|
222
|
+
|
|
223
|
+
A store that throws or rejects costs the cache, not the response:
|
|
224
|
+
|
|
225
|
+
| Store call | Fails while | Then |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| `get` | a request is looked up | a miss: the route runs, `X-Cache: MISS` |
|
|
228
|
+
| `set` | a response is kept | nothing is kept; the response is answered |
|
|
229
|
+
| `deleteTag` | `invalidate` or `invalidateTag` | the call rejects with the store's error |
|
|
230
|
+
|
|
231
|
+
The first two are logged with `console.error` once per outage — the first
|
|
232
|
+
failure, then nothing until the store has answered again — and the request
|
|
233
|
+
goes on. An invalidation rejects instead: the code that changed
|
|
234
|
+
the data should know the old responses may still be served.
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { expect, test } from 'bun:test';
|
|
238
|
+
import { alxia } from '@alxia/core';
|
|
239
|
+
import { cache, MemoryCacheStore } from '@alxia/cache';
|
|
240
|
+
|
|
241
|
+
test('a store that cannot answer: the route answers', async () => {
|
|
242
|
+
const broken = new MemoryCacheStore();
|
|
243
|
+
broken.get = () => {
|
|
244
|
+
throw new Error('store down');
|
|
245
|
+
};
|
|
246
|
+
broken.set = () => Promise.reject(new Error('store down'));
|
|
247
|
+
broken.deleteTag = () => Promise.reject(new Error('store down'));
|
|
248
|
+
|
|
249
|
+
const products = cache({ ttl: 60, store: broken });
|
|
250
|
+
const app = alxia()
|
|
251
|
+
.use(products)
|
|
252
|
+
.get('/products', ({ reply }) => reply.ok([]));
|
|
253
|
+
|
|
254
|
+
const response = await app.request('/products'); // logs "store down" once
|
|
255
|
+
expect(response.status).toBe(200);
|
|
256
|
+
expect(response.headers.get('x-cache')).toBe('MISS');
|
|
257
|
+
await expect(products.invalidate('/products')).rejects.toThrow('store down');
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
A write that must answer even when the store is down catches the
|
|
262
|
+
invalidation, and keeps `ttl` short, since the old responses stay until
|
|
263
|
+
they expire:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
await products.invalidateTag('products').catch((error) => console.error(error));
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## See also
|
|
270
|
+
|
|
271
|
+
- [Caching responses](caching.md): the options that decide freshness.
|
|
272
|
+
- [Invalidation](invalidation.md): `invalidate`, `invalidateTag`, and
|
|
273
|
+
sharing one store between caches.
|