@ix-xs/node-comfort 2.0.0 → 2.1.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Cache.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * users.stats; // { hits: 12, misses: 3, hitRate: 0.8, ... }
12
12
  */
13
13
 
14
- const { parseDuration } = require("./Time.js");
14
+ const { toMs, argsKey } = require("./internal/shared.js");
15
15
 
16
16
  /**
17
17
  * Why an entry left the cache.
@@ -25,6 +25,7 @@ const { parseDuration } = require("./Time.js");
25
25
  * @typedef {object} CacheOptions
26
26
  * @property {number} [max] Maximum number of entries. When full, the least recently used goes. No limit by default.
27
27
  * @property {number | string} [ttl] How long entries live, in ms or like `"10m"`. `0` means forever, the default.
28
+ * @property {number | string} [staleWhileRevalidate] After an entry expires, `getOrSet()` may still return it for this long while it loads a new value in the background.
28
29
  * @property {boolean} [updateAgeOnGet] Reading an entry restarts its time to live.
29
30
  * @property {(key: K, value: V, reason: CacheRemovalReason) => void} [onRemove] Called whenever an entry leaves the cache.
30
31
  */
@@ -33,12 +34,13 @@ const { parseDuration } = require("./Time.js");
33
34
  * Options for `set()` and `getOrSet()`.
34
35
  * @typedef {object} CacheSetOptions
35
36
  * @property {number | string} [ttl] Time to live of this entry. `0` means forever.
37
+ * @property {number | string} [staleWhileRevalidate] How long this entry can be served by `getOrSet()` after it expires, while a new value loads.
36
38
  */
37
39
 
38
40
  /**
39
41
  * Cache stats.
40
42
  * @typedef {object} CacheStats
41
- * @property {number} hits Reads that found a fresh entry.
43
+ * @property {number} hits Reads that found a usable entry.
42
44
  * @property {number} misses Reads that found nothing or an expired entry.
43
45
  * @property {number} sets
44
46
  * @property {number} evictions Entries dropped because the cache was full.
@@ -46,17 +48,17 @@ const { parseDuration } = require("./Time.js");
46
48
  * @property {number} hitRate From 0 to 1.
47
49
  */
48
50
 
51
+ /**
52
+ * @template V
53
+ * @typedef {{ value: V, expires: number, ttl: number, stale: number }} CacheEntry
54
+ */
55
+
49
56
  /**
50
57
  * @param {number | string | undefined} value
58
+ * @param {string} what
51
59
  * @returns {number}
52
60
  */
53
- const _ttl = (value) => {
54
- if (value === undefined) return 0;
55
- if (typeof value === "number") return Math.max(0, value);
56
- const ms = parseDuration(value);
57
- if (ms === null) throw new TypeError(`Invalid ttl: ${value}`);
58
- return ms;
59
- };
61
+ const _duration = (value, what) => (value === undefined ? 0 : Math.max(0, toMs(value, what)));
60
62
 
61
63
  /**
62
64
  * An in-memory cache with a size limit and expiry.
@@ -65,16 +67,24 @@ const _ttl = (value) => {
65
67
  * @template V
66
68
  */
67
69
  class Cache {
68
- /** @type {Map<K, { value: V, expires: number, ttl: number }>} */
70
+ /** @type {Map<K, CacheEntry<V>>} */
69
71
  #map = new Map();
70
72
  /** @type {Map<K, Promise<V>>} */
71
73
  #loading = new Map();
72
74
  #max;
73
75
  #ttl;
76
+ #stale;
74
77
  #updateAgeOnGet;
75
78
  /** @type {((key: K, value: V, reason: CacheRemovalReason) => void) | undefined} */
76
79
  #onRemove;
77
80
  #stats = { hits: 0, misses: 0, sets: 0, evictions: 0, expirations: 0 };
81
+ /**
82
+ * The most recently used key, so reading it again doesn't reorder anything.
83
+ * @type {K | undefined}
84
+ */
85
+ #newest = undefined;
86
+ #hasNewest = false;
87
+ #sweepAt = 1024;
78
88
 
79
89
  /**
80
90
  * @example
@@ -84,7 +94,8 @@ class Cache {
84
94
  */
85
95
  constructor(options = {}) {
86
96
  this.#max = Math.max(1, options.max ?? Infinity);
87
- this.#ttl = _ttl(options.ttl);
97
+ this.#ttl = _duration(options.ttl, "ttl");
98
+ this.#stale = _duration(options.staleWhileRevalidate, "staleWhileRevalidate");
88
99
  this.#updateAgeOnGet = options.updateAgeOnGet ?? false;
89
100
  this.#onRemove = options.onRemove;
90
101
  }
@@ -96,25 +107,43 @@ class Cache {
96
107
  */
97
108
  #remove(key, entry, reason) {
98
109
  this.#map.delete(key);
110
+ if (this.#hasNewest && this.#newest === key) this.#hasNewest = false;
99
111
  if (reason === "expire") this.#stats.expirations++;
100
112
  if (reason === "evict") this.#stats.evictions++;
101
113
  this.#onRemove?.(key, entry.value, reason);
102
114
  }
103
115
 
104
116
  /**
105
- * The entry if it exists and is fresh. Expired entries are removed.
117
+ * The entry if it exists and is fresh. Expired entries are removed, unless
118
+ * `getOrSet()` may still serve them while it reloads.
106
119
  * @param {K} key
107
120
  */
108
121
  #fresh(key) {
109
122
  const entry = this.#map.get(key);
110
123
  if (!entry) return undefined;
111
- if (entry.expires && entry.expires <= Date.now()) {
112
- this.#remove(key, entry, "expire");
113
- return undefined;
124
+ if (entry.expires) {
125
+ const now = Date.now();
126
+ if (entry.expires <= now) {
127
+ if (entry.stale <= now) this.#remove(key, entry, "expire");
128
+ return undefined;
129
+ }
114
130
  }
115
131
  return entry;
116
132
  }
117
133
 
134
+ /**
135
+ * Moves an entry to the "most recently used" end.
136
+ * @param {K} key
137
+ * @param {CacheEntry<V>} entry
138
+ */
139
+ #touch(key, entry) {
140
+ if (this.#hasNewest && this.#newest === key) return;
141
+ this.#map.delete(key);
142
+ this.#map.set(key, entry);
143
+ this.#newest = key;
144
+ this.#hasNewest = true;
145
+ }
146
+
118
147
  /**
119
148
  * Reads a value and marks it as recently used.
120
149
  *
@@ -128,9 +157,12 @@ class Cache {
128
157
  return undefined;
129
158
  }
130
159
  this.#stats.hits++;
131
- this.#map.delete(key);
132
- if (this.#updateAgeOnGet && entry.ttl) entry.expires = Date.now() + entry.ttl;
133
- this.#map.set(key, entry);
160
+ if (this.#updateAgeOnGet && entry.ttl) {
161
+ const now = Date.now();
162
+ entry.stale += now + entry.ttl - entry.expires;
163
+ entry.expires = now + entry.ttl;
164
+ }
165
+ this.#touch(key, entry);
134
166
  return entry.value;
135
167
  }
136
168
 
@@ -146,7 +178,8 @@ class Cache {
146
178
 
147
179
  /**
148
180
  * Stores a value. When the cache is full, the least recently used entry
149
- * goes.
181
+ * goes. A value being loaded for this key by `getOrSet()` is not stored
182
+ * when it arrives: the one you set stays.
150
183
  *
151
184
  * @example
152
185
  * cache.set("token", token, { ttl: "15m" });
@@ -157,14 +190,25 @@ class Cache {
157
190
  * @returns {this}
158
191
  */
159
192
  set(key, value, options = {}) {
160
- const ttl = options.ttl === undefined ? this.#ttl : _ttl(options.ttl);
193
+ this.#loading.delete(key);
194
+ const ttl = options.ttl === undefined ? this.#ttl : _duration(options.ttl, "ttl");
195
+ const grace = options.staleWhileRevalidate === undefined ? this.#stale : _duration(options.staleWhileRevalidate, "staleWhileRevalidate");
161
196
  const previous = this.#map.get(key);
162
197
  if (previous) this.#remove(key, previous, "set");
163
- this.#map.set(key, { value, ttl, expires: ttl ? Date.now() + ttl : 0 });
198
+ const expires = ttl ? Date.now() + ttl : 0;
199
+ this.#map.set(key, { value, ttl, expires, stale: expires ? expires + grace : 0 });
200
+ this.#newest = key;
201
+ this.#hasNewest = true;
164
202
  this.#stats.sets++;
165
- while (this.#map.size > this.#max) {
166
- const [oldestKey, oldest] = /** @type {[K, { value: V }]} */ (this.#map.entries().next().value);
167
- this.#remove(oldestKey, oldest, "evict");
203
+ if (this.#map.size > this.#max) {
204
+ while (this.#map.size > this.#max) {
205
+ const [oldestKey, oldest] = /** @type {[K, { value: V }]} */ (this.#map.entries().next().value);
206
+ this.#remove(oldestKey, oldest, "evict");
207
+ }
208
+ } else if (expires && this.#max === Infinity && this.#map.size >= this.#sweepAt) {
209
+ // Without a size limit, entries nobody reads again would stay forever.
210
+ this.prune();
211
+ this.#sweepAt = Math.max(1024, this.#map.size * 2);
168
212
  }
169
213
  return this;
170
214
  }
@@ -174,27 +218,62 @@ class Cache {
174
218
  * returns it. Simultaneous calls for the same key share one `loader` call,
175
219
  * and a failed load caches nothing.
176
220
  *
221
+ * With `staleWhileRevalidate`, an entry that just expired is returned at
222
+ * once while a new value loads in the background, so callers never wait
223
+ * on a refresh.
224
+ *
177
225
  * @example
178
226
  * const profile = await cache.getOrSet(`profile:${id}`, () => api.fetchProfile(id), { ttl: "10m" });
227
+ * const rates = await cache.getOrSet("rates", fetchRates, { ttl: "1m", staleWhileRevalidate: "10m" });
179
228
  *
180
229
  * @param {K} key
181
230
  * @param {() => V | PromiseLike<V>} loader
182
231
  * @param {CacheSetOptions} [options]
183
232
  * @returns {Promise<V>}
184
233
  */
185
- async getOrSet(key, loader, options = {}) {
186
- const entry = this.#fresh(key);
187
- if (entry) return /** @type {V} */ (this.get(key));
234
+ getOrSet(key, loader, options = {}) {
235
+ const entry = this.#map.get(key);
236
+ if (entry) {
237
+ const now = entry.expires ? Date.now() : 0;
238
+ if (!entry.expires || entry.expires > now) return Promise.resolve(/** @type {V} */ (this.get(key)));
239
+ if (entry.stale > now) {
240
+ this.#stats.hits++;
241
+ // The stale value is good enough if the refresh fails; the next call tries again.
242
+ if (!this.#loading.has(key)) this.#load(key, loader, options).catch(() => {});
243
+ return Promise.resolve(entry.value);
244
+ }
245
+ this.#remove(key, entry, "expire");
246
+ }
188
247
  const inflight = this.#loading.get(key);
189
248
  if (inflight) return inflight;
190
249
  this.#stats.misses++;
250
+ return this.#load(key, loader, options);
251
+ }
252
+
253
+ /**
254
+ * @param {K} key
255
+ * @param {() => V | PromiseLike<V>} loader
256
+ * @param {CacheSetOptions} options
257
+ * @returns {Promise<V>}
258
+ */
259
+ #load(key, loader, options) {
260
+ /** @type {Promise<V>} */
191
261
  const promise = Promise.resolve()
192
262
  .then(loader)
193
- .then((value) => {
194
- this.set(key, value, options);
195
- return value;
196
- })
197
- .finally(() => this.#loading.delete(key));
263
+ .then(
264
+ (value) => {
265
+ // A delete() or clear() during the load means the result is no longer wanted.
266
+ if (this.#loading.get(key) === promise) {
267
+ this.#loading.delete(key);
268
+ this.set(key, value, options);
269
+ }
270
+ return value;
271
+ },
272
+ (error) => {
273
+ if (this.#loading.get(key) === promise) this.#loading.delete(key);
274
+ throw error;
275
+ },
276
+ );
198
277
  this.#loading.set(key, promise);
199
278
  return promise;
200
279
  }
@@ -210,12 +289,14 @@ class Cache {
210
289
  }
211
290
 
212
291
  /**
213
- * Removes an entry.
292
+ * Removes an entry. A value being loaded for this key by `getOrSet()` is
293
+ * not stored when it arrives.
214
294
  *
215
295
  * @param {K} key
216
296
  * @returns {boolean} `true` if there was one.
217
297
  */
218
298
  delete(key) {
299
+ this.#loading.delete(key);
219
300
  const entry = this.#map.get(key);
220
301
  if (!entry) return false;
221
302
  this.#remove(key, entry, "delete");
@@ -228,6 +309,7 @@ class Cache {
228
309
  * @returns {void}
229
310
  */
230
311
  clear() {
312
+ this.#loading.clear();
231
313
  for (const [key, entry] of [...this.#map]) this.#remove(key, entry, "clear");
232
314
  }
233
315
 
@@ -239,8 +321,8 @@ class Cache {
239
321
  prune() {
240
322
  let removed = 0;
241
323
  const now = Date.now();
242
- for (const [key, entry] of [...this.#map]) {
243
- if (entry.expires && entry.expires <= now) {
324
+ for (const [key, entry] of this.#map) {
325
+ if (entry.expires && entry.stale <= now) {
244
326
  this.#remove(key, entry, "expire");
245
327
  removed++;
246
328
  }
@@ -269,13 +351,14 @@ class Cache {
269
351
  *
270
352
  * @template {any[]} A
271
353
  * @param {(...args: A) => V | PromiseLike<V>} fn
272
- * @param {{ key?: (...args: A) => K, ttl?: number | string }} [options] `key` builds the cache key from the arguments; by default they're turned into JSON.
354
+ * @param {{ key?: (...args: A) => K } & CacheSetOptions} [options] `key` builds the cache key from the arguments. By default, primitives are compared by value and plain objects by content.
273
355
  * @returns {(...args: A) => Promise<V>}
274
356
  */
275
357
  wrap(fn, options = {}) {
358
+ const settings = { ttl: options.ttl, staleWhileRevalidate: options.staleWhileRevalidate };
276
359
  return (...args) => {
277
- const key = options.key ? options.key(...args) : /** @type {K} */ (/** @type {unknown} */ (JSON.stringify(args)));
278
- return this.getOrSet(key, () => fn(...args), { ttl: options.ttl });
360
+ const key = options.key ? options.key(...args) : /** @type {K} */ (argsKey(args));
361
+ return this.getOrSet(key, () => fn(...args), settings);
279
362
  };
280
363
  }
281
364
 
@@ -290,13 +373,36 @@ class Cache {
290
373
  return /** @type {CacheStats} */ ({ ...this.#stats, hitRate: total ? this.#stats.hits / total : 0 });
291
374
  }
292
375
 
376
+ /**
377
+ * Puts every counter of `stats` back to zero.
378
+ *
379
+ * @returns {this}
380
+ */
381
+ resetStats() {
382
+ this.#stats = { hits: 0, misses: 0, sets: 0, evictions: 0, expirations: 0 };
383
+ return this;
384
+ }
385
+
386
+ /**
387
+ * @template R
388
+ * @param {(key: K, entry: CacheEntry<V>) => R} pick
389
+ * @returns {R[]}
390
+ */
391
+ #list(pick) {
392
+ this.prune();
393
+ const now = Date.now();
394
+ /** @type {R[]} */
395
+ const out = [];
396
+ for (const [key, entry] of this.#map) if (!entry.expires || entry.expires > now) out.push(pick(key, entry));
397
+ return out;
398
+ }
399
+
293
400
  /**
294
401
  * Fresh keys, least recently used first.
295
402
  * @returns {K[]}
296
403
  */
297
404
  keys() {
298
- this.prune();
299
- return [...this.#map.keys()];
405
+ return this.#list((key) => key);
300
406
  }
301
407
 
302
408
  /**
@@ -304,8 +410,7 @@ class Cache {
304
410
  * @returns {V[]}
305
411
  */
306
412
  values() {
307
- this.prune();
308
- return [...this.#map.values()].map((entry) => entry.value);
413
+ return this.#list((_, entry) => entry.value);
309
414
  }
310
415
 
311
416
  /**
@@ -313,8 +418,7 @@ class Cache {
313
418
  * @returns {Array<[K, V]>}
314
419
  */
315
420
  entries() {
316
- this.prune();
317
- return [...this.#map].map(([key, entry]) => /** @type {[K, V]} */ ([key, entry.value]));
421
+ return this.#list((key, entry) => /** @type {[K, V]} */ ([key, entry.value]));
318
422
  }
319
423
 
320
424
  /**