@nxtedition/cache 1.0.2 → 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.js +4 -2
- 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.js
CHANGED
|
@@ -287,13 +287,14 @@ export class AsyncCache {
|
|
|
287
287
|
// eslint-disable-next-line: no-unsafe-argument
|
|
288
288
|
promise = Promise.resolve(this.#valueSelector(...args)).then(
|
|
289
289
|
(value) => {
|
|
290
|
-
if (this.#dedupe.
|
|
290
|
+
if (this.#dedupe.get(key) === promise) {
|
|
291
|
+
this.#dedupe.delete(key)
|
|
291
292
|
this.#set(key, value)
|
|
292
293
|
}
|
|
293
294
|
return value
|
|
294
295
|
},
|
|
295
296
|
(err) => {
|
|
296
|
-
this.#delete(key)
|
|
297
|
+
this.#dedupe.delete(key)
|
|
297
298
|
throw err
|
|
298
299
|
},
|
|
299
300
|
)
|
|
@@ -358,6 +359,7 @@ export class AsyncCache {
|
|
|
358
359
|
throw new TypeError('key must be a non-empty string')
|
|
359
360
|
}
|
|
360
361
|
|
|
362
|
+
this.#dedupe.delete(key)
|
|
361
363
|
this.#lru?.delete(key)
|
|
362
364
|
try {
|
|
363
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
|
}
|