@ix-xs/node-comfort 1.2.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 (78) hide show
  1. package/CHANGELOG.md +340 -0
  2. package/README.md +137 -548
  3. package/index.js +80 -35
  4. package/package.json +161 -33
  5. package/src/Arr.js +1050 -418
  6. package/src/Async.js +817 -0
  7. package/src/Cache.js +433 -0
  8. package/src/Checker.js +1202 -539
  9. package/src/Cli.js +1293 -0
  10. package/src/Color.js +556 -0
  11. package/src/Crypto.js +1082 -0
  12. package/src/Emitter.js +356 -0
  13. package/src/Env.js +689 -0
  14. package/src/FS.js +1626 -894
  15. package/src/Func.js +823 -356
  16. package/src/Http.js +700 -0
  17. package/src/Id.js +568 -200
  18. package/src/Logger.js +963 -378
  19. package/src/Num.js +965 -360
  20. package/src/Obj.js +1077 -379
  21. package/src/SQLite.js +1629 -1005
  22. package/src/Schema.js +2101 -0
  23. package/src/Str.js +1209 -432
  24. package/src/Sys.js +654 -0
  25. package/src/Time.js +1533 -440
  26. package/src/Utils.js +342 -325
  27. package/src/errors.js +335 -0
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +735 -526
  30. package/types/src/Arr.d.ts +533 -145
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +372 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +221 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +651 -232
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +664 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +300 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +620 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +189 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +412 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +717 -238
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +508 -135
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +444 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +199 -66
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +414 -46
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +514 -137
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +453 -104
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +754 -308
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +1189 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +617 -166
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +314 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +759 -204
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +158 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +316 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
  78. package/types/index.d.ts.map +0 -1
package/src/Cache.js ADDED
@@ -0,0 +1,433 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * An in-memory cache with a size limit (the least recently used entries go
5
+ * first), expiry per entry, and stats. `getOrSet()` loads a missing value
6
+ * only once, even when many callers ask for it at the same moment.
7
+ *
8
+ * @example
9
+ * const users = new nc.Cache({ max: 1000, ttl: "5m" });
10
+ * const user = await users.getOrSet(id, () => db.users.find(id));
11
+ * users.stats; // { hits: 12, misses: 3, hitRate: 0.8, ... }
12
+ */
13
+
14
+ const { toMs, argsKey } = require("./internal/shared.js");
15
+
16
+ /**
17
+ * Why an entry left the cache.
18
+ * @typedef {"evict" | "expire" | "delete" | "set" | "clear"} CacheRemovalReason
19
+ */
20
+
21
+ /**
22
+ * Options for `new Cache()`.
23
+ * @template K
24
+ * @template V
25
+ * @typedef {object} CacheOptions
26
+ * @property {number} [max] Maximum number of entries. When full, the least recently used goes. No limit by default.
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.
29
+ * @property {boolean} [updateAgeOnGet] Reading an entry restarts its time to live.
30
+ * @property {(key: K, value: V, reason: CacheRemovalReason) => void} [onRemove] Called whenever an entry leaves the cache.
31
+ */
32
+
33
+ /**
34
+ * Options for `set()` and `getOrSet()`.
35
+ * @typedef {object} CacheSetOptions
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.
38
+ */
39
+
40
+ /**
41
+ * Cache stats.
42
+ * @typedef {object} CacheStats
43
+ * @property {number} hits Reads that found a usable entry.
44
+ * @property {number} misses Reads that found nothing or an expired entry.
45
+ * @property {number} sets
46
+ * @property {number} evictions Entries dropped because the cache was full.
47
+ * @property {number} expirations Entries dropped because they expired.
48
+ * @property {number} hitRate From 0 to 1.
49
+ */
50
+
51
+ /**
52
+ * @template V
53
+ * @typedef {{ value: V, expires: number, ttl: number, stale: number }} CacheEntry
54
+ */
55
+
56
+ /**
57
+ * @param {number | string | undefined} value
58
+ * @param {string} what
59
+ * @returns {number}
60
+ */
61
+ const _duration = (value, what) => (value === undefined ? 0 : Math.max(0, toMs(value, what)));
62
+
63
+ /**
64
+ * An in-memory cache with a size limit and expiry.
65
+ *
66
+ * @template K
67
+ * @template V
68
+ */
69
+ class Cache {
70
+ /** @type {Map<K, CacheEntry<V>>} */
71
+ #map = new Map();
72
+ /** @type {Map<K, Promise<V>>} */
73
+ #loading = new Map();
74
+ #max;
75
+ #ttl;
76
+ #stale;
77
+ #updateAgeOnGet;
78
+ /** @type {((key: K, value: V, reason: CacheRemovalReason) => void) | undefined} */
79
+ #onRemove;
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;
88
+
89
+ /**
90
+ * @example
91
+ * const cache = new nc.Cache({ max: 500, ttl: "1h", updateAgeOnGet: true });
92
+ *
93
+ * @param {CacheOptions<K, V>} [options]
94
+ */
95
+ constructor(options = {}) {
96
+ this.#max = Math.max(1, options.max ?? Infinity);
97
+ this.#ttl = _duration(options.ttl, "ttl");
98
+ this.#stale = _duration(options.staleWhileRevalidate, "staleWhileRevalidate");
99
+ this.#updateAgeOnGet = options.updateAgeOnGet ?? false;
100
+ this.#onRemove = options.onRemove;
101
+ }
102
+
103
+ /**
104
+ * @param {K} key
105
+ * @param {{ value: V }} entry
106
+ * @param {CacheRemovalReason} reason
107
+ */
108
+ #remove(key, entry, reason) {
109
+ this.#map.delete(key);
110
+ if (this.#hasNewest && this.#newest === key) this.#hasNewest = false;
111
+ if (reason === "expire") this.#stats.expirations++;
112
+ if (reason === "evict") this.#stats.evictions++;
113
+ this.#onRemove?.(key, entry.value, reason);
114
+ }
115
+
116
+ /**
117
+ * The entry if it exists and is fresh. Expired entries are removed, unless
118
+ * `getOrSet()` may still serve them while it reloads.
119
+ * @param {K} key
120
+ */
121
+ #fresh(key) {
122
+ const entry = this.#map.get(key);
123
+ if (!entry) 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
+ }
130
+ }
131
+ return entry;
132
+ }
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
+
147
+ /**
148
+ * Reads a value and marks it as recently used.
149
+ *
150
+ * @param {K} key
151
+ * @returns {V | undefined} `undefined` if missing or expired.
152
+ */
153
+ get(key) {
154
+ const entry = this.#fresh(key);
155
+ if (!entry) {
156
+ this.#stats.misses++;
157
+ return undefined;
158
+ }
159
+ this.#stats.hits++;
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);
166
+ return entry.value;
167
+ }
168
+
169
+ /**
170
+ * Reads a value without counting it as a use or touching the stats.
171
+ *
172
+ * @param {K} key
173
+ * @returns {V | undefined}
174
+ */
175
+ peek(key) {
176
+ return this.#fresh(key)?.value;
177
+ }
178
+
179
+ /**
180
+ * Stores a value. When the cache is full, the least recently used entry
181
+ * goes. A value being loaded for this key by `getOrSet()` is not stored
182
+ * when it arrives: the one you set stays.
183
+ *
184
+ * @example
185
+ * cache.set("token", token, { ttl: "15m" });
186
+ *
187
+ * @param {K} key
188
+ * @param {V} value
189
+ * @param {CacheSetOptions} [options]
190
+ * @returns {this}
191
+ */
192
+ set(key, value, options = {}) {
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");
196
+ const previous = this.#map.get(key);
197
+ if (previous) this.#remove(key, previous, "set");
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;
202
+ this.#stats.sets++;
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);
212
+ }
213
+ return this;
214
+ }
215
+
216
+ /**
217
+ * Returns the cached value, or calls `loader`, stores the result and
218
+ * returns it. Simultaneous calls for the same key share one `loader` call,
219
+ * and a failed load caches nothing.
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
+ *
225
+ * @example
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" });
228
+ *
229
+ * @param {K} key
230
+ * @param {() => V | PromiseLike<V>} loader
231
+ * @param {CacheSetOptions} [options]
232
+ * @returns {Promise<V>}
233
+ */
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
+ }
247
+ const inflight = this.#loading.get(key);
248
+ if (inflight) return inflight;
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>} */
261
+ const promise = Promise.resolve()
262
+ .then(loader)
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
+ );
277
+ this.#loading.set(key, promise);
278
+ return promise;
279
+ }
280
+
281
+ /**
282
+ * Is there a fresh entry for this key? Doesn't count as a use.
283
+ *
284
+ * @param {K} key
285
+ * @returns {boolean}
286
+ */
287
+ has(key) {
288
+ return this.#fresh(key) !== undefined;
289
+ }
290
+
291
+ /**
292
+ * Removes an entry. A value being loaded for this key by `getOrSet()` is
293
+ * not stored when it arrives.
294
+ *
295
+ * @param {K} key
296
+ * @returns {boolean} `true` if there was one.
297
+ */
298
+ delete(key) {
299
+ this.#loading.delete(key);
300
+ const entry = this.#map.get(key);
301
+ if (!entry) return false;
302
+ this.#remove(key, entry, "delete");
303
+ return true;
304
+ }
305
+
306
+ /**
307
+ * Removes every entry. Stats are kept.
308
+ *
309
+ * @returns {void}
310
+ */
311
+ clear() {
312
+ this.#loading.clear();
313
+ for (const [key, entry] of [...this.#map]) this.#remove(key, entry, "clear");
314
+ }
315
+
316
+ /**
317
+ * Removes expired entries now instead of waiting for them to be read.
318
+ *
319
+ * @returns {number} How many were removed.
320
+ */
321
+ prune() {
322
+ let removed = 0;
323
+ const now = Date.now();
324
+ for (const [key, entry] of this.#map) {
325
+ if (entry.expires && entry.stale <= now) {
326
+ this.#remove(key, entry, "expire");
327
+ removed++;
328
+ }
329
+ }
330
+ return removed;
331
+ }
332
+
333
+ /**
334
+ * Time left before an entry expires.
335
+ *
336
+ * @param {K} key
337
+ * @returns {number | undefined} Milliseconds, `Infinity` if it never expires, `undefined` if missing.
338
+ */
339
+ ttl(key) {
340
+ const entry = this.#fresh(key);
341
+ if (!entry) return undefined;
342
+ return entry.expires ? entry.expires - Date.now() : Infinity;
343
+ }
344
+
345
+ /**
346
+ * Wraps a function so its results are cached here.
347
+ *
348
+ * @example
349
+ * const getWeather = cache.wrap((city) => api.weather(city), { key: (city) => city.toLowerCase(), ttl: "30m" });
350
+ * await getWeather("Paris");
351
+ *
352
+ * @template {any[]} A
353
+ * @param {(...args: A) => V | PromiseLike<V>} fn
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.
355
+ * @returns {(...args: A) => Promise<V>}
356
+ */
357
+ wrap(fn, options = {}) {
358
+ const settings = { ttl: options.ttl, staleWhileRevalidate: options.staleWhileRevalidate };
359
+ return (...args) => {
360
+ const key = options.key ? options.key(...args) : /** @type {K} */ (argsKey(args));
361
+ return this.getOrSet(key, () => fn(...args), settings);
362
+ };
363
+ }
364
+
365
+ /** Number of entries, including expired ones not yet removed. */
366
+ get size() {
367
+ return this.#map.size;
368
+ }
369
+
370
+ /** Hit and miss counts. */
371
+ get stats() {
372
+ const total = this.#stats.hits + this.#stats.misses;
373
+ return /** @type {CacheStats} */ ({ ...this.#stats, hitRate: total ? this.#stats.hits / total : 0 });
374
+ }
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
+
400
+ /**
401
+ * Fresh keys, least recently used first.
402
+ * @returns {K[]}
403
+ */
404
+ keys() {
405
+ return this.#list((key) => key);
406
+ }
407
+
408
+ /**
409
+ * Fresh values, least recently used first.
410
+ * @returns {V[]}
411
+ */
412
+ values() {
413
+ return this.#list((_, entry) => entry.value);
414
+ }
415
+
416
+ /**
417
+ * Fresh `[key, value]` pairs, least recently used first.
418
+ * @returns {Array<[K, V]>}
419
+ */
420
+ entries() {
421
+ return this.#list((key, entry) => /** @type {[K, V]} */ ([key, entry.value]));
422
+ }
423
+
424
+ /**
425
+ * Lets you write `for (const [key, value] of cache)`.
426
+ * @returns {IterableIterator<[K, V]>}
427
+ */
428
+ [Symbol.iterator]() {
429
+ return this.entries()[Symbol.iterator]();
430
+ }
431
+ }
432
+
433
+ module.exports = Cache;