@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 +21 -0
- package/README.md +133 -0
- package/lib/index.d.ts +2 -3
- package/lib/index.js +34 -36
- 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,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
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
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.
|
|
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": "
|
|
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": "284d1bf697548d0bda69df9a240268da019b626f"
|
|
29
35
|
}
|