@nxtedition/cache 1.0.0
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/lib/index.d.ts +35 -0
- package/lib/index.js +397 -0
- package/package.json +30 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { LRUCache } from 'lru-cache';
|
|
2
|
+
interface CacheEntry<V> {
|
|
3
|
+
ttl: number;
|
|
4
|
+
stale: number;
|
|
5
|
+
value: V;
|
|
6
|
+
}
|
|
7
|
+
export interface AsyncCacheDbOptions {
|
|
8
|
+
timeout?: number;
|
|
9
|
+
maxSize?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface AsyncCacheOptions<V> {
|
|
12
|
+
ttl?: number | ((value: V, key: string) => number);
|
|
13
|
+
stale?: number | ((value: V, key: string) => number);
|
|
14
|
+
lru?: LRUCache.Options<string, CacheEntry<V>, unknown> | false | null;
|
|
15
|
+
db?: AsyncCacheDbOptions;
|
|
16
|
+
}
|
|
17
|
+
export type CacheResult<V> = {
|
|
18
|
+
value: V;
|
|
19
|
+
async: false;
|
|
20
|
+
} | {
|
|
21
|
+
value: Promise<V> | null | undefined;
|
|
22
|
+
async: true;
|
|
23
|
+
};
|
|
24
|
+
export declare class AsyncCache<V = unknown> {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(location: string, valueSelector?: (...args: unknown[]) => V | Promise<V>, keySelector?: (...args: unknown[]) => string, opts?: AsyncCacheOptions<V>);
|
|
27
|
+
close(): void;
|
|
28
|
+
get(...args: unknown[]): CacheResult<V>;
|
|
29
|
+
peek(...args: unknown[]): CacheResult<V>;
|
|
30
|
+
refresh(...args: unknown[]): Promise<V> | undefined;
|
|
31
|
+
delete(key: string): void;
|
|
32
|
+
set(key: string, value: V): void;
|
|
33
|
+
purgeStale(): void;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { DatabaseSync, } from 'node:sqlite'
|
|
2
|
+
import { LRUCache } from 'lru-cache'
|
|
3
|
+
import { doYield } from '@nxtedition/yield'
|
|
4
|
+
|
|
5
|
+
let fastNowTime = 0
|
|
6
|
+
|
|
7
|
+
function fastNow() {
|
|
8
|
+
if (fastNowTime === 0) {
|
|
9
|
+
fastNowTime = Math.floor(Date.now() / 1e3) * 1e3
|
|
10
|
+
setInterval(() => {
|
|
11
|
+
fastNowTime = Math.floor(Date.now() / 1e3) * 1e3
|
|
12
|
+
}, 1e3).unref()
|
|
13
|
+
}
|
|
14
|
+
return fastNowTime
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function noop() {}
|
|
18
|
+
|
|
19
|
+
const dbs = new Set ()
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
const offPeakBC = new BroadcastChannel('nxt:offPeak')
|
|
23
|
+
offPeakBC.unref()
|
|
24
|
+
offPeakBC.onmessage = () => {
|
|
25
|
+
for (const db of dbs) {
|
|
26
|
+
try {
|
|
27
|
+
db.purgeStale()
|
|
28
|
+
} catch (err) {
|
|
29
|
+
process.emitWarning(err )
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
const VERSION = 2
|
|
71
|
+
const MAX_DURATION = 365000000e3
|
|
72
|
+
|
|
73
|
+
export class AsyncCache {
|
|
74
|
+
#lru
|
|
75
|
+
#valueSelector
|
|
76
|
+
#keySelector
|
|
77
|
+
#dedupe = new Map ()
|
|
78
|
+
|
|
79
|
+
#ttl
|
|
80
|
+
#stale
|
|
81
|
+
|
|
82
|
+
#db = null
|
|
83
|
+
#getQuery = null
|
|
84
|
+
#setQuery = null
|
|
85
|
+
#delQuery = null
|
|
86
|
+
#purgeStaleQuery = null
|
|
87
|
+
#evictQuery = null
|
|
88
|
+
|
|
89
|
+
#setQueue = []
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
location ,
|
|
93
|
+
valueSelector ,
|
|
94
|
+
keySelector ,
|
|
95
|
+
opts ,
|
|
96
|
+
) {
|
|
97
|
+
if (typeof location === 'string') {
|
|
98
|
+
// Do nothing...
|
|
99
|
+
} else {
|
|
100
|
+
throw new TypeError('location must be undefined or a string')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof valueSelector === 'function' || valueSelector === undefined) {
|
|
104
|
+
this.#valueSelector = valueSelector
|
|
105
|
+
} else {
|
|
106
|
+
throw new TypeError('valueSelector must be a function')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof keySelector === 'function' || keySelector === undefined) {
|
|
110
|
+
this.#keySelector = keySelector ?? ((...args ) => JSON.stringify(args))
|
|
111
|
+
} else {
|
|
112
|
+
throw new TypeError('keySelector must be a function')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof opts?.ttl === 'number' || opts?.ttl === undefined) {
|
|
116
|
+
const ttl = opts?.ttl ?? Number.MAX_SAFE_INTEGER
|
|
117
|
+
this.#ttl = (_val , _key ) => ttl
|
|
118
|
+
} else if (typeof opts?.ttl === 'function') {
|
|
119
|
+
this.#ttl = opts.ttl
|
|
120
|
+
} else {
|
|
121
|
+
throw new TypeError('ttl must be a undefined, number or a function')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof opts?.stale === 'number' || opts?.stale === undefined) {
|
|
125
|
+
const stale = opts?.stale ?? Number.MAX_SAFE_INTEGER
|
|
126
|
+
this.#stale = (_val , _key ) => stale
|
|
127
|
+
} else if (typeof opts?.stale === 'function') {
|
|
128
|
+
this.#stale = opts.stale
|
|
129
|
+
} else {
|
|
130
|
+
throw new TypeError('stale must be a undefined, number or a function')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.#lru =
|
|
134
|
+
opts?.lru === false || opts?.lru === undefined
|
|
135
|
+
? null
|
|
136
|
+
: new LRUCache({ max: 4096, ...opts?.lru })
|
|
137
|
+
|
|
138
|
+
for (let n = 0; true; n++) {
|
|
139
|
+
try {
|
|
140
|
+
this.#db ??= new DatabaseSync(location, { timeout: 20, ...opts?.db })
|
|
141
|
+
|
|
142
|
+
this.#db.exec(`
|
|
143
|
+
PRAGMA journal_mode = WAL;
|
|
144
|
+
PRAGMA synchronous = NORMAL;
|
|
145
|
+
PRAGMA temp_store = memory;
|
|
146
|
+
PRAGMA optimize;
|
|
147
|
+
|
|
148
|
+
CREATE TABLE IF NOT EXISTS cache_v${VERSION} (
|
|
149
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
150
|
+
val BLOB NOT NULL,
|
|
151
|
+
ttl INTEGER NOT NULL,
|
|
152
|
+
stale INTEGER NOT NULL
|
|
153
|
+
);
|
|
154
|
+
`)
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
const maxSize = opts?.db?.maxSize ?? 256 * 1024 * 1024
|
|
158
|
+
const { page_size } = this.#db.prepare('PRAGMA page_size').get()
|
|
159
|
+
this.#db.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.#getQuery = this.#db.prepare(
|
|
163
|
+
`SELECT val, ttl, stale FROM cache_v${VERSION} WHERE key = ? AND stale > ?`,
|
|
164
|
+
)
|
|
165
|
+
this.#setQuery = this.#db.prepare(
|
|
166
|
+
`INSERT OR REPLACE INTO cache_v${VERSION} (key, val, ttl, stale) VALUES (?, ?, ?, ?)`,
|
|
167
|
+
)
|
|
168
|
+
this.#delQuery = this.#db.prepare(`DELETE FROM cache_v${VERSION} WHERE key = ?`)
|
|
169
|
+
this.#purgeStaleQuery = this.#db.prepare(`DELETE FROM cache_v${VERSION} WHERE stale <= ?`)
|
|
170
|
+
this.#evictQuery = this.#db.prepare(
|
|
171
|
+
`DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
|
|
172
|
+
)
|
|
173
|
+
break
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (n >= 8) {
|
|
176
|
+
this.#db?.close()
|
|
177
|
+
this.#db = null
|
|
178
|
+
|
|
179
|
+
this.#getQuery = null
|
|
180
|
+
this.#setQuery = null
|
|
181
|
+
this.#delQuery = null
|
|
182
|
+
|
|
183
|
+
process.emitWarning(err )
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
dbs.add(this)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
close() {
|
|
193
|
+
dbs.delete(this)
|
|
194
|
+
this.#setQueue.length = 0
|
|
195
|
+
this.#getQuery = null
|
|
196
|
+
this.#setQuery = null
|
|
197
|
+
this.#delQuery = null
|
|
198
|
+
this.#purgeStaleQuery = null
|
|
199
|
+
this.#evictQuery = null
|
|
200
|
+
this.#db?.close()
|
|
201
|
+
this.#db = null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get(...args ) {
|
|
205
|
+
return this.#load(args, true)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
peek(...args ) {
|
|
209
|
+
return this.#load(args, false)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
refresh(...args ) {
|
|
213
|
+
return this.#refresh(args)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
delete(key ) {
|
|
217
|
+
this.#delete(key)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
set(key , value ) {
|
|
221
|
+
this.#set(key, value)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
purgeStale() {
|
|
225
|
+
try {
|
|
226
|
+
this.#lru?.purgeStale()
|
|
227
|
+
this.#purgeStaleQuery?.run(fastNow())
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#load(args , refresh ) {
|
|
232
|
+
const key = this.#keySelector(...args)
|
|
233
|
+
|
|
234
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
235
|
+
throw new TypeError('keySelector must return a non-empty string')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const now = fastNow()
|
|
239
|
+
|
|
240
|
+
let cached = this.#lru?.get(key)
|
|
241
|
+
|
|
242
|
+
if (cached === undefined) {
|
|
243
|
+
try {
|
|
244
|
+
const row = this.#getQuery?.get(key, now)
|
|
245
|
+
if (row !== undefined) {
|
|
246
|
+
cached = {
|
|
247
|
+
ttl: row.ttl,
|
|
248
|
+
stale: row.stale,
|
|
249
|
+
value: ArrayBuffer.isView(row.val) ? (row.val ) : JSON.parse(row.val),
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
const entry = this.#setQueue.findLast((x) => x.key === key)
|
|
253
|
+
if (entry !== undefined) {
|
|
254
|
+
cached = {
|
|
255
|
+
ttl: entry.ttl,
|
|
256
|
+
stale: entry.stale,
|
|
257
|
+
value: entry.value,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (cached !== undefined) {
|
|
263
|
+
this.#lru?.set(key, cached)
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
process.emitWarning(err )
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (cached !== undefined) {
|
|
271
|
+
if (now < cached.ttl) {
|
|
272
|
+
return { value: cached.value, async: false }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (now > cached.stale) {
|
|
276
|
+
// stale-while-revalidate has expired, purge cached value.
|
|
277
|
+
this.#lru?.delete(key)
|
|
278
|
+
try {
|
|
279
|
+
this.#delQuery?.run(key)
|
|
280
|
+
} catch {
|
|
281
|
+
// Do nothing...
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
cached = undefined
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const promise = refresh ? this.#refresh(args, key) : null
|
|
289
|
+
|
|
290
|
+
return cached !== undefined
|
|
291
|
+
? { value: cached.value, async: false }
|
|
292
|
+
: { value: promise, async: true }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// eslint-disable-next-line: no-unsafe-argument
|
|
296
|
+
#refresh(args , key = this.#keySelector(...args)) {
|
|
297
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
298
|
+
throw new TypeError('keySelector must return a non-empty string')
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let promise = this.#dedupe.get(key)
|
|
302
|
+
if (promise === undefined && this.#valueSelector) {
|
|
303
|
+
// eslint-disable-next-line: no-unsafe-argument
|
|
304
|
+
promise = Promise.resolve(this.#valueSelector(...args)).then(
|
|
305
|
+
(value) => {
|
|
306
|
+
if (this.#dedupe.delete(key)) {
|
|
307
|
+
this.#set(key, value)
|
|
308
|
+
}
|
|
309
|
+
return value
|
|
310
|
+
},
|
|
311
|
+
(err) => {
|
|
312
|
+
this.#delete(key)
|
|
313
|
+
throw err
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
promise.catch(noop)
|
|
317
|
+
this.#dedupe.set(key, promise)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return promise
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#set(key , value ) {
|
|
324
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
325
|
+
throw new TypeError('key must be a non-empty string')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const ttlValue = Math.min(MAX_DURATION, this.#ttl(value, key) ?? Infinity)
|
|
329
|
+
if (!Number.isFinite(ttlValue) || ttlValue < 0) {
|
|
330
|
+
throw new TypeError('ttl must be nully or a positive integer')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const staleValue = Math.min(MAX_DURATION, this.#stale(value, key) ?? 0)
|
|
334
|
+
if (!Number.isFinite(staleValue) || staleValue < 0) {
|
|
335
|
+
throw new TypeError('stale must be nully or a positive integer')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const now = fastNow()
|
|
339
|
+
const ttl = now + ttlValue
|
|
340
|
+
const stale = ttl + staleValue
|
|
341
|
+
|
|
342
|
+
if (stale <= now) {
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
this.#lru?.set(key, { ttl, stale, value })
|
|
347
|
+
|
|
348
|
+
this.#setQueue.push({ key, value, ttl, stale })
|
|
349
|
+
if (this.#setQueue.length === 1) {
|
|
350
|
+
doYield(this.#flushSetQueue)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#flushSetQueue = () => {
|
|
355
|
+
for (const { key, value, ttl, stale } of this.#setQueue.splice(0, 64)) {
|
|
356
|
+
const data = ArrayBuffer.isView(value)
|
|
357
|
+
? value
|
|
358
|
+
: JSON.stringify(value )
|
|
359
|
+
try {
|
|
360
|
+
this.#setQuery?.run(key, data , ttl, stale)
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if ((err )?.errcode === 13 /* SQLITE_FULL */) {
|
|
363
|
+
try {
|
|
364
|
+
this.#evictQuery?.run(256)
|
|
365
|
+
this.#setQuery?.run(key, data , ttl, stale)
|
|
366
|
+
} catch {
|
|
367
|
+
process.emitWarning(err )
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
process.emitWarning(err )
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (this.#setQueue.length > 0) {
|
|
376
|
+
doYield(this.#flushSetQueue)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#delete(key ) {
|
|
381
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
382
|
+
throw new TypeError('key must be a non-empty string')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.#lru?.delete(key)
|
|
386
|
+
|
|
387
|
+
if (this.#setQueue.some((x) => x.key === key)) {
|
|
388
|
+
this.#setQueue = this.#setQueue.filter((x) => x.key !== key)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
this.#delQuery?.run(key)
|
|
393
|
+
} catch (err) {
|
|
394
|
+
process.emitWarning(err )
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nxtedition/cache",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib"
|
|
9
|
+
],
|
|
10
|
+
"license": "UNLICENSED",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/",
|
|
13
|
+
"prepublishOnly": "yarn build",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"test": "node --test",
|
|
16
|
+
"test:ci": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^25.2.3",
|
|
20
|
+
"amaroc": "^1.0.1",
|
|
21
|
+
"oxlint-tsgolint": "^0.12.2",
|
|
22
|
+
"rimraf": "^6.1.2",
|
|
23
|
+
"typescript": "^5.9.3"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@nxtedition/yield": "^1.0.7",
|
|
27
|
+
"lru-cache": "^11.2.6"
|
|
28
|
+
},
|
|
29
|
+
"gitHead": "e85766435987febc99140e523cd2d4f5af458892"
|
|
30
|
+
}
|