@nxtedition/cache 1.0.6 → 1.0.9

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,19 +104,19 @@ 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
 
97
111
  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.
98
112
 
99
- #### `cache.delete(key): void`
113
+ #### `cache.delete(...args): void`
100
114
 
101
- 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.
115
+ Remove an entry from the cache. The cache key is derived from `args` via the `keySelector`, just like `get()` and `refresh()`. Also cancels any in-flight deduplication for that key, meaning a pending fetch will not write its result to the cache.
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,21 @@
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, MemoryCacheEntry } from './memory.ts';
3
+ export { MemoryCache } from './memory.ts';
4
+ export interface DatabaseOptions {
8
5
  timeout?: number;
9
6
  maxSize?: number;
10
7
  }
11
8
  export interface AsyncCacheOptions<V> {
12
9
  ttl?: number | ((value: V, key: string) => number);
13
10
  stale?: number | ((value: V, key: string) => number);
14
- lru?: LRUCache.Options<string, CacheEntry<V>, unknown> | false | null;
15
- db?: AsyncCacheDbOptions | false | null;
11
+ memory?: MemoryOptions | false | null;
12
+ database?: DatabaseOptions | false | null;
16
13
  }
17
14
  export type CacheResult<V> = {
18
- value: V;
15
+ value: V | undefined;
19
16
  async: false;
20
17
  } | {
21
- value: Promise<V> | undefined;
18
+ value: Promise<V>;
22
19
  async: true;
23
20
  };
24
21
  export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
@@ -28,7 +25,6 @@ export declare class AsyncCache<V = unknown, A extends unknown[] = unknown[]> {
28
25
  get(...args: A): CacheResult<V>;
29
26
  peek(...args: A): CacheResult<V>;
30
27
  refresh(...args: A): Promise<V>;
31
- delete(key: string): void;
28
+ delete(...args: A): void;
32
29
  purgeStale(): void;
33
30
  }
34
- export {};
package/lib/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { DatabaseSync, } from 'node:sqlite'
2
- import { LRUCache } from 'lru-cache'
2
+ import { MemoryCache, } from "./memory.js"
3
+
4
+
5
+ export { MemoryCache } from './memory.ts'
3
6
 
4
7
  let fastNowTime = 0
5
8
 
@@ -37,13 +40,13 @@ const dbs = new Set ()
37
40
 
38
41
 
39
42
 
40
-
43
+
41
44
 
42
45
 
43
46
 
44
47
 
45
48
 
46
-
49
+
47
50
 
48
51
 
49
52
 
@@ -51,27 +54,28 @@ const dbs = new Set ()
51
54
 
52
55
 
53
56
 
54
-
55
-
57
+
58
+
56
59
 
57
60
 
58
61
 
59
-
60
-
62
+
63
+
61
64
 
62
65
  const VERSION = 2
63
66
  const MAX_DURATION = 365000000e3
64
67
 
65
68
  export class AsyncCache {
66
- #lru
69
+ #memory
70
+ #dedupe = new Map ()
71
+
67
72
  #valueSelector
68
73
  #keySelector
69
- #dedupe = new Map ()
70
74
 
71
75
  #ttl
72
76
  #stale
73
77
 
74
- #db = null
78
+ #database = null
75
79
  #getQuery = null
76
80
  #setQuery = null
77
81
  #delQuery = null
@@ -116,15 +120,17 @@ export class AsyncCache {
116
120
  throw new TypeError('stale must be a undefined, number or a function')
117
121
  }
118
122
 
119
- this.#lru =
120
- opts?.lru === false || opts?.lru === null ? null : new LRUCache({ max: 4096, ...opts?.lru })
123
+ this.#memory =
124
+ opts?.memory === false || opts?.memory === null ? null : new MemoryCache(opts?.memory)
121
125
 
122
- for (let n = 0; opts?.db !== null && opts?.db !== false; n++) {
126
+ for (let n = 0; opts?.database !== null && opts?.database !== false; n++) {
123
127
  try {
124
- const { maxSize = 256 * 1024 * 1024, timeout = 20 } = opts?.db ?? {}
125
- this.#db ??= new DatabaseSync(location, { timeout })
128
+ const maxSize = opts?.database?.maxSize ?? 256 * 1024 * 1024
129
+ const timeout = opts?.database?.timeout ?? 20
130
+
131
+ this.#database ??= new DatabaseSync(location, { timeout })
126
132
 
127
- this.#db.exec(`
133
+ this.#database.exec(`
128
134
  PRAGMA journal_mode = WAL;
129
135
  PRAGMA synchronous = NORMAL;
130
136
  PRAGMA temp_store = memory;
@@ -139,26 +145,30 @@ export class AsyncCache {
139
145
  `)
140
146
 
141
147
  {
142
- const { page_size } = this.#db.prepare('PRAGMA page_size').get()
143
- this.#db.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
148
+ const { page_size } = this.#database.prepare('PRAGMA page_size').get()
149
+
150
+
151
+ this.#database.exec(`PRAGMA max_page_count = ${Math.ceil(maxSize / page_size)}`)
144
152
  }
145
153
 
146
- this.#getQuery = this.#db.prepare(
154
+ this.#getQuery = this.#database.prepare(
147
155
  `SELECT val, ttl, stale FROM cache_v${VERSION} WHERE key = ? AND stale > ?`,
148
156
  )
149
- this.#setQuery = this.#db.prepare(
157
+ this.#setQuery = this.#database.prepare(
150
158
  `INSERT OR REPLACE INTO cache_v${VERSION} (key, val, ttl, stale) VALUES (?, ?, ?, ?)`,
151
159
  )
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(
160
+ this.#delQuery = this.#database.prepare(`DELETE FROM cache_v${VERSION} WHERE key = ?`)
161
+ this.#purgeStaleQuery = this.#database.prepare(
162
+ `DELETE FROM cache_v${VERSION} WHERE stale <= ?`,
163
+ )
164
+ this.#evictQuery = this.#database.prepare(
155
165
  `DELETE FROM cache_v${VERSION} WHERE key IN (SELECT key FROM cache_v${VERSION} ORDER BY stale ASC LIMIT ?)`,
156
166
  )
157
167
  break
158
168
  } catch (err) {
159
- if (n >= 8) {
160
- this.#db?.close()
161
- this.#db = null
169
+ if (n >= 16) {
170
+ this.#database?.close()
171
+ this.#database = null
162
172
 
163
173
  this.#getQuery = null
164
174
  this.#setQuery = null
@@ -180,8 +190,8 @@ export class AsyncCache {
180
190
  this.#delQuery = null
181
191
  this.#purgeStaleQuery = null
182
192
  this.#evictQuery = null
183
- this.#db?.close()
184
- this.#db = null
193
+ this.#database?.close()
194
+ this.#database = null
185
195
  }
186
196
 
187
197
  get(...args ) {
@@ -196,13 +206,13 @@ export class AsyncCache {
196
206
  return this.#refresh(args)
197
207
  }
198
208
 
199
- delete(key ) {
200
- this.#delete(key)
209
+ delete(...args ) {
210
+ this.#delete(this.#keySelector(...args))
201
211
  }
202
212
 
203
213
  purgeStale() {
204
214
  try {
205
- this.#lru?.purgeStale()
215
+ this.#memory?.purgeStale(fastNow())
206
216
  this.#purgeStaleQuery?.run(fastNow())
207
217
  } catch {}
208
218
  }
@@ -216,13 +226,13 @@ export class AsyncCache {
216
226
 
217
227
  const now = fastNow()
218
228
 
219
- let cached = this.#lru?.get(key)
229
+ let cached = this.#memory?.get(key)
220
230
 
221
231
  if (cached === undefined) {
222
232
  try {
223
- const row = this.#getQuery?.get(key, now)
233
+ const row = this.#getQuery?.get(key, now)
224
234
  if (row !== undefined) {
225
- cached = {
235
+ const entry = {
226
236
  ttl: row.ttl,
227
237
  stale: row.stale,
228
238
  value: ArrayBuffer.isView(row.val)
@@ -232,11 +242,15 @@ export class AsyncCache {
232
242
  row.val.byteLength,
233
243
  ) )
234
244
  : JSON.parse(row.val),
245
+ key,
246
+ size:
247
+ (ArrayBuffer.isView(row.val) ? row.val.byteLength : row.val.length) + key.length + 64,
248
+ index: -1,
249
+ counter: -1,
235
250
  }
236
- }
251
+ this.#memory?.set(key, entry)
237
252
 
238
- if (cached !== undefined) {
239
- this.#lru?.set(key, cached)
253
+ cached = entry
240
254
  }
241
255
  } catch (err) {
242
256
  process.emitWarning(err )
@@ -250,7 +264,7 @@ export class AsyncCache {
250
264
 
251
265
  if (now > cached.stale) {
252
266
  // stale-while-revalidate has expired, purge cached value.
253
- this.#lru?.delete(key)
267
+ this.#memory?.delete(key)
254
268
  try {
255
269
  this.#delQuery?.run(key)
256
270
  } catch {
@@ -263,12 +277,18 @@ export class AsyncCache {
263
277
 
264
278
  const promise = refresh ? this.#refresh(args, key) : undefined
265
279
 
266
- return cached !== undefined
267
- ? { value: cached.value, async: false }
268
- : { value: promise, async: true }
280
+ if (cached !== undefined) {
281
+ return { value: cached.value, async: false }
282
+ }
283
+
284
+ if (promise !== undefined) {
285
+ return { value: promise, async: true }
286
+ }
287
+
288
+ return { value: undefined, async: false }
269
289
  }
270
290
 
271
- #refresh(args , key = this.#keySelector(...args)) {
291
+ #refresh(args , key = this.#keySelector(...args)) {
272
292
  if (typeof key !== 'string' || key.length === 0) {
273
293
  throw new TypeError('keySelector must return a non-empty string')
274
294
  }
@@ -284,7 +304,11 @@ export class AsyncCache {
284
304
  (value) => {
285
305
  if (this.#dedupe.get(key) === promise) {
286
306
  this.#dedupe.delete(key)
287
- this.#set(key, value)
307
+ if (value === undefined) {
308
+ this.#delete(key)
309
+ } else {
310
+ this.#set(key, value)
311
+ }
288
312
  }
289
313
  return value
290
314
  },
@@ -324,16 +348,22 @@ export class AsyncCache {
324
348
  return
325
349
  }
326
350
 
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
351
  const data = ArrayBuffer.isView(value)
334
352
  ? value
335
353
  : JSON.stringify(value )
336
354
 
355
+ this.#memory?.set(key, {
356
+ ttl,
357
+ stale,
358
+ value: ArrayBuffer.isView(value)
359
+ ? (Buffer.from(value.buffer, value.byteOffset, value.byteLength) )
360
+ : value,
361
+ key,
362
+ size: (ArrayBuffer.isView(data) ? data.byteLength : data.length) + key.length + 64,
363
+ index: -1,
364
+ counter: -1,
365
+ })
366
+
337
367
  try {
338
368
  this.#setQuery?.run(key, data , ttl, stale)
339
369
  } catch (err) {
@@ -356,7 +386,7 @@ export class AsyncCache {
356
386
  }
357
387
 
358
388
  this.#dedupe.delete(key)
359
- this.#lru?.delete(key)
389
+ this.#memory?.delete(key)
360
390
  try {
361
391
  this.#delQuery?.run(key)
362
392
  } 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.6",
3
+ "version": "1.0.9",
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": "5c8b45723e68c527888e75a1536569479da7e4c5"
31
+ "gitHead": "0dd08f59b4d41fc8b3038386cfbd5adf970863c7"
35
32
  }