@nxtedition/cache 1.0.1 → 1.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/LICENSE +21 -0
- package/README.md +137 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +15 -11
- package/package.json +11 -5
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,137 @@
|
|
|
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>` | Optional function to fetch a value on cache miss |
|
|
52
|
+
| `keySelector` | `(...args) => string` | Optional function to derive a cache key from arguments. Defaults to `JSON.stringify(args)` |
|
|
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. If no `valueSelector` was provided, `value` is `undefined`.
|
|
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> | undefined`
|
|
83
|
+
|
|
84
|
+
Forces a fetch via `valueSelector` regardless of cache state. Returns `undefined` if no `valueSelector` is configured. Concurrent calls for the same key are deduplicated.
|
|
85
|
+
|
|
86
|
+
#### `cache.set(key, value): void`
|
|
87
|
+
|
|
88
|
+
Manually set a value in the cache.
|
|
89
|
+
|
|
90
|
+
#### `cache.delete(key): void`
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
#### `cache.purgeStale(): void`
|
|
95
|
+
|
|
96
|
+
Remove all expired entries from both the LRU cache and SQLite.
|
|
97
|
+
|
|
98
|
+
#### `cache.close(): void`
|
|
99
|
+
|
|
100
|
+
Close the SQLite database and release resources.
|
|
101
|
+
|
|
102
|
+
## Deduplication
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// valueSelector is called once, both promises resolve to the same value
|
|
108
|
+
const [a, b] = await Promise.all([cache.get('key').value, cache.get('key').value])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
If a fetch fails, the deduplication entry is cleaned up and subsequent calls will retry.
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
## Stale-While-Revalidate
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
Once the stale window expires, the entry is purged entirely and the next `get()` returns `async: true`.
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|--- ttl ---|--- stale ---|
|
|
123
|
+
fresh stale expired
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Off-Peak Purge
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
## Scripts
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
npm test # run tests
|
|
134
|
+
npm run test:coverage # run tests with branch coverage report (90%+ enforced)
|
|
135
|
+
npm run typecheck # type-check without emitting
|
|
136
|
+
npm run build # build for publishing
|
|
137
|
+
```
|
package/lib/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface AsyncCacheOptions<V> {
|
|
|
12
12
|
ttl?: number | ((value: V, key: string) => number);
|
|
13
13
|
stale?: number | ((value: V, key: string) => number);
|
|
14
14
|
lru?: LRUCache.Options<string, CacheEntry<V>, unknown> | false | null;
|
|
15
|
-
db?: AsyncCacheDbOptions;
|
|
15
|
+
db?: AsyncCacheDbOptions | false | null;
|
|
16
16
|
}
|
|
17
17
|
export type CacheResult<V> = {
|
|
18
18
|
value: V;
|
package/lib/index.js
CHANGED
|
@@ -52,7 +52,7 @@ const dbs = new Set ()
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
|
|
@@ -121,13 +121,12 @@ export class AsyncCache {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
this.#lru =
|
|
124
|
-
opts?.lru === false || opts?.lru ===
|
|
125
|
-
? null
|
|
126
|
-
: new LRUCache({ max: 4096, ...opts?.lru })
|
|
124
|
+
opts?.lru === false || opts?.lru === null ? null : new LRUCache({ max: 4096, ...opts?.lru })
|
|
127
125
|
|
|
128
|
-
for (let n = 0;
|
|
126
|
+
for (let n = 0; opts?.db !== null && opts?.db !== false; n++) {
|
|
129
127
|
try {
|
|
130
|
-
|
|
128
|
+
const { maxSize = 256 * 1024 * 1024, timeout = 20 } = opts?.db ?? {}
|
|
129
|
+
this.#db ??= new DatabaseSync(location, { timeout })
|
|
131
130
|
|
|
132
131
|
this.#db.exec(`
|
|
133
132
|
PRAGMA journal_mode = WAL;
|
|
@@ -144,7 +143,6 @@ export class AsyncCache {
|
|
|
144
143
|
`)
|
|
145
144
|
|
|
146
145
|
{
|
|
147
|
-
const maxSize = opts?.db?.maxSize ?? 256 * 1024 * 1024
|
|
148
146
|
const { page_size } = this.#db.prepare('PRAGMA page_size').get()
|
|
149
147
|
this.#db.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
|
|
150
148
|
}
|
|
@@ -278,24 +276,25 @@ export class AsyncCache {
|
|
|
278
276
|
: { value: promise, async: true }
|
|
279
277
|
}
|
|
280
278
|
|
|
281
|
-
// eslint-disable-next-line: no-unsafe-argument
|
|
282
279
|
#refresh(args , key = this.#keySelector(...args)) {
|
|
283
280
|
if (typeof key !== 'string' || key.length === 0) {
|
|
284
281
|
throw new TypeError('keySelector must return a non-empty string')
|
|
285
282
|
}
|
|
286
283
|
|
|
284
|
+
// TODO (fix): cross process/thread dedupe...
|
|
287
285
|
let promise = this.#dedupe.get(key)
|
|
288
286
|
if (promise === undefined && this.#valueSelector) {
|
|
289
287
|
// eslint-disable-next-line: no-unsafe-argument
|
|
290
288
|
promise = Promise.resolve(this.#valueSelector(...args)).then(
|
|
291
289
|
(value) => {
|
|
292
|
-
if (this.#dedupe.
|
|
290
|
+
if (this.#dedupe.get(key) === promise) {
|
|
291
|
+
this.#dedupe.delete(key)
|
|
293
292
|
this.#set(key, value)
|
|
294
293
|
}
|
|
295
294
|
return value
|
|
296
295
|
},
|
|
297
296
|
(err) => {
|
|
298
|
-
this.#delete(key)
|
|
297
|
+
this.#dedupe.delete(key)
|
|
299
298
|
throw err
|
|
300
299
|
},
|
|
301
300
|
)
|
|
@@ -329,7 +328,11 @@ export class AsyncCache {
|
|
|
329
328
|
return
|
|
330
329
|
}
|
|
331
330
|
|
|
332
|
-
|
|
331
|
+
const storedValue = ArrayBuffer.isView(value)
|
|
332
|
+
? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
|
|
333
|
+
: value
|
|
334
|
+
|
|
335
|
+
this.#lru?.set(key, { ttl, stale, value: storedValue })
|
|
333
336
|
|
|
334
337
|
const data = ArrayBuffer.isView(value)
|
|
335
338
|
? value
|
|
@@ -356,6 +359,7 @@ export class AsyncCache {
|
|
|
356
359
|
throw new TypeError('key must be a non-empty string')
|
|
357
360
|
}
|
|
358
361
|
|
|
362
|
+
this.#dedupe.delete(key)
|
|
359
363
|
this.#lru?.delete(key)
|
|
360
364
|
try {
|
|
361
365
|
this.#delQuery?.run(key)
|
package/package.json
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxtedition/cache",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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": "
|
|
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": "
|
|
34
|
+
"gitHead": "f3a0afdf05d4d395ba8bdb45c48f619da7491d28"
|
|
29
35
|
}
|