@nxtedition/cache 2.0.0 → 2.0.2

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/README.md CHANGED
@@ -14,9 +14,9 @@ An async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, a
14
14
  ## Usage
15
15
 
16
16
  ```ts
17
- import { AsyncCache } from '@nxtedition/cache'
17
+ import { Cache } from '@nxtedition/cache'
18
18
 
19
- const cache = new AsyncCache(
19
+ const cache = new Cache(
20
20
  './my-cache.db', // SQLite file path, or ':memory:'
21
21
  async (id: string) => {
22
22
  // fetch the value for this key
@@ -43,14 +43,14 @@ if (result.async) {
43
43
 
44
44
  ## API
45
45
 
46
- ### `new AsyncCache(location, valueSelector, keySelector, opts?)`
46
+ ### `new Cache(location, valueSelector, keySelector, opts?)`
47
47
 
48
48
  | Parameter | Type | Description |
49
49
  | --------------- | ------------------------------ | --------------------------------------------- |
50
50
  | `location` | `string` | SQLite database path, or `':memory:'` |
51
51
  | `valueSelector` | `(...args) => V \| Promise<V>` | Function to fetch a value on cache miss |
52
52
  | `keySelector` | `(...args) => string` | Function to derive a cache key from arguments |
53
- | `opts` | `AsyncCacheOptions<V>` | Optional configuration |
53
+ | `opts` | `CacheOptions<V>` | Optional configuration |
54
54
 
55
55
  #### Options
56
56
 
package/lib/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export interface DatabaseOptions {
4
4
  timeout?: number;
5
5
  maxSize?: number;
6
6
  }
7
- export interface AsyncCacheOptions<V> {
7
+ export interface CacheOptions<V> {
8
8
  ttl?: number | ((value: V, key: string) => number);
9
9
  stale?: number | ((value: V, key: string) => number);
10
10
  memory?: MemoryOptions | false | null;
@@ -17,9 +17,9 @@ export type CacheResult<V> = {
17
17
  value: Promise<V>;
18
18
  async: true;
19
19
  };
20
- export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
20
+ export declare class Cache<V = unknown, A extends unknown[] = unknown[]> {
21
21
  #private;
22
- constructor(location: string, valueSelector: (...args: A) => V | Promise<V>, keySelector: (...args: A) => string, opts?: AsyncCacheOptions<V>);
22
+ constructor(location: string, valueSelector: (...args: A) => V | PromiseLike<V>, keySelector: (...args: A) => string, opts?: CacheOptions<V>);
23
23
  close(): void;
24
24
  get(...args: A): CacheResult<V>;
25
25
  peek(...args: A): CacheResult<V>;
@@ -27,3 +27,7 @@ export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
27
27
  delete(...args: A): void;
28
28
  purgeStale(): void;
29
29
  }
30
+ /** @deprecated Use `Cache` instead. */
31
+ export declare const AsyncCache: typeof Cache;
32
+ /** @deprecated Use `CacheOptions` instead. */
33
+ export type AsyncCacheOptions<V> = CacheOptions<V>;
package/lib/index.js CHANGED
@@ -1,22 +1,16 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { DatabaseSync, } from 'node:sqlite'
3
+ import { setTimeout as delay } from 'node:timers/promises'
2
4
  import { MemoryCache, } from "./memory.js"
3
5
 
4
6
 
5
7
 
6
- let fastNowTime = 0
8
+ function noop() {}
7
9
 
8
- function fastNow() {
9
- if (fastNowTime === 0) {
10
- fastNowTime = Math.floor(Date.now() / 1e3) * 1e3
11
- setInterval(() => {
12
- fastNowTime = Math.floor(Date.now() / 1e3) * 1e3
13
- }, 1e3).unref()
14
- }
15
- return fastNowTime
10
+ function isThenable(value ) {
11
+ return value != null && typeof (value ).then === 'function'
16
12
  }
17
13
 
18
- function noop() {}
19
-
20
14
  const dbs = new Set ()
21
15
 
22
16
  {
@@ -50,7 +44,7 @@ const dbs = new Set ()
50
44
 
51
45
 
52
46
 
53
-
47
+
54
48
 
55
49
 
56
50
 
@@ -61,31 +55,41 @@ const dbs = new Set ()
61
55
 
62
56
 
63
57
 
64
- const VERSION = 2
58
+ const VERSION = 4
65
59
  const MAX_DURATION = 365000000e3
66
60
 
67
- export class AsyncCache {
61
+ export class Cache {
68
62
  #memory
69
63
  #dedupe = new Map ()
64
+ #closed = false
70
65
 
71
- #valueSelector
66
+ #valueSelector
72
67
  #keySelector
73
68
 
74
69
  #ttl
75
70
  #stale
76
71
 
72
+ #lockMean = 5
73
+ #lockVar = 0
74
+ #lockTimeout = 10
75
+ #lockId = randomUUID()
76
+
77
77
  #database = null
78
78
  #getQuery = null
79
79
  #setQuery = null
80
80
  #delQuery = null
81
81
  #purgeStaleQuery = null
82
82
  #evictQuery = null
83
+ #lockAcquireQuery = null
84
+ #lockStealQuery = null
85
+ #lockGetQuery = null
86
+ #lockPurgeQuery = null
83
87
 
84
88
  constructor(
85
89
  location ,
86
- valueSelector ,
90
+ valueSelector ,
87
91
  keySelector ,
88
- opts ,
92
+ opts ,
89
93
  ) {
90
94
  if (typeof location !== 'string') {
91
95
  throw new TypeError('location must be a string')
@@ -141,6 +145,12 @@ export class AsyncCache {
141
145
  ttl INTEGER NOT NULL,
142
146
  stale INTEGER NOT NULL
143
147
  );
148
+
149
+ CREATE TABLE IF NOT EXISTS cache_lock_v${VERSION} (
150
+ key TEXT PRIMARY KEY NOT NULL,
151
+ lock_acquired INTEGER NOT NULL,
152
+ lock_owner TEXT NOT NULL
153
+ );
144
154
  `)
145
155
 
146
156
  {
@@ -163,6 +173,22 @@ export class AsyncCache {
163
173
  this.#evictQuery = this.#database.prepare(
164
174
  `DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
165
175
  )
176
+
177
+ // Bug A fix: ON CONFLICT refreshes lock_acquired when we are the owner,
178
+ // preventing other processes from stealing our still-active lock.
179
+ this.#lockAcquireQuery = this.#database.prepare(
180
+ `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`,
181
+ )
182
+ this.#lockStealQuery = this.#database.prepare(
183
+ `UPDATE cache_lock_v${VERSION} SET lock_acquired = ?, lock_owner = ? WHERE key = ? AND lock_acquired = ?`,
184
+ )
185
+ // Bug B fix: read the current lock state after a failed steal to get the winner's timestamp.
186
+ this.#lockGetQuery = this.#database.prepare(
187
+ `SELECT lock_acquired FROM cache_lock_v${VERSION} WHERE key = ?`,
188
+ )
189
+ this.#lockPurgeQuery = this.#database.prepare(
190
+ `DELETE FROM cache_lock_v${VERSION} WHERE lock_acquired <= ?`,
191
+ )
166
192
  break
167
193
  } catch (err) {
168
194
  if (n >= 16) {
@@ -174,6 +200,10 @@ export class AsyncCache {
174
200
  this.#delQuery = null
175
201
  this.#purgeStaleQuery = null
176
202
  this.#evictQuery = null
203
+ this.#lockAcquireQuery = null
204
+ this.#lockStealQuery = null
205
+ this.#lockGetQuery = null
206
+ this.#lockPurgeQuery = null
177
207
 
178
208
  process.emitWarning(err )
179
209
  break
@@ -185,36 +215,59 @@ export class AsyncCache {
185
215
  }
186
216
 
187
217
  close() {
218
+ this.#closed = true
219
+ this.#dedupe.clear()
188
220
  dbs.delete(this)
221
+
189
222
  this.#getQuery = null
190
223
  this.#setQuery = null
191
224
  this.#delQuery = null
192
225
  this.#purgeStaleQuery = null
193
226
  this.#evictQuery = null
227
+ this.#lockAcquireQuery = null
228
+ this.#lockStealQuery = null
229
+ this.#lockGetQuery = null
230
+ this.#lockPurgeQuery = null
194
231
  this.#database?.close()
195
232
  this.#database = null
196
233
  }
197
234
 
198
235
  get(...args ) {
236
+ if (this.#closed) {
237
+ throw new Error('cache is closed')
238
+ }
199
239
  return this.#load(args, true)
200
240
  }
201
241
 
202
242
  peek(...args ) {
243
+ if (this.#closed) {
244
+ throw new Error('cache is closed')
245
+ }
203
246
  return this.#load(args, false)
204
247
  }
205
248
 
206
249
  refresh(...args ) {
250
+ if (this.#closed) {
251
+ throw new Error('cache is closed')
252
+ }
207
253
  return this.#refresh(args)
208
254
  }
209
255
 
210
256
  delete(...args ) {
211
- this.#delete(this.#keySelector(...args))
257
+ if (this.#closed) {
258
+ throw new Error('cache is closed')
259
+ }
260
+ this.#set(this.#keySelector(...args), undefined)
212
261
  }
213
262
 
214
263
  purgeStale() {
264
+ if (this.#closed) {
265
+ throw new Error('cache is closed')
266
+ }
215
267
  try {
216
- this.#memory?.purgeStale(fastNow())
217
- this.#purgeStaleQuery?.run(fastNow())
268
+ this.#memory?.purgeStale(Date.now())
269
+ this.#purgeStaleQuery?.run(Date.now())
270
+ this.#lockPurgeQuery?.run(Date.now() - 3_600_000)
218
271
  } catch (err) {
219
272
  process.emitWarning(err )
220
273
  }
@@ -227,7 +280,7 @@ export class AsyncCache {
227
280
  throw new TypeError('keySelector must return a non-empty string')
228
281
  }
229
282
 
230
- const now = fastNow()
283
+ const now = Date.now()
231
284
 
232
285
  let cached = this.#memory?.get(key)
233
286
 
@@ -235,24 +288,8 @@ export class AsyncCache {
235
288
  try {
236
289
  const row = this.#getQuery?.get(key, now)
237
290
  if (row !== undefined) {
238
- const entry = {
239
- ttl: row.ttl,
240
- stale: row.stale,
241
- value: ArrayBuffer.isView(row.val)
242
- ? (Buffer.from(
243
- row.val.buffer,
244
- row.val.byteOffset,
245
- row.val.byteLength,
246
- ) )
247
- : JSON.parse(row.val),
248
- key,
249
- size:
250
- (ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length) + key.length + 64,
251
- index: -1,
252
- counter: -1,
253
- }
291
+ const entry = this.#loadRow(key, row)
254
292
  this.#memory?.set(key, entry)
255
-
256
293
  cached = entry
257
294
  }
258
295
  } catch (err) {
@@ -278,7 +315,19 @@ export class AsyncCache {
278
315
  }
279
316
  }
280
317
 
281
- const result = refresh ? this.#refresh(args, key) : ({ value: undefined, async: false } )
318
+ 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
327
+ }
328
+ } else {
329
+ result = { value: undefined, async: false }
330
+ }
282
331
 
283
332
  if (result.async && cached !== undefined) {
284
333
  return { value: cached.value, async: false }
@@ -292,68 +341,94 @@ export class AsyncCache {
292
341
  throw new TypeError('keySelector must return a non-empty string')
293
342
  }
294
343
 
295
- // TODO (fix): cross process/thread dedupe...
296
344
  const existing = this.#dedupe.get(key)
297
345
  if (existing !== undefined) {
298
346
  return { async: true, value: existing }
299
347
  }
300
348
 
301
- const value = this.#valueSelector(...args)
302
-
303
- if (!(value instanceof Promise)) {
304
- this.#dedupe.delete(key)
305
- if (value === undefined) {
306
- this.#delete(key)
307
- } else {
308
- this.#set(key, value)
309
- }
310
- return { async: false, value }
349
+ // Another process holds the lock — wait for the value instead of calling valueSelector.
350
+ // Use deferred pattern so #waitForValue can check promise identity to detect delete+get races.
351
+ const lockResult = this.#tryAcquireLock(key)
352
+ if (typeof lockResult === 'number') {
353
+ const { promise, resolve, reject } = Promise.withResolvers ()
354
+ promise.catch(noop)
355
+ this.#dedupe.set(key, promise)
356
+ this.#waitForValue(args, key, lockResult, promise).then(resolve, reject)
357
+ return { async: true, value: promise }
311
358
  }
312
359
 
313
- // eslint-disable-next-line: no-unsafe-argument
314
- const promise = value.then(
315
- (value) => {
316
- if (this.#dedupe.get(key) === promise) {
317
- this.#dedupe.delete(key)
318
- if (value === undefined) {
319
- this.#delete(key)
320
- } else {
360
+ const res = this.#fetch(args)
361
+
362
+ if (res.async) {
363
+ const promise = res.value.then(
364
+ (value) => {
365
+ if (this.#dedupe.get(key) === promise) {
321
366
  this.#set(key, value)
322
367
  }
323
- }
324
- return value
325
- },
326
- (err) => {
327
- if (this.#dedupe.get(key) === promise) {
328
- this.#dedupe.delete(key)
329
- }
330
- throw err
331
- },
332
- )
333
- promise.catch(noop)
334
- this.#dedupe.set(key, promise)
368
+ return value
369
+ },
370
+ (err) => {
371
+ if (this.#dedupe.get(key) === promise) {
372
+ this.#dedupe.delete(key)
373
+ }
374
+ throw err
375
+ },
376
+ )
377
+ promise.catch(noop)
378
+ this.#dedupe.set(key, promise)
379
+ return { async: true, value: promise }
380
+ }
335
381
 
336
- return { async: true, value: promise }
382
+ this.#set(key, res.value)
383
+ return res
337
384
  }
338
385
 
339
- #set(key , value ) {
386
+ #fetch(args ) {
387
+ const startTime = performance.now()
388
+ const value = this.#valueSelector(...args)
389
+
390
+ if (isThenable(value)) {
391
+ return {
392
+ async: true,
393
+ value: Promise.resolve(value).then((value) => {
394
+ this.#updateLockTimeout(performance.now() - startTime)
395
+ return value
396
+ }),
397
+ }
398
+ }
399
+
400
+ this.#updateLockTimeout(performance.now() - startTime)
401
+ return { async: false, value }
402
+ }
403
+
404
+ #set(key , value ) {
340
405
  if (typeof key !== 'string' || key.length === 0) {
341
406
  throw new TypeError('key must be a non-empty string')
342
407
  }
343
408
 
344
409
  this.#dedupe.delete(key)
345
410
 
411
+ if (value === undefined) {
412
+ this.#memory?.delete(key)
413
+ try {
414
+ this.#delQuery?.run(key)
415
+ } catch (err) {
416
+ process.emitWarning(err )
417
+ }
418
+ return
419
+ }
420
+
346
421
  const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
347
422
  if (!Number.isFinite(ttlValue) || ttlValue < 0) {
348
423
  throw new TypeError('ttl must be nully or a positive integer')
349
424
  }
350
425
 
351
- const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? 0)
426
+ const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? Number.MAX_SAFE_INTEGER)
352
427
  if (!Number.isFinite(staleValue) || staleValue < 0) {
353
428
  throw new TypeError('stale must be nully or a positive integer')
354
429
  }
355
430
 
356
- const now = fastNow()
431
+ const now = Date.now()
357
432
  const ttl = now + ttlValue
358
433
  const stale = ttl + staleValue
359
434
 
@@ -361,21 +436,16 @@ export class AsyncCache {
361
436
  return
362
437
  }
363
438
 
364
- const data = ArrayBuffer.isView(value)
365
- ? value
366
- : JSON.stringify(value )
439
+ const data = ArrayBuffer.isView(value) ? value : JSON.stringify(value )
367
440
 
368
- this.#memory?.set(key, {
441
+ const entry = this.#createEntry(
442
+ key,
443
+ value,
369
444
  ttl,
370
445
  stale,
371
- value: ArrayBuffer.isView(value)
372
- ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
373
- : value,
374
- key,
375
- size: (ArrayBuffer.isView(data) ? data.byteLength : data.length) + key.length + 64,
376
- index: -1,
377
- counter: -1,
378
- })
446
+ ArrayBuffer.isView(data) ? data.byteLength : data.length * 2,
447
+ )
448
+ this.#memory?.set(key, entry)
379
449
 
380
450
  try {
381
451
  this.#setQuery?.run(key, data , ttl, stale)
@@ -393,17 +463,163 @@ export class AsyncCache {
393
463
  }
394
464
  }
395
465
 
396
- #delete(key ) {
397
- if (typeof key !== 'string' || key.length === 0) {
398
- throw new TypeError('key must be a non-empty string')
466
+ #createEntry(
467
+ key ,
468
+ value ,
469
+ ttl ,
470
+ stale ,
471
+ size ,
472
+ ) {
473
+ return {
474
+ ttl,
475
+ stale,
476
+ value: ArrayBuffer.isView(value)
477
+ ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
478
+ : value,
479
+ key,
480
+ size,
481
+ index: -1,
482
+ counter: -1,
399
483
  }
484
+ }
485
+
486
+ #loadRow(key , row ) {
487
+ const value = (ArrayBuffer.isView(row.val) ? row.val : JSON.parse(row.val))
488
+ return this.#createEntry(
489
+ key,
490
+ value,
491
+ row.ttl,
492
+ row.stale,
493
+ ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length * 2,
494
+ )
495
+ }
496
+
497
+ // Returns lock_acquired timestamp (number) when contended, or a string status.
498
+ #tryAcquireLock(key ) {
499
+ if (this.#lockAcquireQuery === null) {
500
+ return 'unavailable'
501
+ }
502
+
503
+ const now = Date.now()
400
504
 
401
- this.#dedupe.delete(key)
402
- this.#memory?.delete(key)
403
505
  try {
404
- this.#delQuery?.run(key)
506
+ const row = this.#lockAcquireQuery.get(key, now, this.#lockId)
507
+
508
+
509
+
510
+ if (row === undefined) {
511
+ return 'unavailable'
512
+ }
513
+
514
+ if (row.lock_owner === this.#lockId) {
515
+ return 'acquired'
516
+ }
517
+
518
+ const lockedAt = row.lock_acquired
519
+ if (now - lockedAt > this.#lockTimeout * 3) {
520
+ // Lock is stale (3x the EMA-based timeout) — attempt to steal atomically.
521
+ // The WHERE clause matches the exact lock_acquired we observed, so only one
522
+ // process wins if multiple try to steal concurrently.
523
+ const stealResult = this.#lockStealQuery .run(now, this.#lockId, key, lockedAt)
524
+ if (stealResult.changes === 1) {
525
+ return 'acquired'
526
+ }
527
+
528
+ // Steal failed — another process won the race. Read the winner's lock_acquired
529
+ // so the caller waits the right amount of time instead of using the stale timestamp.
530
+ const current = this.#lockGetQuery .get(key)
531
+ if (current !== undefined) {
532
+ return current.lock_acquired
533
+ }
534
+ }
535
+
536
+ return lockedAt
405
537
  } catch (err) {
406
538
  process.emitWarning(err )
539
+ return 'unavailable'
540
+ }
541
+ }
542
+
543
+ #updateLockTimeout(duration ) {
544
+ // EMA of mean and variance (Welford-style), clamped to [10ms, 1s].
545
+ // Timeout = mean * 1.2 + 3 * stddev: 20% base margin plus 3 standard
546
+ // deviations to accommodate timing variability.
547
+ const alpha = 0.2
548
+ const diff = duration - this.#lockMean
549
+ this.#lockMean += alpha * diff
550
+ this.#lockVar = (1 - alpha) * (this.#lockVar + alpha * diff * diff)
551
+ this.#lockTimeout = Math.max(
552
+ 10,
553
+ Math.min(1_000, Math.ceil(this.#lockMean * 1.2 + Math.sqrt(this.#lockVar) * 3)),
554
+ )
555
+ }
556
+
557
+ // Wait for another process to complete, then check for their result.
558
+ // If no result found, take over by calling valueSelector ourselves.
559
+ // selfPromise is the exact promise stored in #dedupe, used for identity checks
560
+ // to avoid double valueSelector calls after delete()+get() races.
561
+ async #waitForValue(args , key , lockedAt , selfPromise ) {
562
+ // Loop: wait for lock holder to write a value, retry if lock was stolen by another process.
563
+ for (let retries = 0; retries < 2 && this.#dedupe.get(key) === selfPromise; retries++) {
564
+ // Wait for estimated completion: locked_at + our EMA-based timeout.
565
+ const waitTime = Math.max(0, lockedAt + this.#lockTimeout - Date.now())
566
+ if (waitTime > 0) {
567
+ // Add 20% jitter to reduce thundering herd on contention
568
+ await delay(waitTime + Math.floor(Math.random() * waitTime * 0.2), undefined, {
569
+ ref: false,
570
+ })
571
+ }
572
+
573
+ // Check if the lock holder wrote a value
574
+ try {
575
+ const row = this.#getQuery?.get(key, Date.now())
576
+ if (row !== undefined) {
577
+ if (this.#dedupe.get(key) === selfPromise) {
578
+ this.#dedupe.delete(key)
579
+ }
580
+ const entry = this.#loadRow(key, row)
581
+ this.#memory?.set(key, entry)
582
+ return entry.value
583
+ }
584
+ } catch (err) {
585
+ process.emitWarning(err )
586
+ }
587
+
588
+ // Try to acquire lock for takeover
589
+ const lockResult = this.#tryAcquireLock(key)
590
+ if (typeof lockResult === 'number') {
591
+ // Another process holds the lock — wait for them
592
+ lockedAt = lockResult
593
+ } else {
594
+ // We acquired the lock or lock is unavailable — do the work
595
+ break
596
+ }
597
+ }
598
+
599
+ {
600
+ const promise = this.#dedupe.get(key)
601
+ if (promise !== undefined && promise !== selfPromise) {
602
+ return promise
603
+ }
604
+ }
605
+
606
+ try {
607
+ const res = this.#fetch(args)
608
+ const value = res.async ? await res.value : res.value
609
+ if (this.#dedupe.get(key) === selfPromise) {
610
+ this.#set(key, value)
611
+ }
612
+ return value
613
+ } catch (err) {
614
+ if (this.#dedupe.get(key) === selfPromise) {
615
+ this.#dedupe.delete(key)
616
+ }
617
+ throw err
407
618
  }
408
619
  }
409
620
  }
621
+
622
+ /** @deprecated Use `Cache` instead. */
623
+ export const AsyncCache = Cache
624
+ /** @deprecated Use `CacheOptions` instead. */
625
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/cache",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -17,16 +17,18 @@
17
17
  "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/ && amaroc ./src/memory.ts && mv src/memory.js lib/",
18
18
  "prepublishOnly": "yarn build",
19
19
  "typecheck": "tsc --noEmit",
20
- "test": "node --test && yarn build",
21
- "test:ci": "node --test && yarn build",
22
- "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-include=src/memory.ts --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=100"
20
+ "test": "node --test '**/*.test.js' && yarn build",
21
+ "test:ci": "node --test '**/*.test.js' && yarn build",
22
+ "test:types": "tsd",
23
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-include=src/memory.ts --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=100 '**/*.test.js'"
23
24
  },
24
25
  "devDependencies": {
25
26
  "@types/node": "^25.2.3",
26
27
  "amaroc": "^1.0.1",
27
28
  "oxlint-tsgolint": "^0.13.0",
28
29
  "rimraf": "^6.1.3",
30
+ "tsd": "^0.33.0",
29
31
  "typescript": "^5.9.3"
30
32
  },
31
- "gitHead": "05f9f940c3b262e4c5431c953ac93bc159ade0a0"
33
+ "gitHead": "f6592f0be62fecc161237610ae6454ec6585548b"
32
34
  }