@nxtedition/nxt-undici 7.4.1 → 7.4.3

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.
@@ -2,18 +2,43 @@ import { DatabaseSync } from 'node:sqlite'
2
2
  import { parseRangeHeader, getFastNow } from './utils.js'
3
3
 
4
4
  // Bump version when the URL key format or schema changes to invalidate old caches.
5
- const VERSION = 10
6
-
7
- /** @typedef {{ gc: () => void, clear: () => void } } */
5
+ const VERSION = 11
6
+
7
+ // Registry of live stores so process-level broadcasts (nxt:offPeak,
8
+ // nxt:clearCache) can reach them. Stores are held via WeakRef so that a store
9
+ // dropped without close() is not pinned forever (together with its open
10
+ // DatabaseSync handle and page cache) — GC can still collect it. close()
11
+ // remains the recommended, deterministic cleanup path.
12
+ /** @type {Set<WeakRef<SqliteCacheStore>>} */
8
13
  const stores = new Set()
9
14
 
15
+ // Removes a collected store's WeakRef entry once the store has been GC'd.
16
+ // The callback runs after the store is already gone, so it must not (and
17
+ // cannot) touch the store or its DatabaseSync — the native handle has its own
18
+ // lifecycle and is released by GC/process exit. This only drops bookkeeping.
19
+ const registry = new FinalizationRegistry((ref) => {
20
+ stores.delete(ref)
21
+ })
22
+
23
+ /**
24
+ * @param {(store: SqliteCacheStore) => void} fn
25
+ */
26
+ function forEachStore(fn) {
27
+ for (const ref of stores) {
28
+ const store = ref.deref()
29
+ if (store === undefined) {
30
+ stores.delete(ref)
31
+ } else {
32
+ fn(store)
33
+ }
34
+ }
35
+ }
36
+
10
37
  {
11
38
  const offPeakBC = new BroadcastChannel('nxt:offPeak')
12
39
  offPeakBC.unref()
13
40
  offPeakBC.onmessage = () => {
14
- for (const store of stores) {
15
- store.gc()
16
- }
41
+ forEachStore((store) => store.gc())
17
42
  }
18
43
  }
19
44
 
@@ -21,15 +46,14 @@ const stores = new Set()
21
46
  const clearCacheBC = new BroadcastChannel('nxt:clearCache')
22
47
  clearCacheBC.unref()
23
48
  clearCacheBC.onmessage = () => {
24
- for (const store of stores) {
25
- store.clear()
26
- }
49
+ forEachStore((store) => store.clear())
27
50
  }
28
51
  }
29
52
 
30
53
  /**
31
- * @typedef {import('undici-types/cache-interceptor.d.ts').default.CacheStore} CacheStore
32
- * @implements {CacheStore}
54
+ * Synchronous get/set/delete cache store backed by node:sqlite. Note: this is
55
+ * NOT undici's stream-based CacheStore interface (createWriteStream); the
56
+ * cache interceptor in this package drives it directly.
33
57
  *
34
58
  * @typedef {{
35
59
  * id: Readonly<number>,
@@ -43,6 +67,7 @@ const stores = new Set()
43
67
  * etag?: string
44
68
  * cacheControlDirectives?: string
45
69
  * cachedAt: number
70
+ * staleAt: number
46
71
  * deleteAt: number
47
72
  * }} SqliteStoreValue
48
73
  */
@@ -77,10 +102,30 @@ export class SqliteCacheStore {
77
102
  */
78
103
  #evictQuery
79
104
 
105
+ /**
106
+ * @type {import('node:sqlite').StatementSync}
107
+ */
108
+ #deleteByUrlQuery
109
+
110
+ /**
111
+ * @type {import('node:sqlite').StatementSync}
112
+ */
113
+ #supersedeFullQuery
114
+
115
+ /**
116
+ * @type {import('node:sqlite').StatementSync}
117
+ */
118
+ #supersede206Query
119
+
80
120
  #insertBatch = []
81
121
  #insertSeq = 0
82
122
  #closed = false
83
123
 
124
+ /**
125
+ * @type {WeakRef<SqliteCacheStore>}
126
+ */
127
+ #ref
128
+
84
129
  /**
85
130
  * @param {import('undici-types/cache-interceptor.d.ts').default.SqliteCacheStoreOpts & { maxSize?: number } | undefined} opts
86
131
  */
@@ -108,6 +153,7 @@ export class SqliteCacheStore {
108
153
  body BLOB NULL,
109
154
  start INTEGER NOT NULL,
110
155
  end INTEGER NOT NULL,
156
+ staleAt INTEGER NOT NULL,
111
157
  deleteAt INTEGER NOT NULL,
112
158
  statusCode INTEGER NOT NULL,
113
159
  statusMessage TEXT NOT NULL,
@@ -120,14 +166,65 @@ export class SqliteCacheStore {
120
166
 
121
167
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_getValuesQuery ON cacheInterceptorV${VERSION}(url, method, start, deleteAt);
122
168
  CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_deleteExpiredValuesQuery ON cacheInterceptorV${VERSION}(deleteAt);
169
+ -- Covers the supersede-on-flush DELETEs (#supersedeFullQuery /
170
+ -- #supersede206Query), whose predicate is url+method+vary (+statusCode,
171
+ -- and start/end for 206) — so a re-cache supersedes via an index seek
172
+ -- instead of scanning every row for a hot url+method with many Vary
173
+ -- variants or partial windows.
174
+ CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV${VERSION}_supersedeQuery ON cacheInterceptorV${VERSION}(url, method, vary, statusCode, start, end);
123
175
  `)
124
176
 
177
+ // Drop tables left behind by previous schema versions. gc(), clear() and
178
+ // the SQLITE_FULL eviction only ever touch the current version's table, so
179
+ // after a VERSION bump the old table's pages would otherwise stay allocated
180
+ // to its b-tree forever while max_page_count caps the whole file — new
181
+ // inserts hit SQLITE_FULL almost immediately and eviction frees nothing.
182
+ // Dropping returns the pages to SQLite's freelist, which subsequent inserts
183
+ // reuse, so no VACUUM is needed. SQLite drops the table's indexes and its
184
+ // sqlite_sequence row along with it.
185
+ try {
186
+ // LIKE is only a coarse pre-filter; the regexp restricts matches to
187
+ // digit-only version suffixes so user tables sharing the prefix (e.g.
188
+ // "cacheInterceptorVBackup") in a shared database file are never dropped.
189
+ const staleTables = this.#db
190
+ .prepare(
191
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'cacheInterceptorV%'`,
192
+ )
193
+ .all()
194
+ .filter(
195
+ ({ name }) =>
196
+ /^cacheInterceptorV\d+$/.test(name) && name !== `cacheInterceptorV${VERSION}`,
197
+ )
198
+ if (staleTables.length > 0) {
199
+ this.#db.exec('BEGIN')
200
+ try {
201
+ for (const { name } of staleTables) {
202
+ // name comes from sqlite_master; quote it defensively anyway.
203
+ this.#db.exec(`DROP TABLE IF EXISTS "${String(name).replaceAll('"', '""')}"`)
204
+ }
205
+ this.#db.exec('COMMIT')
206
+ } catch (err) {
207
+ try {
208
+ this.#db.exec('ROLLBACK')
209
+ } catch {
210
+ // already rolled back automatically
211
+ }
212
+ throw err
213
+ }
214
+ }
215
+ } catch (err) {
216
+ // A failed cleanup must not brick construction — the current version's
217
+ // table still works, the stale one just keeps occupying pages.
218
+ process.emitWarning(err)
219
+ }
220
+
125
221
  this.#getValuesQuery = this.#db.prepare(`
126
222
  SELECT
127
223
  id,
128
224
  body,
129
225
  start,
130
226
  end,
227
+ staleAt,
131
228
  deleteAt,
132
229
  statusCode,
133
230
  statusMessage,
@@ -143,7 +240,7 @@ export class SqliteCacheStore {
143
240
  AND start <= ?
144
241
  AND deleteAt > ?
145
242
  ORDER BY
146
- cachedAt DESC, id DESC
243
+ id DESC
147
244
  `)
148
245
 
149
246
  this.#insertValueQuery = this.#db.prepare(`
@@ -153,6 +250,7 @@ export class SqliteCacheStore {
153
250
  body,
154
251
  start,
155
252
  end,
253
+ staleAt,
156
254
  deleteAt,
157
255
  statusCode,
158
256
  statusMessage,
@@ -161,7 +259,7 @@ export class SqliteCacheStore {
161
259
  cacheControlDirectives,
162
260
  vary,
163
261
  cachedAt
164
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
262
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
165
263
  `)
166
264
 
167
265
  this.#deleteExpiredValuesQuery = this.#db.prepare(
@@ -174,7 +272,40 @@ export class SqliteCacheStore {
174
272
  `DELETE FROM cacheInterceptorV${VERSION} WHERE id IN (SELECT id FROM cacheInterceptorV${VERSION} ORDER BY deleteAt ASC LIMIT ?)`,
175
273
  )
176
274
 
177
- stores.add(this)
275
+ // RFC 9111 §4.4 invalidation: a successful unsafe request invalidates every
276
+ // stored response for the URI, across methods and vary variants.
277
+ this.#deleteByUrlQuery = this.#db.prepare(
278
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ?`,
279
+ )
280
+
281
+ // Supersede queries: a newly flushed row replaces prior rows for the same
282
+ // representation so hot keys don't accumulate dead duplicates until gc.
283
+ // Receipt order (insertion order), NOT cachedAt, decides who wins — the
284
+ // same semantics as upstream undici's update-in-place set(). cachedAt is
285
+ // backdated by the corrected initial age (RFC 9111 §4.2.3), so a
286
+ // replacement fetched through a relay advertising a large Age (or a 304
287
+ // freshening with a skewed origin Date) can carry an OLDER cachedAt than
288
+ // the stale row it replaces; keying supersede or the read sort on
289
+ // cachedAt would let the stale row win every read, forever. `vary IS ?`
290
+ // is NULL-safe and compares the serialized-JSON text, deterministic
291
+ // because both sides are produced by the same CacheHandler code path
292
+ // (key order = Vary header order). A new full (non-206) representation
293
+ // supersedes all prior full ones regardless of byte window; a 206 only
294
+ // supersedes the exact same window so distinct partials coexist, and
295
+ // never a 200 row (matchesValue routes range and non-range requests to
296
+ // different rows).
297
+ this.#supersedeFullQuery = this.#db.prepare(
298
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode != 206`,
299
+ )
300
+ this.#supersede206Query = this.#db.prepare(
301
+ `DELETE FROM cacheInterceptorV${VERSION} WHERE url = ? AND method = ? AND vary IS ? AND statusCode = 206 AND start = ? AND end = ?`,
302
+ )
303
+
304
+ this.#ref = new WeakRef(this)
305
+ stores.add(this.#ref)
306
+ // The store itself doubles as the unregister token so close() can drop
307
+ // the registry entry deterministically.
308
+ registry.register(this, this.#ref, this)
178
309
  }
179
310
 
180
311
  gc() {
@@ -222,7 +353,8 @@ export class SqliteCacheStore {
222
353
  }
223
354
 
224
355
  close() {
225
- stores.delete(this)
356
+ stores.delete(this.#ref)
357
+ registry.unregister(this)
226
358
  // Drain the entire batch synchronously before closing. A plain #flush()
227
359
  // only commits one time-budget slice and reschedules the rest via
228
360
  // setImmediate; that deferred flush would see #closed and discard the
@@ -294,7 +426,7 @@ export class SqliteCacheStore {
294
426
  setImmediate(this.#flush)
295
427
  }
296
428
 
297
- this.#insertBatch.push({
429
+ const entry = {
298
430
  // Monotonic per-store sequence used only to break cachedAt ties in
299
431
  // #findValue (newest write wins). Not persisted — #flush ignores it.
300
432
  seq: this.#insertSeq++,
@@ -303,6 +435,9 @@ export class SqliteCacheStore {
303
435
  body,
304
436
  start: value.start,
305
437
  end: value.end,
438
+ // staleAt column is NOT NULL; default to deleteAt when the caller omits
439
+ // it (pre-v11 value shape) — matches this package's staleAt === deleteAt.
440
+ staleAt: value.staleAt ?? value.deleteAt,
306
441
  deleteAt: value.deleteAt,
307
442
  statusCode: value.statusCode,
308
443
  statusMessage: value.statusMessage,
@@ -313,7 +448,56 @@ export class SqliteCacheStore {
313
448
  : null,
314
449
  vary: value.vary ? JSON.stringify(value.vary) : null,
315
450
  cachedAt: value.cachedAt,
316
- })
451
+ }
452
+
453
+ // Coalesce within the pending batch: a stampede of identical misses would
454
+ // otherwise commit N identical rows. Receipt order wins (see the
455
+ // #supersede* query comments): the entry being added supersedes every
456
+ // pending entry for the same representation.
457
+ for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
458
+ const other = this.#insertBatch[i]
459
+ if (
460
+ other.url === entry.url &&
461
+ other.method === entry.method &&
462
+ other.vary === entry.vary &&
463
+ (entry.statusCode !== 206
464
+ ? other.statusCode !== 206
465
+ : other.statusCode === 206 && other.start === entry.start && other.end === entry.end)
466
+ ) {
467
+ this.#insertBatch.splice(i, 1)
468
+ }
469
+ }
470
+
471
+ this.#insertBatch.push(entry)
472
+ }
473
+
474
+ /**
475
+ * Invalidates every stored response for the key's URI (RFC 9111 §4.4) —
476
+ * across methods and vary variants. Pending batched inserts for the URI are
477
+ * dropped as well: a batch entry flushed after the DELETE would otherwise
478
+ * resurrect the invalidated response. Note this only covers THIS process's
479
+ * batch — with a shared on-disk store, another process's in-flight batch
480
+ * can still transiently resurrect an entry (bounded by its ~10ms flush
481
+ * slice); cross-process invalidation is inherently best-effort.
482
+ *
483
+ * @param {import('undici-types/cache-interceptor.d.ts').default.CacheKey} key
484
+ */
485
+ delete(key) {
486
+ assertCacheKey(key)
487
+
488
+ if (this.#closed) {
489
+ return
490
+ }
491
+
492
+ const url = makeValueUrl(key)
493
+
494
+ for (let i = this.#insertBatch.length - 1; i >= 0; i--) {
495
+ if (this.#insertBatch[i].url === url) {
496
+ this.#insertBatch.splice(i, 1)
497
+ }
498
+ }
499
+
500
+ this.#deleteByUrlQuery.run(url)
317
501
  }
318
502
 
319
503
  #flush = (final = false) => {
@@ -335,6 +519,7 @@ export class SqliteCacheStore {
335
519
  body,
336
520
  start,
337
521
  end,
522
+ staleAt,
338
523
  deleteAt,
339
524
  statusCode,
340
525
  statusMessage,
@@ -344,12 +529,21 @@ export class SqliteCacheStore {
344
529
  vary,
345
530
  cachedAt,
346
531
  } = this.#insertBatch[n++]
532
+ // Supersede prior rows for the same representation (see the
533
+ // #supersede* query comments) inside the same transaction, so a
534
+ // re-cache replaces instead of accumulating.
535
+ if (statusCode === 206) {
536
+ this.#supersede206Query.run(url, method, vary, start, end)
537
+ } else {
538
+ this.#supersedeFullQuery.run(url, method, vary)
539
+ }
347
540
  this.#insertValueQuery.run(
348
541
  url,
349
542
  method,
350
543
  body,
351
544
  start,
352
545
  end,
546
+ staleAt,
353
547
  deleteAt,
354
548
  statusCode,
355
549
  statusMessage,
@@ -457,16 +651,14 @@ export class SqliteCacheStore {
457
651
  return undefined
458
652
  }
459
653
 
460
- // Newest representation wins. cachedAt is millisecond-resolution, so a
461
- // re-cache within the same millisecond produces a tie; break it
462
- // deterministically toward the freshest write: pending batch entries
463
- // (tagged with a monotonic seq) are always newer than any flushed DB row,
464
- // and within each source a higher seq/id wins.
654
+ // Most recently WRITTEN representation wins receipt order, not
655
+ // cachedAt (see the #supersede* query comments: cachedAt is backdated by
656
+ // the corrected initial age, so a fresher write can carry an older
657
+ // cachedAt). Pending batch entries (tagged with a monotonic seq) are
658
+ // always newer than any flushed DB row, and within each source a higher
659
+ // seq/id wins.
465
660
  if (values.length > 1) {
466
661
  values.sort((a, b) => {
467
- if (a.cachedAt !== b.cachedAt) {
468
- return b.cachedAt - a.cachedAt
469
- }
470
662
  const aBatch = a.seq != null
471
663
  const bBatch = b.seq != null
472
664
  if (aBatch !== bBatch) {
@@ -580,6 +772,7 @@ function makeResult(value) {
580
772
  ? JSON.parse(value.cacheControlDirectives)
581
773
  : undefined,
582
774
  cachedAt: value.cachedAt,
775
+ staleAt: value.staleAt,
583
776
  deleteAt: value.deleteAt,
584
777
  }
585
778
  }
@@ -627,6 +820,16 @@ function assertCacheValue(value) {
627
820
  }
628
821
  }
629
822
 
823
+ // staleAt is optional for backward compatibility: pre-v11 callers of the
824
+ // public SqliteCacheStore.set() omit it, and set() defaults it to deleteAt
825
+ // (this package's staleAt === deleteAt semantics). Validate the type only
826
+ // when supplied.
827
+ if (value.staleAt !== undefined && typeof value.staleAt !== 'number') {
828
+ throw new TypeError(
829
+ `expected value.staleAt to be number, got ${printType(value.staleAt)} [${value.staleAt}]`,
830
+ )
831
+ }
832
+
630
833
  if (typeof value.statusMessage !== 'string') {
631
834
  throw new TypeError(
632
835
  `expected value.statusMessage to be string, got ${printType(value.statusMessage)} [${value.statusMessage}]`,