@nxtedition/cache 2.0.2 → 2.0.4

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/lib/index.d.ts CHANGED
@@ -1,14 +1,20 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  import { type MemoryOptions } from './memory.ts';
2
3
  export type { MemoryOptions } from './memory.ts';
3
4
  export interface DatabaseOptions {
4
5
  timeout?: number;
5
6
  maxSize?: number;
6
7
  }
8
+ export interface Serializer<V> {
9
+ serialize: (value: V) => Buffer | Uint8Array | string;
10
+ deserialize: (data: Buffer | string) => V;
11
+ }
7
12
  export interface CacheOptions<V> {
8
13
  ttl?: number | ((value: V, key: string) => number);
9
14
  stale?: number | ((value: V, key: string) => number);
10
15
  memory?: MemoryOptions | false | null;
11
16
  database?: DatabaseOptions | false | null;
17
+ serializer?: Serializer<V>;
12
18
  }
13
19
  export type CacheResult<V> = {
14
20
  value: V | undefined;
@@ -17,9 +23,34 @@ export type CacheResult<V> = {
17
23
  value: Promise<V>;
18
24
  async: true;
19
25
  };
20
- export declare class Cache<V = unknown, A extends unknown[] = unknown[]> {
26
+ declare global {
27
+ var __nxt_cache: {
28
+ stats: Cache['stats'];
29
+ }[];
30
+ }
31
+ export declare class Cache<V = unknown, A extends unknown[] = unknown[]> extends EventEmitter {
21
32
  #private;
22
33
  constructor(location: string, valueSelector: (...args: A) => V | PromiseLike<V>, keySelector: (...args: A) => string, opts?: CacheOptions<V>);
34
+ get stats(): {
35
+ lock: {
36
+ timeout: number;
37
+ mean: number;
38
+ stddev: number;
39
+ };
40
+ dedupe: {
41
+ size: number;
42
+ };
43
+ memory: {
44
+ size: number;
45
+ maxSize: number;
46
+ count: number;
47
+ maxCount: number;
48
+ } | undefined;
49
+ database: {
50
+ location: string;
51
+ size: number | undefined;
52
+ } | undefined;
53
+ };
23
54
  close(): void;
24
55
  get(...args: A): CacheResult<V>;
25
56
  peek(...args: A): CacheResult<V>;
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import { EventEmitter } from 'node:events'
2
3
  import { DatabaseSync, } from 'node:sqlite'
3
4
  import { setTimeout as delay } from 'node:timers/promises'
4
5
  import { MemoryCache, } from "./memory.js"
@@ -7,25 +8,18 @@ import { MemoryCache, } from "./memory
7
8
 
8
9
  function noop() {}
9
10
 
11
+ function maybeToBuffer(value ) {
12
+ return ArrayBuffer.isView(value)
13
+ ? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
14
+ : value
15
+ }
16
+
10
17
  function isThenable(value ) {
11
18
  return value != null && typeof (value ).then === 'function'
12
19
  }
13
20
 
14
- const dbs = new Set ()
15
-
16
- {
17
- const offPeakBC = new BroadcastChannel('nxt:offPeak')
18
- offPeakBC.unref()
19
- offPeakBC.onmessage = () => {
20
- for (const db of dbs) {
21
- try {
22
- db.purgeStale()
23
- } catch (err) {
24
- process.emitWarning(err )
25
- }
26
- }
27
- }
28
- }
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ const dbs = new Set ()
29
23
 
30
24
 
31
25
 
@@ -44,21 +38,42 @@ const dbs = new Set ()
44
38
 
45
39
 
46
40
 
41
+
42
+
43
+
44
+
45
+
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ const defaultSerializer = {
48
+ serialize(value) {
49
+ return ArrayBuffer.isView(value) ? (value ) : JSON.stringify(value)
50
+ },
51
+ deserialize(data) {
52
+ return ArrayBuffer.isView(data) ? data : JSON.parse(data)
53
+ },
54
+ }
55
+
47
56
 
48
57
 
49
58
 
50
59
 
51
60
 
61
+
52
62
 
53
63
 
54
64
 
55
65
 
56
66
 
57
67
 
58
- const VERSION = 4
68
+
69
+
70
+
71
+
72
+
73
+ const VERSION = 5
59
74
  const MAX_DURATION = 365000000e3
60
75
 
61
- export class Cache {
76
+ export class Cache extends EventEmitter {
62
77
  #memory
63
78
  #dedupe = new Map ()
64
79
  #closed = false
@@ -68,12 +83,14 @@ export class Cache {
68
83
 
69
84
  #ttl
70
85
  #stale
86
+ #serializer
71
87
 
72
88
  #lockMean = 5
73
89
  #lockVar = 0
74
90
  #lockTimeout = 10
75
91
  #lockId = randomUUID()
76
92
 
93
+ #location
77
94
  #database = null
78
95
  #getQuery = null
79
96
  #setQuery = null
@@ -84,6 +101,16 @@ export class Cache {
84
101
  #lockStealQuery = null
85
102
  #lockGetQuery = null
86
103
  #lockPurgeQuery = null
104
+ #pageCountQuery = null
105
+ #pageSizeQuery = null
106
+
107
+ #emitError(err ) {
108
+ if (this.listenerCount('error') > 0) {
109
+ this.emit('error', err)
110
+ } else {
111
+ process.emitWarning(err)
112
+ }
113
+ }
87
114
 
88
115
  constructor(
89
116
  location ,
@@ -91,9 +118,11 @@ export class Cache {
91
118
  keySelector ,
92
119
  opts ,
93
120
  ) {
121
+ super()
94
122
  if (typeof location !== 'string') {
95
123
  throw new TypeError('location must be a string')
96
124
  }
125
+ this.#location = location
97
126
 
98
127
  if (typeof valueSelector !== 'function') {
99
128
  throw new TypeError('valueSelector must be a function')
@@ -123,6 +152,19 @@ export class Cache {
123
152
  throw new TypeError('stale must be a undefined, number or a function')
124
153
  }
125
154
 
155
+ if (opts?.serializer !== undefined) {
156
+ if (typeof opts.serializer !== 'object' || opts.serializer === null) {
157
+ throw new TypeError('serializer must be an object')
158
+ }
159
+ if (typeof opts.serializer.serialize !== 'function') {
160
+ throw new TypeError('serializer.serialize must be a function')
161
+ }
162
+ if (typeof opts.serializer.deserialize !== 'function') {
163
+ throw new TypeError('serializer.deserialize must be a function')
164
+ }
165
+ }
166
+ this.#serializer = opts?.serializer ?? defaultSerializer
167
+
126
168
  this.#memory =
127
169
  opts?.memory === false || opts?.memory === null ? null : new MemoryCache(opts?.memory)
128
170
 
@@ -137,6 +179,9 @@ export class Cache {
137
179
  PRAGMA journal_mode = WAL;
138
180
  PRAGMA synchronous = NORMAL;
139
181
  PRAGMA temp_store = memory;
182
+ PRAGMA cache_size = -${Math.ceil(maxSize / 1024 / 8)};
183
+ PRAGMA mmap_size = ${maxSize};
184
+ PRAGMA max_page_count = ${Math.ceil(maxSize / 4096)};
140
185
  PRAGMA optimize;
141
186
 
142
187
  CREATE TABLE IF NOT EXISTS cache_v${VERSION} (
@@ -144,7 +189,9 @@ export class Cache {
144
189
  val BLOB NOT NULL,
145
190
  ttl INTEGER NOT NULL,
146
191
  stale INTEGER NOT NULL
147
- );
192
+ ) WITHOUT ROWID;
193
+
194
+ CREATE INDEX IF NOT EXISTS cache_v${VERSION}_stale_idx ON cache_v${VERSION}(stale);
148
195
 
149
196
  CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
150
197
  key TEXT PRIMARY KEY NOT NULL,
@@ -153,13 +200,6 @@ export class Cache {
153
200
  );
154
201
  `)
155
202
 
156
- {
157
- const { page_size } = this.#database.prepare('PRAGMA page_size').get()
158
-
159
-
160
- this.#database.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
161
- }
162
-
163
203
  this.#getQuery = this.#database.prepare(
164
204
  `SELECT val, ttl, stale FROM cache_v${VERSION} WHERE key = ? AND stale > ?`,
165
205
  )
@@ -174,7 +214,7 @@ export class Cache {
174
214
  `DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
175
215
  )
176
216
 
177
- // Bug A fix: ON CONFLICT refreshes lock_acquired when we are the owner,
217
+ // ON CONFLICT refreshes lock_acquired when we are the owner,
178
218
  // preventing other processes from stealing our still-active lock.
179
219
  this.#lockAcquireQuery = this.#database.prepare(
180
220
  `INSERT INTO cache_lock_v${VERSION} (key, lock_acquired, lock_owner) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET lock_acquired = CASE WHEN lock_owner = excluded.lock_owner THEN excluded.lock_acquired ELSE lock_acquired END RETURNING lock_acquired, lock_owner`,
@@ -182,13 +222,15 @@ export class Cache {
182
222
  this.#lockStealQuery = this.#database.prepare(
183
223
  `UPDATE cache_lock_v${VERSION} SET lock_acquired = ?, lock_owner = ? WHERE key = ? AND lock_acquired = ?`,
184
224
  )
185
- // Bug B fix: read the current lock state after a failed steal to get the winner's timestamp.
225
+ // Read the current lock state after a failed steal to get the winner's timestamp.
186
226
  this.#lockGetQuery = this.#database.prepare(
187
227
  `SELECT lock_acquired FROM cache_lock_v${VERSION} WHERE key = ?`,
188
228
  )
189
229
  this.#lockPurgeQuery = this.#database.prepare(
190
230
  `DELETE FROM cache_lock_v${VERSION} WHERE lock_acquired <= ?`,
191
231
  )
232
+ this.#pageCountQuery = this.#database.prepare('PRAGMA page_count')
233
+ this.#pageSizeQuery = this.#database.prepare('PRAGMA page_size')
192
234
  break
193
235
  } catch (err) {
194
236
  if (n >= 16) {
@@ -204,14 +246,45 @@ export class Cache {
204
246
  this.#lockStealQuery = null
205
247
  this.#lockGetQuery = null
206
248
  this.#lockPurgeQuery = null
249
+ this.#pageCountQuery = null
250
+ this.#pageSizeQuery = null
207
251
 
208
- process.emitWarning(err )
252
+ this.#emitError(err )
209
253
  break
210
254
  }
211
255
  }
212
256
  }
213
257
 
214
258
  dbs.add(this)
259
+
260
+ globalThis.__nxt_cache ??= []
261
+ globalThis.__nxt_cache.push(this)
262
+ }
263
+
264
+ get stats() {
265
+ let database
266
+ if (this.#database) {
267
+ let size
268
+ try {
269
+ const { page_count } = this.#pageCountQuery .get()
270
+ const { page_size } = this.#pageSizeQuery .get()
271
+ size = page_count * page_size
272
+ } catch (err) {
273
+ this.#emitError(err )
274
+ }
275
+ database = { location: this.#location, size }
276
+ }
277
+
278
+ return {
279
+ lock: {
280
+ timeout: this.#lockTimeout,
281
+ mean: this.#lockMean,
282
+ stddev: Math.sqrt(this.#lockVar),
283
+ },
284
+ dedupe: { size: this.#dedupe.size },
285
+ memory: this.#memory?.stats,
286
+ database,
287
+ }
215
288
  }
216
289
 
217
290
  close() {
@@ -228,8 +301,15 @@ export class Cache {
228
301
  this.#lockStealQuery = null
229
302
  this.#lockGetQuery = null
230
303
  this.#lockPurgeQuery = null
304
+ this.#pageCountQuery = null
305
+ this.#pageSizeQuery = null
231
306
  this.#database?.close()
232
307
  this.#database = null
308
+
309
+ const idx = globalThis.__nxt_cache?.indexOf(this) ?? -1
310
+ if (idx !== -1) {
311
+ globalThis.__nxt_cache.splice(idx, 1)
312
+ }
233
313
  }
234
314
 
235
315
  get(...args ) {
@@ -269,7 +349,7 @@ export class Cache {
269
349
  this.#purgeStaleQuery?.run(Date.now())
270
350
  this.#lockPurgeQuery?.run(Date.now() - 3_600_000)
271
351
  } catch (err) {
272
- process.emitWarning(err )
352
+ this.#emitError(err )
273
353
  }
274
354
  }
275
355
 
@@ -293,7 +373,7 @@ export class Cache {
293
373
  cached = entry
294
374
  }
295
375
  } catch (err) {
296
- process.emitWarning(err )
376
+ this.#emitError(err )
297
377
  }
298
378
  }
299
379
 
@@ -315,18 +395,20 @@ export class Cache {
315
395
  }
316
396
  }
317
397
 
398
+ if (!refresh) {
399
+ // peek: return stale value if available, undefined if expired or missing
400
+ return { value: cached?.value, async: false }
401
+ }
402
+
318
403
  let result
319
- if (refresh) {
320
- try {
321
- result = this.#refresh(args, key)
322
- } catch (err) {
323
- if (cached !== undefined) {
324
- return { value: cached.value, async: false }
325
- }
326
- throw err
404
+ try {
405
+ result = this.#refresh(args, key)
406
+ } catch (err) {
407
+ if (cached !== undefined) {
408
+ this.#emitError(err )
409
+ return { value: cached.value, async: false }
327
410
  }
328
- } else {
329
- result = { value: undefined, async: false }
411
+ throw err
330
412
  }
331
413
 
332
414
  if (result.async && cached !== undefined) {
@@ -413,7 +495,7 @@ export class Cache {
413
495
  try {
414
496
  this.#delQuery?.run(key)
415
497
  } catch (err) {
416
- process.emitWarning(err )
498
+ this.#emitError(err )
417
499
  }
418
500
  return
419
501
  }
@@ -436,7 +518,7 @@ export class Cache {
436
518
  return
437
519
  }
438
520
 
439
- const data = ArrayBuffer.isView(value) ? value : JSON.stringify(value )
521
+ const data = this.#serializer.serialize(value)
440
522
 
441
523
  const entry = this.#createEntry(
442
524
  key,
@@ -454,11 +536,11 @@ export class Cache {
454
536
  try {
455
537
  this.#evictQuery?.run(256)
456
538
  this.#setQuery?.run(key, data , ttl, stale)
457
- } catch {
458
- process.emitWarning(err )
539
+ } catch (evictErr) {
540
+ this.#emitError(evictErr )
459
541
  }
460
542
  } else {
461
- process.emitWarning(err )
543
+ this.#emitError(err )
462
544
  }
463
545
  }
464
546
  }
@@ -473,9 +555,7 @@ export class Cache {
473
555
  return {
474
556
  ttl,
475
557
  stale,
476
- value: ArrayBuffer.isView(value)
477
- ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
478
- : value,
558
+ value,
479
559
  key,
480
560
  size,
481
561
  index: -1,
@@ -484,7 +564,7 @@ export class Cache {
484
564
  }
485
565
 
486
566
  #loadRow(key , row ) {
487
- const value = (ArrayBuffer.isView(row.val) ? row.val : JSON.parse(row.val))
567
+ const value = this.#serializer.deserialize(maybeToBuffer(row.val))
488
568
  return this.#createEntry(
489
569
  key,
490
570
  value,
@@ -535,7 +615,7 @@ export class Cache {
535
615
 
536
616
  return lockedAt
537
617
  } catch (err) {
538
- process.emitWarning(err )
618
+ this.#emitError(err )
539
619
  return 'unavailable'
540
620
  }
541
621
  }
@@ -582,7 +662,7 @@ export class Cache {
582
662
  return entry.value
583
663
  }
584
664
  } catch (err) {
585
- process.emitWarning(err )
665
+ this.#emitError(err )
586
666
  }
587
667
 
588
668
  // Try to acquire lock for takeover
@@ -619,6 +699,24 @@ export class Cache {
619
699
  }
620
700
  }
621
701
 
702
+ {
703
+ const offPeakBC = new BroadcastChannel('nxt:offPeak')
704
+ offPeakBC.unref()
705
+ offPeakBC.onmessage = () => {
706
+ for (const db of dbs) {
707
+ try {
708
+ db.purgeStale()
709
+ } catch (err) {
710
+ if (db.listenerCount('error') > 0) {
711
+ db.emit('error', err)
712
+ } else {
713
+ process.emitWarning(err )
714
+ }
715
+ }
716
+ }
717
+ }
718
+ }
719
+
622
720
  /** @deprecated Use `Cache` instead. */
623
721
  export const AsyncCache = Cache
624
722
  /** @deprecated Use `CacheOptions` instead. */
package/lib/memory.d.ts CHANGED
@@ -18,4 +18,10 @@ export declare class MemoryCache<V> {
18
18
  get(key: string): MemoryCacheEntry<V> | undefined;
19
19
  delete(key: string): void;
20
20
  purgeStale(now: number): void;
21
+ get stats(): {
22
+ size: number;
23
+ maxSize: number;
24
+ count: number;
25
+ maxCount: number;
26
+ };
21
27
  }
package/lib/memory.js CHANGED
@@ -97,6 +97,15 @@ export class MemoryCache {
97
97
  }
98
98
  }
99
99
 
100
+ get stats() {
101
+ return {
102
+ size: this.#size,
103
+ maxSize: this.#maxSize,
104
+ count: this.#count,
105
+ maxCount: this.#maxCount,
106
+ }
107
+ }
108
+
100
109
  #prune() {
101
110
  while (this.#size > this.#maxSize || this.#count > this.#maxCount) {
102
111
  const e1 = this.#arr[(Math.random() * this.#arr.length) | 0]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/cache",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -30,5 +30,5 @@
30
30
  "tsd": "^0.33.0",
31
31
  "typescript": "^5.9.3"
32
32
  },
33
- "gitHead": "f6592f0be62fecc161237610ae6454ec6585548b"
33
+ "gitHead": "9b8156711c1909480df222a003871e2d9cded24c"
34
34
  }