@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.
@@ -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.