@nxtedition/cache 1.0.7 → 1.0.10

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/README.md CHANGED
@@ -54,21 +54,35 @@ if (result.async) {
54
54
 
55
55
  #### Options
56
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 |
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
+ | `memory` | `MemoryOptions \| false \| null` | `{ maxSize: 16MB, maxCount: 16384 }` | In-memory cache options, or `false`/`null` to disable in-memory caching. |
62
+ | `database` | `DatabaseOptions \| false \| null` | `{ timeout: 20, maxSize: 256MB }` | SQLite options, or `false`/`null` to disable persistence. |
63
+
64
+ #### `MemoryOptions`
65
+
66
+ | Option | Type | Default | Description |
67
+ | ---------- | -------- | -------------------------- | ---------------------------------------------- |
68
+ | `maxSize` | `number` | `16 * 1024 * 1024` (16 MB) | Maximum total size in bytes of cached entries. |
69
+ | `maxCount` | `number` | `16 * 1024` (16384) | Maximum number of entries in memory. |
70
+
71
+ #### `DatabaseOptions`
72
+
73
+ | Option | Type | Default | Description |
74
+ | --------- | -------- | ---------------------------- | ----------------------------------------------------------------- |
75
+ | `timeout` | `number` | `20` | SQLite busy timeout in milliseconds. |
76
+ | `maxSize` | `number` | `256 * 1024 * 1024` (256 MB) | Maximum database file size. Oldest entries are evicted when full. |
63
77
 
64
78
  ### `CacheResult<V>`
65
79
 
66
80
  Both `get()` and `peek()` return a `CacheResult<V>`, a discriminated union on the `async` property:
67
81
 
68
- | `async` | `value` | Meaning |
69
- | ------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
70
- | `false` | `V` | Cache hit — the value is available synchronously. Also returned for stale entries (a background refresh is triggered automatically). |
71
- | `true` | `Promise<V> \| undefined` | Cache miss — `value` is a `Promise` that resolves when the `valueSelector` completes, or `undefined` when called via `peek()` (which never triggers a fetch). |
82
+ | `async` | `value` | Meaning |
83
+ | ------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `false` | `V \| undefined` | Cache hit — the value is available synchronously. Also returned for stale entries (a background refresh is triggered automatically). `undefined` when `peek()` has no cached entry. |
85
+ | `true` | `Promise<V>` | Cache miss — `value` is a `Promise` that resolves when the `valueSelector` completes. |
72
86
 
73
87
  ```ts
74
88
  const result = cache.get('key')
@@ -90,7 +104,7 @@ Returns a cached value or triggers a fetch on cache miss.
90
104
 
91
105
  #### `cache.peek(...args): CacheResult<V>`
92
106
 
93
- Same as `get()` but does **not** trigger a refresh on cache miss. Returns `{ value: undefined, async: true }` for missing entries.
107
+ Same as `get()` but does **not** trigger a refresh on cache miss. Returns `{ value: undefined, async: false }` for missing entries.
94
108
 
95
109
  #### `cache.refresh(...args): Promise<V>`
96
110
 
@@ -102,7 +116,7 @@ Remove an entry from the cache. The cache key is derived from `args` via the `ke
102
116
 
103
117
  #### `cache.purgeStale(): void`
104
118
 
105
- Remove all expired entries from both the LRU cache and SQLite.
119
+ Remove all expired entries from both the in-memory cache and SQLite.
106
120
 
107
121
  #### `cache.close(): void`
108
122
 
package/lib/index.d.ts CHANGED
@@ -1,24 +1,20 @@
1
- import { LRUCache } from 'lru-cache';
2
- interface CacheEntry<V> {
3
- ttl: number;
4
- stale: number;
5
- value: V;
6
- }
7
- export interface AsyncCacheDbOptions {
1
+ import { type MemoryOptions } from './memory.ts';
2
+ export type { MemoryOptions } from './memory.ts';
3
+ export interface DatabaseOptions {
8
4
  timeout?: number;
9
5
  maxSize?: number;
10
6
  }
11
7
  export interface AsyncCacheOptions<V> {
12
8
  ttl?: number | ((value: V, key: string) => number);
13
9
  stale?: number | ((value: V, key: string) => number);
14
- lru?: LRUCache.Options<string, CacheEntry<V>, unknown> | false | null;
15
- db?: AsyncCacheDbOptions | false | null;
10
+ memory?: MemoryOptions | false | null;
11
+ database?: DatabaseOptions | false | null;
16
12
  }
17
13
  export type CacheResult<V> = {
18
- value: V;
14
+ value: V | undefined;
19
15
  async: false;
20
16
  } | {
21
- value: Promise<V> | undefined;
17
+ value: Promise<V>;
22
18
  async: true;
23
19
  };
24
20
  export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
@@ -31,4 +27,3 @@ export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
31
27
  delete(...args: A): void;
32
28
  purgeStale(): void;
33
29
  }
34
- export {};
package/lib/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { DatabaseSync, } from 'node:sqlite'
2
- import { LRUCache } from 'lru-cache'
2
+ import { MemoryCache, } from "./memory.js"
3
+
4
+
3
5
 
4
6
  let fastNowTime = 0
5
7
 
@@ -37,13 +39,13 @@ const dbs = new Set ()
37
39
 
38
40
 
39
41
 
40
-
42
+
41
43
 
42
44
 
43
45
 
44
46
 
45
47
 
46
-
48
+
47
49
 
48
50
 
49
51
 
@@ -51,27 +53,28 @@ const dbs = new Set ()
51
53
 
52
54
 
53
55
 
54
-
55
-
56
+
57
+
56
58
 
57
59
 
58
60
 
59
-
60
-
61
+
62
+
61
63
 
62
64
  const VERSION = 2
63
65
  const MAX_DURATION = 365000000e3
64
66
 
65
67
  export class AsyncCache {
66
- #lru
68
+ #memory
69
+ #dedupe = new Map ()
70
+
67
71
  #valueSelector
68
72
  #keySelector
69
- #dedupe = new Map ()
70
73
 
71
74
  #ttl
72
75
  #stale
73
76
 
74
- #db = null
77
+ #database = null
75
78
  #getQuery = null
76
79
  #setQuery = null
77
80
  #delQuery = null
@@ -116,15 +119,17 @@ export class AsyncCache {
116
119
  throw new TypeError('stale must be a undefined, number or a function')
117
120
  }
118
121
 
119
- this.#lru =
120
- opts?.lru === false || opts?.lru === null ? null : new LRUCache({ max: 4096, ...opts?.lru })
122
+ this.#memory =
123
+ opts?.memory === false || opts?.memory === null ? null : new MemoryCache(opts?.memory)
121
124
 
122
- for (let n = 0; opts?.db !== null && opts?.db !== false; n++) {
125
+ for (let n = 0; opts?.database !== null && opts?.database !== false; n++) {
123
126
  try {
124
- const { maxSize = 256 * 1024 * 1024, timeout = 20 } = opts?.db ?? {}
125
- this.#db ??= new DatabaseSync(location, { timeout })
127
+ const maxSize = opts?.database?.maxSize ?? 256 * 1024 * 1024
128
+ const timeout = opts?.database?.timeout ?? 20
129
+
130
+ this.#database ??= new DatabaseSync(location, { timeout })
126
131
 
127
- this.#db.exec(`
132
+ this.#database.exec(`
128
133
  PRAGMA journal_mode = WAL;
129
134
  PRAGMA synchronous = NORMAL;
130
135
  PRAGMA temp_store = memory;
@@ -139,26 +144,30 @@ export class AsyncCache {
139
144
  `)
140
145
 
141
146
  {
142
- const { page_size } = this.#db.prepare('PRAGMA page_size').get()
143
- this.#db.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
147
+ const { page_size } = this.#database.prepare('PRAGMA page_size').get()
148
+
149
+
150
+ this.#database.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
144
151
  }
145
152
 
146
- this.#getQuery = this.#db.prepare(
153
+ this.#getQuery = this.#database.prepare(
147
154
  `SELECT val, ttl, stale FROM cache_v${VERSION} WHERE key = ? AND stale > ?`,
148
155
  )
149
- this.#setQuery = this.#db.prepare(
156
+ this.#setQuery = this.#database.prepare(
150
157
  `INSERT OR REPLACE INTO cache_v${VERSION} (key, val, ttl, stale) VALUES (?, ?, ?, ?)`,
151
158
  )
152
- this.#delQuery = this.#db.prepare(`DELETE FROM cache_v${VERSION} WHERE key = ?`)
153
- this.#purgeStaleQuery = this.#db.prepare(`DELETE FROM cache_v${VERSION} WHERE stale <= ?`)
154
- this.#evictQuery = this.#db.prepare(
159
+ this.#delQuery = this.#database.prepare(`DELETE FROM cache_v${VERSION} WHERE key = ?`)
160
+ this.#purgeStaleQuery = this.#database.prepare(
161
+ `DELETE FROM cache_v${VERSION} WHERE stale <= ?`,
162
+ )
163
+ this.#evictQuery = this.#database.prepare(
155
164
  `DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
156
165
  )
157
166
  break
158
167
  } catch (err) {
159
- if (n >= 8) {
160
- this.#db?.close()
161
- this.#db = null
168
+ if (n >= 16) {
169
+ this.#database?.close()
170
+ this.#database = null
162
171
 
163
172
  this.#getQuery = null
164
173
  this.#setQuery = null
@@ -180,8 +189,8 @@ export class AsyncCache {
180
189
  this.#delQuery = null
181
190
  this.#purgeStaleQuery = null
182
191
  this.#evictQuery = null
183
- this.#db?.close()
184
- this.#db = null
192
+ this.#database?.close()
193
+ this.#database = null
185
194
  }
186
195
 
187
196
  get(...args ) {
@@ -197,12 +206,12 @@ export class AsyncCache {
197
206
  }
198
207
 
199
208
  delete(...args ) {
200
- this.#delete(args)
209
+ this.#delete(this.#keySelector(...args))
201
210
  }
202
211
 
203
212
  purgeStale() {
204
213
  try {
205
- this.#lru?.purgeStale()
214
+ this.#memory?.purgeStale(fastNow())
206
215
  this.#purgeStaleQuery?.run(fastNow())
207
216
  } catch {}
208
217
  }
@@ -216,13 +225,13 @@ export class AsyncCache {
216
225
 
217
226
  const now = fastNow()
218
227
 
219
- let cached = this.#lru?.get(key)
228
+ let cached = this.#memory?.get(key)
220
229
 
221
230
  if (cached === undefined) {
222
231
  try {
223
- const row = this.#getQuery?.get(key, now)
232
+ const row = this.#getQuery?.get(key, now)
224
233
  if (row !== undefined) {
225
- cached = {
234
+ const entry = {
226
235
  ttl: row.ttl,
227
236
  stale: row.stale,
228
237
  value: ArrayBuffer.isView(row.val)
@@ -232,11 +241,15 @@ export class AsyncCache {
232
241
  row.val.byteLength,
233
242
  ) )
234
243
  : JSON.parse(row.val),
244
+ key,
245
+ size:
246
+ (ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length) + key.length + 64,
247
+ index: -1,
248
+ counter: -1,
235
249
  }
236
- }
250
+ this.#memory?.set(key, entry)
237
251
 
238
- if (cached !== undefined) {
239
- this.#lru?.set(key, cached)
252
+ cached = entry
240
253
  }
241
254
  } catch (err) {
242
255
  process.emitWarning(err )
@@ -250,7 +263,7 @@ export class AsyncCache {
250
263
 
251
264
  if (now > cached.stale) {
252
265
  // stale-while-revalidate has expired, purge cached value.
253
- this.#lru?.delete(key)
266
+ this.#memory?.delete(key)
254
267
  try {
255
268
  this.#delQuery?.run(key)
256
269
  } catch {
@@ -263,9 +276,15 @@ export class AsyncCache {
263
276
 
264
277
  const promise = refresh ? this.#refresh(args, key) : undefined
265
278
 
266
- return cached !== undefined
267
- ? { value: cached.value, async: false }
268
- : { value: promise, async: true }
279
+ if (cached !== undefined) {
280
+ return { value: cached.value, async: false }
281
+ }
282
+
283
+ if (promise !== undefined) {
284
+ return { value: promise, async: true }
285
+ }
286
+
287
+ return { value: undefined, async: false }
269
288
  }
270
289
 
271
290
  #refresh(args , key = this.#keySelector(...args)) {
@@ -284,7 +303,11 @@ export class AsyncCache {
284
303
  (value) => {
285
304
  if (this.#dedupe.get(key) === promise) {
286
305
  this.#dedupe.delete(key)
287
- this.#set(key, value)
306
+ if (value === undefined) {
307
+ this.#delete(key)
308
+ } else {
309
+ this.#set(key, value)
310
+ }
288
311
  }
289
312
  return value
290
313
  },
@@ -324,16 +347,22 @@ export class AsyncCache {
324
347
  return
325
348
  }
326
349
 
327
- const storedValue = ArrayBuffer.isView(value)
328
- ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
329
- : value
330
-
331
- this.#lru?.set(key, { ttl, stale, value: storedValue })
332
-
333
350
  const data = ArrayBuffer.isView(value)
334
351
  ? value
335
352
  : JSON.stringify(value )
336
353
 
354
+ this.#memory?.set(key, {
355
+ ttl,
356
+ stale,
357
+ value: ArrayBuffer.isView(value)
358
+ ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
359
+ : value,
360
+ key,
361
+ size: (ArrayBuffer.isView(data) ? data.byteLength : data.length) + key.length + 64,
362
+ index: -1,
363
+ counter: -1,
364
+ })
365
+
337
366
  try {
338
367
  this.#setQuery?.run(key, data , ttl, stale)
339
368
  } catch (err) {
@@ -350,13 +379,13 @@ export class AsyncCache {
350
379
  }
351
380
  }
352
381
 
353
- #delete(args , key = this.#keySelector(...args)) {
382
+ #delete(key ) {
354
383
  if (typeof key !== 'string' || key.length === 0) {
355
384
  throw new TypeError('key must be a non-empty string')
356
385
  }
357
386
 
358
387
  this.#dedupe.delete(key)
359
- this.#lru?.delete(key)
388
+ this.#memory?.delete(key)
360
389
  try {
361
390
  this.#delQuery?.run(key)
362
391
  } catch (err) {
@@ -0,0 +1,21 @@
1
+ export interface MemoryOptions {
2
+ maxSize?: number;
3
+ maxCount?: number;
4
+ }
5
+ export interface MemoryCacheEntry<V> {
6
+ ttl: number;
7
+ stale: number;
8
+ value: V;
9
+ key: string;
10
+ index: number;
11
+ size: number;
12
+ counter: number;
13
+ }
14
+ export declare class MemoryCache<V> {
15
+ #private;
16
+ constructor(opts?: MemoryOptions);
17
+ set(key: string, entry: MemoryCacheEntry<V>): void;
18
+ get(key: string): MemoryCacheEntry<V> | undefined;
19
+ delete(key: string): void;
20
+ purgeStale(now: number): void;
21
+ }
package/lib/memory.js ADDED
@@ -0,0 +1,103 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+ export class MemoryCache {
17
+ #map = new Map()
18
+ #arr = []
19
+
20
+ #maxSize
21
+ #maxCount
22
+
23
+ #size = 0
24
+ #count = 0
25
+
26
+ #counter = 0
27
+
28
+ constructor(opts ) {
29
+ if (opts?.maxSize != null && (!Number.isInteger(opts.maxSize) || opts.maxSize < 1)) {
30
+ throw new Error('maxSize must be a positive integer')
31
+ }
32
+
33
+ if (opts?.maxCount != null && (!Number.isInteger(opts.maxCount) || opts.maxCount < 1)) {
34
+ throw new Error('maxCount must be a positive integer')
35
+ }
36
+
37
+ this.#maxSize = opts?.maxSize ?? 16 * 1024 * 1024
38
+ this.#maxCount = opts?.maxCount ?? 16 * 1024
39
+ }
40
+
41
+ set(key , entry ) {
42
+ const existing = this.#map.get(key)
43
+ if (existing != null) {
44
+ this.#delete(existing)
45
+ }
46
+
47
+ this.#map.set(key, entry)
48
+ entry.key = key
49
+ entry.index = this.#arr.push(entry) - 1
50
+ entry.counter = this.#counter++
51
+
52
+ this.#size += entry.size ?? 0
53
+ this.#count += 1
54
+
55
+ this.#prune()
56
+ }
57
+
58
+ get(key ) {
59
+ const entry = this.#map.get(key)
60
+ if (entry != null) {
61
+ entry.counter = this.#counter++
62
+ }
63
+ return entry
64
+ }
65
+
66
+ delete(key ) {
67
+ const entry = this.#map.get(key)
68
+ if (entry != null) {
69
+ this.#delete(entry)
70
+ }
71
+ }
72
+
73
+ #delete(entry ) {
74
+ this.#map.delete(entry.key)
75
+
76
+ this.#size -= entry.size
77
+ this.#count -= 1
78
+
79
+ const tmp = this.#arr.pop()
80
+ if (tmp !== entry) {
81
+ this.#arr[entry.index] = tmp
82
+ this.#arr[entry.index].index = entry.index
83
+ }
84
+ }
85
+
86
+ purgeStale(now ) {
87
+ for (let i = this.#arr.length - 1; i >= 0; i--) {
88
+ const entry = this.#arr[i]
89
+ if (now > entry.stale) {
90
+ this.#delete(entry)
91
+ }
92
+ }
93
+ }
94
+
95
+ #prune() {
96
+ while (this.#size > this.#maxSize || this.#count > this.#maxCount) {
97
+ const e1 = this.#arr[(Math.random() * this.#arr.length) | 0]
98
+ const e2 = this.#arr[(Math.random() * this.#arr.length) | 0]
99
+ const e = e2.counter > e1.counter ? e1 : e2
100
+ this.#delete(e)
101
+ }
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/cache",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -14,12 +14,12 @@
14
14
  "access": "public"
15
15
  },
16
16
  "scripts": {
17
- "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/",
17
+ "build": "rimraf lib && tsc && amaroc ./src/index.ts && mv src/index.js lib/ && amaroc ./src/memory.ts && mv src/memory.js lib/",
18
18
  "prepublishOnly": "yarn build",
19
19
  "typecheck": "tsc --noEmit",
20
- "test": "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"
20
+ "test": "node --test && yarn build",
21
+ "test:ci": "node --test && yarn build",
22
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=src/index.ts --test-coverage-include=src/memory.ts --test-coverage-lines=90 --test-coverage-branches=90 --test-coverage-functions=100"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/node": "^25.2.3",
@@ -28,8 +28,5 @@
28
28
  "rimraf": "^6.1.2",
29
29
  "typescript": "^5.9.3"
30
30
  },
31
- "dependencies": {
32
- "lru-cache": "^11.2.6"
33
- },
34
- "gitHead": "a113680af7f36b0262a5de692bbf57409a51b86b"
31
+ "gitHead": "17807cefcac092e20ebf99befddf0e742e5fc0e2"
35
32
  }