@nxtedition/cache 1.0.2 → 1.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Robert Nagy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # @nxtedition/cache
2
+
3
+ An async cache with SQLite persistence, in-memory LRU, stale-while-revalidate, and automatic request deduplication.
4
+
5
+ ## Features
6
+
7
+ - **Two-tier storage**: In-memory LRU cache backed by SQLite on disk
8
+ - **Stale-while-revalidate**: Serve stale data while refreshing in the background
9
+ - **Request deduplication**: Concurrent fetches for the same key share a single in-flight request
10
+ - **Async value resolution**: Transparently fetches missing values via a user-defined `valueSelector`
11
+ - **Buffer support**: Store and retrieve binary data (Buffer, Uint8Array) alongside JSON values
12
+ - **Size-bounded SQLite**: Configurable max database size with automatic eviction of oldest entries
13
+
14
+ ## Usage
15
+
16
+ ```ts
17
+ import { AsyncCache } from '@nxtedition/cache'
18
+
19
+ const cache = new AsyncCache(
20
+ './my-cache.db', // SQLite file path, or ':memory:'
21
+ async (id: string) => {
22
+ // fetch the value for this key
23
+ const res = await fetch(`https://api.example.com/items/${id}`)
24
+ return res.json()
25
+ },
26
+ (id: string) => id, // keySelector: derive cache key from arguments
27
+ {
28
+ ttl: 60_000, // 60s before value is considered stale
29
+ stale: 30_000, // serve stale for 30s while revalidating
30
+ },
31
+ )
32
+
33
+ const result = cache.get('item-123')
34
+
35
+ if (result.async) {
36
+ // Cache miss — value is being fetched
37
+ const value = await result.value
38
+ } else {
39
+ // Cache hit — value returned synchronously
40
+ const value = result.value
41
+ }
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### `new AsyncCache(location, valueSelector, keySelector, opts?)`
47
+
48
+ | Parameter | Type | Description |
49
+ | --------------- | ------------------------------ | --------------------------------------------- |
50
+ | `location` | `string` | SQLite database path, or `':memory:'` |
51
+ | `valueSelector` | `(...args) => V \| Promise<V>` | Function to fetch a value on cache miss |
52
+ | `keySelector` | `(...args) => string` | Function to derive a cache key from arguments |
53
+ | `opts` | `AsyncCacheOptions<V>` | Optional configuration |
54
+
55
+ #### Options
56
+
57
+ | Option | Type | Default | Description |
58
+ | ------- | ----------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------- |
59
+ | `ttl` | `number \| (value, key) => number` | `MAX_SAFE_INTEGER` | Time-to-live in milliseconds. After this, the entry is stale. |
60
+ | `stale` | `number \| (value, key) => number` | `MAX_SAFE_INTEGER` | Stale-while-revalidate window in milliseconds. After `ttl + stale`, the entry is purged. |
61
+ | `lru` | `LRUCache.Options \| false \| null` | `{ max: 4096 }` | LRU cache options, or `false`/`null` to disable in-memory caching |
62
+ | `db` | `{ timeout?, maxSize? } \| false \| null` | `{ timeout: 20, maxSize: 256MB }` | SQLite options, or `false`/`null` to disable persistence |
63
+
64
+ ### Methods
65
+
66
+ #### `cache.get(...args): CacheResult<V>`
67
+
68
+ Returns the cached value or triggers a fetch via `valueSelector`.
69
+
70
+ ```ts
71
+ type CacheResult<V> =
72
+ | { value: V; async: false } // cache hit
73
+ | { value: Promise<V> | null; async: true } // cache miss
74
+ ```
75
+
76
+ When `async: true`, `value` is a Promise that resolves once the `valueSelector` completes.
77
+
78
+ #### `cache.peek(...args): CacheResult<V>`
79
+
80
+ Same as `get()` but does **not** trigger a refresh on cache miss. Returns `{ value: null, async: true }` for missing entries.
81
+
82
+ #### `cache.refresh(...args): Promise<V>`
83
+
84
+ Triggers a fetch via `valueSelector` regardless of cache state. If a fetch for the same key is already in-flight (from a prior `get()` or `refresh()`), the existing promise is returned instead of starting a new one.
85
+
86
+ #### `cache.delete(key): void`
87
+
88
+ Remove a key from the cache. Also cancels any in-flight deduplication for that key, meaning a pending fetch will not write its result to the cache.
89
+
90
+ #### `cache.purgeStale(): void`
91
+
92
+ Remove all expired entries from both the LRU cache and SQLite.
93
+
94
+ #### `cache.close(): void`
95
+
96
+ Close the SQLite database and release resources.
97
+
98
+ ## Deduplication
99
+
100
+ Concurrent calls to `get()` or `refresh()` for the same key share a single in-flight Promise. The `valueSelector` is called only once, and all callers receive the same resolved value.
101
+
102
+ ```ts
103
+ // valueSelector is called once, both promises resolve to the same value
104
+ const [a, b] = await Promise.all([cache.get('key').value, cache.get('key').value])
105
+ ```
106
+
107
+ If a fetch fails, the deduplication entry is cleaned up and subsequent calls will retry.
108
+
109
+ Calling `cache.delete(key)` while a fetch is in-flight invalidates the deduplication entry. The pending promise still resolves for its callers, but the result is **not** written to the cache.
110
+
111
+ ## Stale-While-Revalidate
112
+
113
+ When an entry's TTL has expired but is still within the stale window (`ttl + stale`), `get()` returns the stale value synchronously (`async: false`) while triggering a background refresh.
114
+
115
+ Once the stale window expires, the entry is purged entirely and the next `get()` returns `async: true`.
116
+
117
+ ```
118
+ |--- ttl ---|--- stale ---|
119
+ fresh stale expired
120
+ ```
121
+
122
+ ## Off-Peak Purge
123
+
124
+ All cache instances listen for messages on the `nxt:offPeak` BroadcastChannel. When a message is received, `purgeStale()` is called on every active instance, allowing coordinated cleanup during low-traffic periods.
125
+
126
+ ## Scripts
127
+
128
+ ```sh
129
+ npm test # run tests
130
+ npm run test:coverage # run tests with branch coverage report (90%+ enforced)
131
+ npm run typecheck # type-check without emitting
132
+ npm run build # build for publishing
133
+ ```
package/lib/index.d.ts CHANGED
@@ -23,13 +23,12 @@ export type CacheResult<V> = {
23
23
  };
24
24
  export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
25
25
  #private;
26
- constructor(location: string, valueSelector?: (...args: A) => V | Promise<V>, keySelector?: (...args: A) => string, opts?: AsyncCacheOptions<V>);
26
+ constructor(location: string, valueSelector: (...args: A) => V | Promise<V>, keySelector: (...args: A) => string, opts?: AsyncCacheOptions<V>);
27
27
  close(): void;
28
28
  get(...args: A): CacheResult<V>;
29
29
  peek(...args: A): CacheResult<V>;
30
- refresh(...args: A): Promise<V> | undefined;
30
+ refresh(...args: A): Promise<V>;
31
31
  delete(key: string): void;
32
- set(key: string, value: V): void;
33
32
  purgeStale(): void;
34
33
  }
35
34
  export {};
package/lib/index.js CHANGED
@@ -64,7 +64,7 @@ const MAX_DURATION = 365000000e3
64
64
 
65
65
  export class AsyncCache {
66
66
  #lru
67
- #valueSelector
67
+ #valueSelector
68
68
  #keySelector
69
69
  #dedupe = new Map ()
70
70
 
@@ -80,27 +80,23 @@ export class AsyncCache {
80
80
 
81
81
  constructor(
82
82
  location ,
83
- valueSelector ,
84
- keySelector ,
83
+ valueSelector ,
84
+ keySelector ,
85
85
  opts ,
86
86
  ) {
87
- if (typeof location === 'string') {
88
- // Do nothing...
89
- } else {
90
- throw new TypeError('location must be undefined or a string')
87
+ if (typeof location !== 'string') {
88
+ throw new TypeError('location must be a string')
91
89
  }
92
90
 
93
- if (typeof valueSelector === 'function' || valueSelector === undefined) {
94
- this.#valueSelector = valueSelector
95
- } else {
91
+ if (typeof valueSelector !== 'function') {
96
92
  throw new TypeError('valueSelector must be a function')
97
93
  }
94
+ this.#valueSelector = valueSelector
98
95
 
99
- if (typeof keySelector === 'function' || keySelector === undefined) {
100
- this.#keySelector = keySelector ?? ((...args ) => JSON.stringify(args))
101
- } else {
96
+ if (typeof keySelector !== 'function') {
102
97
  throw new TypeError('keySelector must be a function')
103
98
  }
99
+ this.#keySelector = keySelector
104
100
 
105
101
  if (typeof opts?.ttl === 'number' || opts?.ttl === undefined) {
106
102
  const ttl = opts?.ttl ?? Number.MAX_SAFE_INTEGER
@@ -196,7 +192,7 @@ export class AsyncCache {
196
192
  return this.#load(args, false)
197
193
  }
198
194
 
199
- refresh(...args ) {
195
+ refresh(...args ) {
200
196
  return this.#refresh(args)
201
197
  }
202
198
 
@@ -204,10 +200,6 @@ export class AsyncCache {
204
200
  this.#delete(key)
205
201
  }
206
202
 
207
- set(key , value ) {
208
- this.#set(key, value)
209
- }
210
-
211
203
  purgeStale() {
212
204
  try {
213
205
  this.#lru?.purgeStale()
@@ -276,31 +268,34 @@ export class AsyncCache {
276
268
  : { value: promise, async: true }
277
269
  }
278
270
 
279
- #refresh(args , key = this.#keySelector(...args)) {
271
+ #refresh(args , key = this.#keySelector(...args)) {
280
272
  if (typeof key !== 'string' || key.length === 0) {
281
273
  throw new TypeError('keySelector must return a non-empty string')
282
274
  }
283
275
 
284
276
  // TODO (fix): cross process/thread dedupe...
285
- let promise = this.#dedupe.get(key)
286
- if (promise === undefined && this.#valueSelector) {
287
- // eslint-disable-next-line: no-unsafe-argument
288
- promise = Promise.resolve(this.#valueSelector(...args)).then(
289
- (value) => {
290
- if (this.#dedupe.delete(key)) {
291
- this.#set(key, value)
292
- }
293
- return value
294
- },
295
- (err) => {
296
- this.#delete(key)
297
- throw err
298
- },
299
- )
300
- promise.catch(noop)
301
- this.#dedupe.set(key, promise)
277
+ const existing = this.#dedupe.get(key)
278
+ if (existing !== undefined) {
279
+ return existing
302
280
  }
303
281
 
282
+ // eslint-disable-next-line: no-unsafe-argument
283
+ const promise = Promise.resolve(this.#valueSelector(...args)).then(
284
+ (value) => {
285
+ if (this.#dedupe.get(key) === promise) {
286
+ this.#dedupe.delete(key)
287
+ this.#set(key, value)
288
+ }
289
+ return value
290
+ },
291
+ (err) => {
292
+ this.#dedupe.delete(key)
293
+ throw err
294
+ },
295
+ )
296
+ promise.catch(noop)
297
+ this.#dedupe.set(key, promise)
298
+
304
299
  return promise
305
300
  }
306
301
 
@@ -309,6 +304,8 @@ export class AsyncCache {
309
304
  throw new TypeError('key must be a non-empty string')
310
305
  }
311
306
 
307
+ this.#dedupe.delete(key)
308
+
312
309
  const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
313
310
  if (!Number.isFinite(ttlValue) || ttlValue < 0) {
314
311
  throw new TypeError('ttl must be nully or a positive integer')
@@ -358,6 +355,7 @@ export class AsyncCache {
358
355
  throw new TypeError('key must be a non-empty string')
359
356
  }
360
357
 
358
+ this.#dedupe.delete(key)
361
359
  this.#lru?.delete(key)
362
360
  try {
363
361
  this.#delQuery?.run(key)
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "@nxtedition/cache",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "files": [
8
- "lib"
8
+ "lib",
9
+ "README.md",
10
+ "LICENSE"
9
11
  ],
10
- "license": "UNLICENSED",
12
+ "license": "MIT",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
11
16
  "scripts": {
12
17
  "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/",
13
18
  "prepublishOnly": "yarn build",
14
19
  "typecheck": "tsc --noEmit",
15
20
  "test": "node --test",
16
- "test:ci": "node --test"
21
+ "test:ci": "node --test",
22
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=100"
17
23
  },
18
24
  "devDependencies": {
19
25
  "@types/node": "^25.2.3",
@@ -25,5 +31,5 @@
25
31
  "dependencies": {
26
32
  "lru-cache": "^11.2.6"
27
33
  },
28
- "gitHead": "1bcd9e44bf750ac6fb478967c46e784e14ea63c5"
34
+ "gitHead": "284d1bf697548d0bda69df9a240268da019b626f"
29
35
  }