@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,67 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/cache` gives an app, and what is coming. This page is a
4
+ direction, not a commitment: the version something shipped in is the only
5
+ number on it. Every release, with each change it made, is in
6
+ [`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/cache/CHANGELOG.md).
7
+
8
+ ## Now
9
+
10
+ Nothing scheduled yet.
11
+
12
+ ## Next
13
+
14
+ Nothing scheduled yet.
15
+
16
+ ## Later
17
+
18
+ Nothing scheduled yet.
19
+
20
+ ## Not planned
21
+
22
+ - **A runtime dependency.** `@alxia/cache` installs nothing beside itself
23
+ and its `@alxia/core` and `typescript` peers: it is built on Bun's and the
24
+ web platform's own APIs, so adding it to an app adds no package to audit
25
+ or update.
26
+ - **A Redis store in this package.** This package defines the store's
27
+ contract and ships the memory store; sharing responses across processes
28
+ is `@alxia/redis`'s `redisCacheStore`, which answers the same contract, so
29
+ an app that runs one process installs no Redis client. The plugin never
30
+ knows which store it was given.
31
+
32
+ ## Shipped
33
+
34
+ ### 0.1.0
35
+
36
+ - **Response caching as a plugin.** `use(cache({ ttl }))` answers the `GET`
37
+ and `HEAD` requests of every route declared after it from a store while
38
+ they are fresh, and from the route otherwise, saying `X-Cache: HIT` or
39
+ `MISS` and `Age`.
40
+ - **Stale while revalidate.** With `staleWhileRevalidate`, an expired
41
+ response is still served at once, as `X-Cache: STALE`, while one request
42
+ refreshes it behind: no client waits for a slow route.
43
+ - **One run for many misses.** Concurrent requests for a missing response
44
+ wait for one run of the route, not one each, when its response is kept.
45
+ - **Only what may be shared.** A response is kept only with a status in
46
+ `statuses` (`200` by default), and never when it says
47
+ `Cache-Control: private` or `no-store`, sets a cookie, streams events, or
48
+ its route calls `cache.skip()`.
49
+ - **ETags and 304s.** Every kept response gets a weak `ETag` from its body
50
+ when the route set none, and a client whose copy is current gets a 304.
51
+ - **Keys your way.** The path and query by default; `vary` adds request
52
+ headers to the key and to `Vary`; `key` replaces it, and `undefined`
53
+ leaves a request uncached. `defaultKey` builds the default one.
54
+ `cache<{ user: User }>(…)` types `key` and `tags` with what an earlier
55
+ plugin adds, and an app that does not give it cannot use the cache.
56
+ - **Invalidation.** `invalidate(path)` forgets every response kept for a
57
+ path, whatever its key, and
58
+ `invalidateTag(tag)` every response tagged by the plugin's `tags` or by
59
+ its route's `cache.tag()`.
60
+ - **A store that cannot answer costs the cache, not the response.** A
61
+ lookup that fails is a miss and a keep that fails keeps nothing, logged
62
+ once per outage; an invalidation rejects, so the code that changed the
63
+ data knows.
64
+ - **A store contract.** `CacheStore` is four methods; `MemoryCacheStore`
65
+ keeps the least recently read responses of one process, within
66
+ `maxEntries` and `maxBytes`, and `@alxia/redis`'s `redisCacheStore` shares
67
+ them across processes.
@@ -0,0 +1,491 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by the text you see: a TypeScript error, or an
4
+ exception in the log. `@alxia/cache` throws nothing of its own; most of what
5
+ goes wrong prints nothing at all, and is under [Traps](#traps), by symptom.
6
+
7
+ **Types**
8
+
9
+ - [`Argument of type '{}' is not assignable to parameter of type 'CacheOptions<Empty>'`](#argument-of-type--is-not-assignable-to-parameter-of-type-cacheoptionsempty)
10
+ - [`Property 'cache' does not exist on type 'Context<…>'`](#property-cache-does-not-exist-on-type-context)
11
+ - [`Property '…' does not exist on type 'CacheControls'`](#property--does-not-exist-on-type-cachecontrols)
12
+ - [`Property 'user' does not exist on type 'BaseContext & Empty'`](#property-user-does-not-exist-on-type-basecontext--empty)
13
+ - [`Type '(…) => Promise<string>' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`](#type---promisestring-is-not-assignable-to-type-ctx-basecontext--empty--string--undefined)
14
+ - [`Type '(…) => string | null' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`](#type---string--null-is-not-assignable-to-type-ctx-basecontext--empty--string--undefined)
15
+ - [`Type '() => string' is not assignable to type '(ctx: BaseContext & Empty) => readonly string[]'`](#type---string-is-not-assignable-to-type-ctx-basecontext--empty--readonly-string)
16
+ - [`Type 'string' is not assignable to type 'readonly string[]'`](#type-string-is-not-assignable-to-type-readonly-string)
17
+
18
+ **Writing a store**
19
+
20
+ - [`Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'`](#type-null-is-not-assignable-to-type-cachedresponse--promisecachedresponse--undefined--undefined)
21
+ - [`Property 'deleteTag' is missing in type '{ … }' but required in type 'CacheStore'`](#property-deletetag-is-missing-in-type----but-required-in-type-cachestore)
22
+ - [`Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'`](#type-string-is-not-assignable-to-type-uint8arrayarraybufferlike)
23
+
24
+ **Runtime**
25
+
26
+ - [`500 {"error":"internal"}` from a route that invalidates, with the store's error in the log](#500-errorinternal-from-a-route-that-invalidates-with-the-stores-error-in-the-log)
27
+ - [The route's error is logged, yet the client got a `200` with `X-Cache: STALE`](#the-routes-error-is-logged-yet-the-client-got-a-200-with-x-cache-stale)
28
+
29
+ **Traps**
30
+
31
+ - [No `X-Cache` header, and the route runs every time](#no-x-cache-header-and-the-route-runs-every-time)
32
+ - [`X-Cache: MISS` on every request](#x-cache-miss-on-every-request)
33
+ - [Hits with curl, misses in a browser](#hits-with-curl-misses-in-a-browser)
34
+ - [A client that sent no `Accept-Encoding` gets gzip bytes](#a-client-that-sent-no-accept-encoding-gets-gzip-bytes)
35
+ - [One visitor sees another visitor's page](#one-visitor-sees-another-visitors-page)
36
+ - [Old data after a write](#old-data-after-a-write)
37
+ - [A hard reload shows new data, a plain reload the old](#a-hard-reload-shows-new-data-a-plain-reload-the-old)
38
+ - [Responses are kept for hours](#responses-are-kept-for-hours)
39
+
40
+ ## Types
41
+
42
+ ### `Argument of type '{}' is not assignable to parameter of type 'CacheOptions<Empty>'`
43
+
44
+ **When:** calling `cache()` without a `ttl`.
45
+
46
+ ```text
47
+ error TS2345: Argument of type '{}' is not assignable to parameter of type 'CacheOptions<Empty>'.
48
+ Property 'ttl' is missing in type '{}' but required in type 'CacheOptions<Empty>'.
49
+ ```
50
+
51
+ **Why:** `ttl` has no default: how long a response may be served again is
52
+ a decision only the app can make.
53
+
54
+ **Fix:** give it, in seconds:
55
+
56
+ ```ts
57
+ cache({ ttl: 60 });
58
+ ```
59
+
60
+ ### `Property 'cache' does not exist on type 'Context<…>'`
61
+
62
+ **When:** a route reads `ctx.cache` — to call `tag` or `skip` — and is
63
+ declared before `use(cache(…))`.
64
+
65
+ ```text
66
+ error TS2339: Property 'cache' does not exist on type 'Context<Empty, "/x", Empty>'.
67
+ ```
68
+
69
+ **Why:** the plugin is a route hook: it applies to, and adds `cache` to, the
70
+ routes declared after it. The route before it is not cached either.
71
+
72
+ **Fix:** declare the route after the plugin:
73
+
74
+ ```ts
75
+ alxia()
76
+ .use(cache({ ttl: 60 }))
77
+ .get('/products/:id', ({ params, cache, reply }) => {
78
+ cache.tag(`product:${params.id}`);
79
+ return reply(200, { id: params.id });
80
+ });
81
+ ```
82
+
83
+ ### `Property '…' does not exist on type 'CacheControls'`
84
+
85
+ **When:** a route reads from `ctx.cache` something other than `tag` or
86
+ `skip` — most often `@alxia/redis`'s typed caches, which `redis()` puts in
87
+ the context as `caches`.
88
+
89
+ ```text
90
+ error TS2339: Property 'users' does not exist on type 'CacheControls'.
91
+ ```
92
+
93
+ At run time, without a typecheck, the route answers a 500 with
94
+ `TypeError: undefined is not an object (evaluating 'cache.users.remember')`.
95
+
96
+ **Why:** `ctx.cache` is this plugin's controls, `{ tag, skip }`, and
97
+ nothing else.
98
+
99
+ **Fix:** read the other plugin's name for it:
100
+
101
+ ```ts
102
+ alxia()
103
+ .use(redis(connection.client, { caches: { users } }))
104
+ .use(cache({ ttl: 60 }))
105
+ .get('/users/:id', async ({ caches, cache, params, reply }) => {
106
+ cache.tag(`user:${params.id}`);
107
+ return reply.ok(await caches.users.remember(params.id, () => loadUser(params.id)));
108
+ });
109
+ ```
110
+
111
+ ### `Property 'user' does not exist on type 'BaseContext & Empty'`
112
+
113
+ **When:** a `key` or `tags` function reads something an earlier `derive`,
114
+ `decorate` or plugin added to the context, and `cache` is not told about it.
115
+
116
+ ```text
117
+ error TS2339: Property 'user' does not exist on type 'BaseContext & Empty'.
118
+ ```
119
+
120
+ **Why:** `key` and `tags` are typed with `BaseContext` — `request`, `url`,
121
+ `ip`, `server`, `route`, `pathParams` — plus what you name as `cache`'s type
122
+ argument, and nothing else. `cache` is built before it is used, so it
123
+ cannot see the app it will be used on.
124
+
125
+ **Fix:** name what `key` and `tags` read. The app that uses the cache must
126
+ then give it, before the cache:
127
+
128
+ ```ts
129
+ const perTenant = cache<{ user: { tenantId: string } }>({
130
+ ttl: 60,
131
+ key: ({ user, url }) => `${user.tenantId}:${url.pathname}${url.search}`,
132
+ tags: ({ user }) => [`tenant:${user.tenantId}`],
133
+ });
134
+
135
+ const auth = alxia().derive(({ request }) => ({
136
+ user: { tenantId: request.headers.get('x-tenant') ?? 'public' },
137
+ }));
138
+
139
+ alxia().use(auth).use(perTenant); // auth derives user
140
+ ```
141
+
142
+ On an app that does not give `user`, `use(perTenant)` is a compile error:
143
+ [`the plugin reads "user", which this app's context does not give`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first),
144
+ or [`… gives with another type`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-gives-with-another-type)
145
+ when its `user` is not `{ tenantId: string }`.
146
+ See [Reading the app's context](guide/keys-and-vary.md#reading-the-apps-context).
147
+
148
+ To tag by what only the route knows — the product it loaded — tag from the
149
+ route with `ctx.cache.tag(…)` instead
150
+ ([Invalidation](guide/invalidation.md#by-tag-invalidatetag)).
151
+
152
+ ### `Type '(…) => Promise<string>' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`
153
+
154
+ **When:** `key` is an `async` function.
155
+
156
+ ```text
157
+ error TS2322: Type '({ url }: BaseContext & Empty) => Promise<string>' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'.
158
+ Type 'Promise<string>' is not assignable to type 'string'.
159
+ ```
160
+
161
+ **Why:** the key is computed on every request before the store is asked;
162
+ it is synchronous.
163
+
164
+ **Fix:** compute the key from the request alone. A route whose answer
165
+ needs a lookup — a session, a tenant from a database — is personal:
166
+ declare it before the cache, or answer `Cache-Control: private`
167
+ ([Personal responses](guide/keys-and-vary.md#personal-responses)).
168
+
169
+ ```ts
170
+ cache({ ttl: 60, key: ({ url }) => `${url.pathname}${url.search}` });
171
+ ```
172
+
173
+ ### `Type '(…) => string | null' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'`
174
+
175
+ **When:** `key` returns `request.headers.get(…)`, or anything else that may
176
+ be `null`.
177
+
178
+ ```text
179
+ error TS2322: Type '({ request }: BaseContext & Empty) => string | null' is not assignable to type '(ctx: BaseContext & Empty) => string | undefined'.
180
+ Type 'string | null' is not assignable to type 'string | undefined'.
181
+ Type 'null' is not assignable to type 'string | undefined'.
182
+ ```
183
+
184
+ **Why:** `undefined` is the key's "do not cache"; `null` is not accepted for
185
+ it.
186
+
187
+ **Fix:**
188
+
189
+ ```ts
190
+ cache({ ttl: 60, key: ({ request }) => request.headers.get('x-tenant') ?? undefined });
191
+ ```
192
+
193
+ ### `Type '() => string' is not assignable to type '(ctx: BaseContext & Empty) => readonly string[]'`
194
+
195
+ **When:** `tags` returns one tag instead of a list.
196
+
197
+ ```text
198
+ error TS2322: Type '() => string' is not assignable to type '(ctx: BaseContext & Empty) => readonly string[]'.
199
+ Type 'string' is not assignable to type 'readonly string[]'.
200
+ ```
201
+
202
+ **Fix:**
203
+
204
+ ```ts
205
+ cache({ ttl: 60, tags: () => ['products'] });
206
+ ```
207
+
208
+ ### `Type 'string' is not assignable to type 'readonly string[]'`
209
+
210
+ **When:** `vary` or `statuses` is given one value instead of a list — or
211
+ the line above, for `tags`.
212
+
213
+ ```text
214
+ error TS2322: Type 'string' is not assignable to type 'readonly string[]'.
215
+ ```
216
+
217
+ **Fix:**
218
+
219
+ ```ts
220
+ cache({ ttl: 60, vary: ['accept-language'], statuses: [200] });
221
+ ```
222
+
223
+ ## Writing a store
224
+
225
+ ### `Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'`
226
+
227
+ **When:** a store's `get` returns `null` for a key it does not hold — as
228
+ most key-value clients do.
229
+
230
+ ```text
231
+ error TS2322: Type '(_key: string) => CachedResponse | null' is not assignable to type '(key: string) => CachedResponse | Promise<CachedResponse | undefined> | undefined'.
232
+ Type 'CachedResponse | null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'.
233
+ Type 'null' is not assignable to type 'CachedResponse | Promise<CachedResponse | undefined> | undefined'.
234
+ ```
235
+
236
+ **Why:** the plugin treats `undefined` as a miss; it would read `null` as a
237
+ response.
238
+
239
+ **Fix:** turn the client's `null` into `undefined`:
240
+
241
+ ```ts
242
+ async get(key) {
243
+ const raw = await client.get(key); // string | null
244
+ return raw === null ? undefined : decode(raw);
245
+ },
246
+ ```
247
+
248
+ ### `Property 'deleteTag' is missing in type '{ … }' but required in type 'CacheStore'`
249
+
250
+ **When:** a store implements `get`, `set` and `delete` only.
251
+
252
+ ```text
253
+ error TS2741: Property 'deleteTag' is missing in type '{ get: () => undefined; set(): void; delete(): void; }' but required in type 'CacheStore'.
254
+ ```
255
+
256
+ **Why:** `invalidateTag` calls the store's `deleteTag`; every store must
257
+ answer it.
258
+
259
+ **Fix:** remember which keys each tag names in `set`, and delete them in
260
+ `deleteTag` — [Writing a store](guide/stores.md#writing-a-store) has a
261
+ complete one.
262
+
263
+ ### `Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'`
264
+
265
+ **When:** a store's `get` returns the body as it read it from a text
266
+ service — a string — instead of bytes.
267
+
268
+ ```text
269
+ error TS2322: Type 'string' is not assignable to type 'Uint8Array<ArrayBufferLike>'.
270
+ ```
271
+
272
+ **Why:** `CachedResponse.body` is the response's bytes, so a binary body
273
+ survives the round trip.
274
+
275
+ **Fix:** store it as base64, and decode it on the way back:
276
+
277
+ ```ts
278
+ const text = Buffer.from(value.body).toString('base64'); // in set
279
+ const body = new Uint8Array(Buffer.from(text, 'base64')); // in get
280
+ ```
281
+
282
+ ## Runtime
283
+
284
+ ### `500 {"error":"internal"}` from a route that invalidates, with the store's error in the log
285
+
286
+ **When:** a write calls `invalidate(path)` or `invalidateTag(tag)` while
287
+ the store cannot answer — a Redis that is down, a store of your own with a
288
+ bug — and does not catch it.
289
+
290
+ **Why:** a lookup or a keep that fails is only logged — the cached route
291
+ still answers, `X-Cache: MISS` — but an invalidation rejects, so the code
292
+ that changed the data learns that the old responses may still be served.
293
+
294
+ **Fix:** bring the store back. Where the write must succeed regardless,
295
+ catch the invalidation, and keep `ttl` short —
296
+ [When the store cannot answer](guide/stores.md#when-the-store-cannot-answer):
297
+
298
+ ```ts
299
+ await products.invalidateTag('products').catch((error) => console.error(error));
300
+ ```
301
+
302
+ ### The route's error is logged, yet the client got a `200` with `X-Cache: STALE`
303
+
304
+ **When:** `staleWhileRevalidate` is set, a response is stale, and the
305
+ route throws while refreshing it.
306
+
307
+ **Why:** the stale copy was already sent; the refresh runs behind it, and
308
+ its error has no request left to answer, so it is logged with
309
+ `console.error`. Each later stale request tries again, until
310
+ `ttl + staleWhileRevalidate` has passed; then the next request waits for
311
+ the route, and gets its 500.
312
+
313
+ **Fix:** none is needed — serving the last good response while the route
314
+ fails is what stale-while-revalidate is for. Fix the route; keep
315
+ `staleWhileRevalidate` as long as you are willing to serve old data.
316
+
317
+ ## Traps
318
+
319
+ ### No `X-Cache` header, and the route runs every time
320
+
321
+ **When:** the request is never looked up, or the response is never kept.
322
+
323
+ **Why:** one of these, from the most common:
324
+
325
+ | Cause | Fix |
326
+ | --- | --- |
327
+ | the route is declared before `use(cache(…))` | declare it after |
328
+ | the response sets a cookie — a session plugin that touches every response, say | move the routes that set it before the cache, or stop it setting a cookie on public pages |
329
+ | the response says `Cache-Control: private` or `no-store` | intended: it is personal |
330
+ | its status is not in `statuses` (`[200]`) | `statuses: [200, 404]` |
331
+ | it is `text/event-stream` | intended: a stream is never kept |
332
+ | the route called `cache.skip()` | intended |
333
+ | it arrived while a concurrent request for the same key was answered with something not kept, or failed | intended: it ran the route itself, and the next request is a miss |
334
+ | `key` returned `undefined` | intended: see [Personal responses](guide/keys-and-vary.md#personal-responses) |
335
+ | the method is not `GET` or `HEAD` | intended |
336
+ | `honorClientNoCache: true`, and the request said `Cache-Control: no-cache` | see [below](#a-hard-reload-shows-new-data-a-plain-reload-the-old) |
337
+ | `debugHeaders: false` | the cache works; it just does not say so |
338
+
339
+ ### `X-Cache: MISS` on every request
340
+
341
+ **When:** the response is computed, kept, and the next request misses
342
+ anyway.
343
+
344
+ **Why:** the store did not keep it, or the next request has another key:
345
+
346
+ - **The body is larger than the memory store's `maxBytes`** (64 MiB by
347
+ default): a body that would not fit is never kept.
348
+ - **The key changes every time**: a query parameter that is new on each
349
+ request (`?t=1712345678`), or a `vary` header whose value differs — see
350
+ [the next entry](#hits-with-curl-misses-in-a-browser).
351
+ - **A store of your own reads `keepFor` as seconds**: it is
352
+ **milliseconds**. A Redis `EX` given `keepFor` keeps a response 1 000 times
353
+ too long; `PX` is right, or `Math.ceil(keepFor / 1000)` for `EX`.
354
+ - **Each process has its own memory store**, and a load balancer spreads
355
+ requests across them: each process misses once.
356
+ - **The store cannot answer**: its first error of the outage is in the log,
357
+ and every request runs the route
358
+ ([When the store cannot answer](guide/stores.md#when-the-store-cannot-answer)).
359
+
360
+ **Fix:** for a large body, raise the limit:
361
+
362
+ ```ts
363
+ cache({ ttl: 60, store: new MemoryCacheStore({ maxBytes: 256 * 1024 * 1024 }) });
364
+ ```
365
+
366
+ For a changing query, key by what the route reads:
367
+
368
+ ```ts
369
+ cache({ ttl: 60, key: ({ url }) => `${url.pathname}?page=${url.searchParams.get('page') ?? '1'}` });
370
+ ```
371
+
372
+ ### Hits with curl, misses in a browser
373
+
374
+ **When:** `vary` names `accept-language`, `cookie`, `user-agent` or another
375
+ header a browser fills in.
376
+
377
+ **Why:** the key holds the header's **exact** value. curl sends no
378
+ `Accept-Language` and no `Cookie`, so every curl request shares one key and
379
+ hits. A browser sends its whole preference list —
380
+ `fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7` — and every cookie of the site, so
381
+ visitors rarely share a key.
382
+
383
+ **Fix:** key by what the route actually answers with, and keep `vary` for
384
+ the `Vary` header —
385
+ [A key of your own](guide/keys-and-vary.md#a-key-of-your-own):
386
+
387
+ ```ts
388
+ cache({
389
+ ttl: 60,
390
+ vary: ['accept-language'],
391
+ key: ({ url, request }) =>
392
+ `${url.pathname}${url.search}|${language(request.headers.get('accept-language'))}`,
393
+ });
394
+ ```
395
+
396
+ ### A client that sent no `Accept-Encoding` gets gzip bytes
397
+
398
+ **When:** the cache is in front of `app.static(…, { precompressed })`, or of
399
+ any route whose answer depends on a request header, and that header is not
400
+ in `vary`. The first client asked with `Accept-Encoding: gzip`; the next
401
+ one, without it, gets `Content-Encoding: gzip` with `X-Cache: HIT`.
402
+
403
+ **Why:** the default key is built from the path, the query and `vary`
404
+ alone, and a `key` of your own from what it reads. The response's own
405
+ `Vary` is passed on to the client but not read by the cache.
406
+
407
+ **Fix:** name the header in `vary`, or read it in your `key`:
408
+
409
+ ```ts
410
+ app.use(cache({ ttl: 60, vary: ['accept-encoding'] }))
411
+ .static('/assets', './public', { precompressed: ['br', 'gzip'] });
412
+ ```
413
+
414
+ ### One visitor sees another visitor's page
415
+
416
+ **When:** a route that answers by who is asking — a `Cookie`, an
417
+ `Authorization` header — is behind the cache with the default key, and its
418
+ response says nothing about it. curl, with no cookie, shows nothing wrong;
419
+ a signed-in browser does.
420
+
421
+ **Why:** the default key is the path and query only. The first visitor's
422
+ response is kept, and served to everyone after.
423
+
424
+ **Fix:** say the response is personal — it is then never kept, nor handed
425
+ to a concurrent request:
426
+
427
+ ```ts
428
+ app.get('/me', ({ reply }) =>
429
+ reply(200, { name: 'Grace' }, { headers: { 'cache-control': 'private' } }),
430
+ );
431
+ ```
432
+
433
+ Or declare the route before the cache, which also saves the lookup
434
+ ([Personal responses](guide/keys-and-vary.md#personal-responses)).
435
+
436
+ Then empty what was already kept: `await products.invalidateTag(…)`, or
437
+ restart a process that uses the memory store.
438
+
439
+ ### Old data after a write
440
+
441
+ **When:** a write calls `invalidate(path)` or `invalidateTag(tag)`, and a
442
+ read still answers the old response.
443
+
444
+ **Why:** the invalidation did not reach the key, or the store:
445
+
446
+ - `invalidate(path)` forgets the responses of that **exact** path and
447
+ query, prefix included: `/products` is not `/products?page=2`, nor
448
+ `/api/products`.
449
+ - A store of your own does not remember each response's `tags` in `set`:
450
+ neither `invalidate` nor `invalidateTag` reaches anything.
451
+ - Two `cache()` without a `store` have **two memory stores**; invalidating
452
+ through one does not touch the other.
453
+ - Several processes with the **memory store** each keep their own copy;
454
+ the write's process is the only one emptied.
455
+
456
+ **Fix:** invalidate by tag — it reaches every query — on a store every
457
+ cache and process shares:
458
+
459
+ ```ts
460
+ const store = redisCacheStore(connection.client, { name: 'shop' });
461
+ const products = cache({ ttl: 60, store, tags: () => ['products'] });
462
+
463
+ await products.invalidateTag('products');
464
+ ```
465
+
466
+ [Invalidation](guide/invalidation.md) covers each case.
467
+
468
+ ### A hard reload shows new data, a plain reload the old
469
+
470
+ **When:** `honorClientNoCache: true`.
471
+
472
+ **Why:** a browser sends `Cache-Control: no-cache` on a hard reload
473
+ (Shift-reload) and not on a plain one. With `honorClientNoCache`, that
474
+ request runs the route — but its fresh response does **not** replace the
475
+ one kept, so the next plain request is served the old one.
476
+
477
+ **Fix:** leave `honorClientNoCache` off (the default) and invalidate on
478
+ write; or keep it, knowing it is a way for one client to bypass the cache,
479
+ not to refresh it.
480
+
481
+ ### Responses are kept for hours
482
+
483
+ **When:** `ttl` or `staleWhileRevalidate` was given in milliseconds.
484
+
485
+ **Why:** both are **seconds**: `ttl: 60_000` is nearly 17 hours.
486
+
487
+ **Fix:**
488
+
489
+ ```ts
490
+ cache({ ttl: 60, staleWhileRevalidate: 300 }); // one minute fresh, five more stale
491
+ ```
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
- "name": "@alxia/cache",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@alxia/cache",
3
+ "version": "0.1.0",
4
+ "description": "HTTP response caching for alxia: TTL, stale-while-revalidate, one load for concurrent misses, tags, ETags — in memory, or in Redis with @alxia/redis",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/softistx/alxia.git",
27
+ "directory": "packages/cache"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run ../../build.ts",
35
+ "test": "bun test src",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "alxia": {
39
+ "entrypoints": [
40
+ "src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@alxia/client": "^0.1.0",
45
+ "@alxia/core": "^0.1.0",
46
+ "@types/bun": "^1.4.2"
47
+ },
48
+ "peerDependencies": {
49
+ "@alxia/core": "^0.1.0",
50
+ "typescript": "^6.0.3 || ^7.0.0"
51
+ }
52
+ }