@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 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
+ }