@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.
- package/CHANGELOG.md +150 -0
- package/README.md +32 -30
- package/index.js +3 -3
- package/package.json +8 -7
- package/src/Arr.js +222 -58
- package/src/Async.js +322 -90
- package/src/Cache.js +148 -44
- package/src/Checker.js +257 -26
- package/src/Cli.js +404 -145
- package/src/Color.js +224 -83
- package/src/Crypto.js +481 -106
- package/src/Emitter.js +104 -37
- package/src/Env.js +176 -42
- package/src/FS.js +203 -60
- package/src/Func.js +226 -76
- package/src/Http.js +311 -149
- package/src/Id.js +281 -96
- package/src/Logger.js +302 -78
- package/src/Num.js +182 -60
- package/src/Obj.js +340 -125
- package/src/SQLite.js +426 -105
- package/src/Schema.js +602 -87
- package/src/Str.js +316 -86
- package/src/Sys.js +197 -54
- package/src/Time.js +572 -317
- package/src/Utils.js +62 -41
- package/src/errors.js +54 -11
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +59 -17
- package/types/src/Arr.d.ts +78 -0
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +52 -1
- package/types/src/Async.d.ts.map +1 -1
- package/types/src/Cache.d.ts +33 -7
- package/types/src/Cache.d.ts.map +1 -1
- package/types/src/Checker.d.ts +155 -2
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +97 -15
- package/types/src/Cli.d.ts.map +1 -1
- package/types/src/Color.d.ts +7 -1
- package/types/src/Color.d.ts.map +1 -1
- package/types/src/Crypto.d.ts +99 -13
- package/types/src/Crypto.d.ts.map +1 -1
- package/types/src/Emitter.d.ts +36 -17
- package/types/src/Emitter.d.ts.map +1 -1
- package/types/src/Env.d.ts +31 -1
- package/types/src/Env.d.ts.map +1 -1
- package/types/src/FS.d.ts +36 -5
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +147 -19
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +113 -43
- package/types/src/Http.d.ts.map +1 -1
- package/types/src/Id.d.ts +60 -8
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +53 -2
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +48 -7
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +67 -4
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +123 -14
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +256 -7
- package/types/src/Schema.d.ts.map +1 -1
- package/types/src/Str.d.ts +49 -1
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +51 -6
- package/types/src/Sys.d.ts.map +1 -1
- package/types/src/Time.d.ts +130 -34
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +15 -11
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +27 -3
- package/types/src/errors.d.ts.map +1 -1
- package/types/src/internal/shared.d.ts +123 -0
- 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 {
|
|
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
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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.#
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
186
|
-
const entry = this.#
|
|
187
|
-
if (entry)
|
|
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(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
243
|
-
if (entry.expires && entry.
|
|
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
|
|
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} */ (
|
|
278
|
-
return this.getOrSet(key, () => fn(...args),
|
|
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
|
|
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
|
|
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
|
|
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
|
/**
|