@ix-xs/node-comfort 1.2.0 → 2.0.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 +190 -0
- package/README.md +136 -549
- package/index.js +80 -35
- package/package.json +160 -33
- package/src/Arr.js +887 -419
- package/src/Async.js +585 -0
- package/src/Cache.js +329 -0
- package/src/Checker.js +971 -539
- package/src/Cli.js +1034 -0
- package/src/Color.js +415 -0
- package/src/Crypto.js +707 -0
- package/src/Emitter.js +289 -0
- package/src/Env.js +555 -0
- package/src/FS.js +1483 -894
- package/src/Func.js +670 -353
- package/src/Http.js +538 -0
- package/src/Id.js +384 -201
- package/src/Logger.js +739 -378
- package/src/Num.js +844 -361
- package/src/Obj.js +855 -372
- package/src/SQLite.js +1308 -1005
- package/src/Schema.js +1586 -0
- package/src/Str.js +983 -436
- package/src/Sys.js +511 -0
- package/src/Time.js +1260 -422
- package/src/Utils.js +321 -325
- package/src/errors.js +292 -0
- package/types/index.d.ts +693 -526
- package/types/src/Arr.d.ts +455 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +321 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +195 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +501 -235
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +582 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +294 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +534 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +170 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +382 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +686 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +380 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +374 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +149 -68
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +363 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +473 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +390 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +646 -309
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +940 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +569 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +269 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +663 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +154 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +292 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/index.d.ts.map +0 -1
package/src/Cache.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
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 { parseDuration } = require("./Time.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 {boolean} [updateAgeOnGet] Reading an entry restarts its time to live.
|
|
29
|
+
* @property {(key: K, value: V, reason: CacheRemovalReason) => void} [onRemove] Called whenever an entry leaves the cache.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for `set()` and `getOrSet()`.
|
|
34
|
+
* @typedef {object} CacheSetOptions
|
|
35
|
+
* @property {number | string} [ttl] Time to live of this entry. `0` means forever.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Cache stats.
|
|
40
|
+
* @typedef {object} CacheStats
|
|
41
|
+
* @property {number} hits Reads that found a fresh entry.
|
|
42
|
+
* @property {number} misses Reads that found nothing or an expired entry.
|
|
43
|
+
* @property {number} sets
|
|
44
|
+
* @property {number} evictions Entries dropped because the cache was full.
|
|
45
|
+
* @property {number} expirations Entries dropped because they expired.
|
|
46
|
+
* @property {number} hitRate From 0 to 1.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {number | string | undefined} value
|
|
51
|
+
* @returns {number}
|
|
52
|
+
*/
|
|
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
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An in-memory cache with a size limit and expiry.
|
|
63
|
+
*
|
|
64
|
+
* @template K
|
|
65
|
+
* @template V
|
|
66
|
+
*/
|
|
67
|
+
class Cache {
|
|
68
|
+
/** @type {Map<K, { value: V, expires: number, ttl: number }>} */
|
|
69
|
+
#map = new Map();
|
|
70
|
+
/** @type {Map<K, Promise<V>>} */
|
|
71
|
+
#loading = new Map();
|
|
72
|
+
#max;
|
|
73
|
+
#ttl;
|
|
74
|
+
#updateAgeOnGet;
|
|
75
|
+
/** @type {((key: K, value: V, reason: CacheRemovalReason) => void) | undefined} */
|
|
76
|
+
#onRemove;
|
|
77
|
+
#stats = { hits: 0, misses: 0, sets: 0, evictions: 0, expirations: 0 };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @example
|
|
81
|
+
* const cache = new nc.Cache({ max: 500, ttl: "1h", updateAgeOnGet: true });
|
|
82
|
+
*
|
|
83
|
+
* @param {CacheOptions<K, V>} [options]
|
|
84
|
+
*/
|
|
85
|
+
constructor(options = {}) {
|
|
86
|
+
this.#max = Math.max(1, options.max ?? Infinity);
|
|
87
|
+
this.#ttl = _ttl(options.ttl);
|
|
88
|
+
this.#updateAgeOnGet = options.updateAgeOnGet ?? false;
|
|
89
|
+
this.#onRemove = options.onRemove;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {K} key
|
|
94
|
+
* @param {{ value: V }} entry
|
|
95
|
+
* @param {CacheRemovalReason} reason
|
|
96
|
+
*/
|
|
97
|
+
#remove(key, entry, reason) {
|
|
98
|
+
this.#map.delete(key);
|
|
99
|
+
if (reason === "expire") this.#stats.expirations++;
|
|
100
|
+
if (reason === "evict") this.#stats.evictions++;
|
|
101
|
+
this.#onRemove?.(key, entry.value, reason);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The entry if it exists and is fresh. Expired entries are removed.
|
|
106
|
+
* @param {K} key
|
|
107
|
+
*/
|
|
108
|
+
#fresh(key) {
|
|
109
|
+
const entry = this.#map.get(key);
|
|
110
|
+
if (!entry) return undefined;
|
|
111
|
+
if (entry.expires && entry.expires <= Date.now()) {
|
|
112
|
+
this.#remove(key, entry, "expire");
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reads a value and marks it as recently used.
|
|
120
|
+
*
|
|
121
|
+
* @param {K} key
|
|
122
|
+
* @returns {V | undefined} `undefined` if missing or expired.
|
|
123
|
+
*/
|
|
124
|
+
get(key) {
|
|
125
|
+
const entry = this.#fresh(key);
|
|
126
|
+
if (!entry) {
|
|
127
|
+
this.#stats.misses++;
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
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);
|
|
134
|
+
return entry.value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reads a value without counting it as a use or touching the stats.
|
|
139
|
+
*
|
|
140
|
+
* @param {K} key
|
|
141
|
+
* @returns {V | undefined}
|
|
142
|
+
*/
|
|
143
|
+
peek(key) {
|
|
144
|
+
return this.#fresh(key)?.value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Stores a value. When the cache is full, the least recently used entry
|
|
149
|
+
* goes.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* cache.set("token", token, { ttl: "15m" });
|
|
153
|
+
*
|
|
154
|
+
* @param {K} key
|
|
155
|
+
* @param {V} value
|
|
156
|
+
* @param {CacheSetOptions} [options]
|
|
157
|
+
* @returns {this}
|
|
158
|
+
*/
|
|
159
|
+
set(key, value, options = {}) {
|
|
160
|
+
const ttl = options.ttl === undefined ? this.#ttl : _ttl(options.ttl);
|
|
161
|
+
const previous = this.#map.get(key);
|
|
162
|
+
if (previous) this.#remove(key, previous, "set");
|
|
163
|
+
this.#map.set(key, { value, ttl, expires: ttl ? Date.now() + ttl : 0 });
|
|
164
|
+
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");
|
|
168
|
+
}
|
|
169
|
+
return this;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns the cached value, or calls `loader`, stores the result and
|
|
174
|
+
* returns it. Simultaneous calls for the same key share one `loader` call,
|
|
175
|
+
* and a failed load caches nothing.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* const profile = await cache.getOrSet(`profile:${id}`, () => api.fetchProfile(id), { ttl: "10m" });
|
|
179
|
+
*
|
|
180
|
+
* @param {K} key
|
|
181
|
+
* @param {() => V | PromiseLike<V>} loader
|
|
182
|
+
* @param {CacheSetOptions} [options]
|
|
183
|
+
* @returns {Promise<V>}
|
|
184
|
+
*/
|
|
185
|
+
async getOrSet(key, loader, options = {}) {
|
|
186
|
+
const entry = this.#fresh(key);
|
|
187
|
+
if (entry) return /** @type {V} */ (this.get(key));
|
|
188
|
+
const inflight = this.#loading.get(key);
|
|
189
|
+
if (inflight) return inflight;
|
|
190
|
+
this.#stats.misses++;
|
|
191
|
+
const promise = Promise.resolve()
|
|
192
|
+
.then(loader)
|
|
193
|
+
.then((value) => {
|
|
194
|
+
this.set(key, value, options);
|
|
195
|
+
return value;
|
|
196
|
+
})
|
|
197
|
+
.finally(() => this.#loading.delete(key));
|
|
198
|
+
this.#loading.set(key, promise);
|
|
199
|
+
return promise;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Is there a fresh entry for this key? Doesn't count as a use.
|
|
204
|
+
*
|
|
205
|
+
* @param {K} key
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
has(key) {
|
|
209
|
+
return this.#fresh(key) !== undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Removes an entry.
|
|
214
|
+
*
|
|
215
|
+
* @param {K} key
|
|
216
|
+
* @returns {boolean} `true` if there was one.
|
|
217
|
+
*/
|
|
218
|
+
delete(key) {
|
|
219
|
+
const entry = this.#map.get(key);
|
|
220
|
+
if (!entry) return false;
|
|
221
|
+
this.#remove(key, entry, "delete");
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Removes every entry. Stats are kept.
|
|
227
|
+
*
|
|
228
|
+
* @returns {void}
|
|
229
|
+
*/
|
|
230
|
+
clear() {
|
|
231
|
+
for (const [key, entry] of [...this.#map]) this.#remove(key, entry, "clear");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Removes expired entries now instead of waiting for them to be read.
|
|
236
|
+
*
|
|
237
|
+
* @returns {number} How many were removed.
|
|
238
|
+
*/
|
|
239
|
+
prune() {
|
|
240
|
+
let removed = 0;
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
for (const [key, entry] of [...this.#map]) {
|
|
243
|
+
if (entry.expires && entry.expires <= now) {
|
|
244
|
+
this.#remove(key, entry, "expire");
|
|
245
|
+
removed++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return removed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Time left before an entry expires.
|
|
253
|
+
*
|
|
254
|
+
* @param {K} key
|
|
255
|
+
* @returns {number | undefined} Milliseconds, `Infinity` if it never expires, `undefined` if missing.
|
|
256
|
+
*/
|
|
257
|
+
ttl(key) {
|
|
258
|
+
const entry = this.#fresh(key);
|
|
259
|
+
if (!entry) return undefined;
|
|
260
|
+
return entry.expires ? entry.expires - Date.now() : Infinity;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Wraps a function so its results are cached here.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* const getWeather = cache.wrap((city) => api.weather(city), { key: (city) => city.toLowerCase(), ttl: "30m" });
|
|
268
|
+
* await getWeather("Paris");
|
|
269
|
+
*
|
|
270
|
+
* @template {any[]} A
|
|
271
|
+
* @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.
|
|
273
|
+
* @returns {(...args: A) => Promise<V>}
|
|
274
|
+
*/
|
|
275
|
+
wrap(fn, options = {}) {
|
|
276
|
+
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 });
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Number of entries, including expired ones not yet removed. */
|
|
283
|
+
get size() {
|
|
284
|
+
return this.#map.size;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Hit and miss counts. */
|
|
288
|
+
get stats() {
|
|
289
|
+
const total = this.#stats.hits + this.#stats.misses;
|
|
290
|
+
return /** @type {CacheStats} */ ({ ...this.#stats, hitRate: total ? this.#stats.hits / total : 0 });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Fresh keys, least recently used first.
|
|
295
|
+
* @returns {K[]}
|
|
296
|
+
*/
|
|
297
|
+
keys() {
|
|
298
|
+
this.prune();
|
|
299
|
+
return [...this.#map.keys()];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Fresh values, least recently used first.
|
|
304
|
+
* @returns {V[]}
|
|
305
|
+
*/
|
|
306
|
+
values() {
|
|
307
|
+
this.prune();
|
|
308
|
+
return [...this.#map.values()].map((entry) => entry.value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Fresh `[key, value]` pairs, least recently used first.
|
|
313
|
+
* @returns {Array<[K, V]>}
|
|
314
|
+
*/
|
|
315
|
+
entries() {
|
|
316
|
+
this.prune();
|
|
317
|
+
return [...this.#map].map(([key, entry]) => /** @type {[K, V]} */ ([key, entry.value]));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Lets you write `for (const [key, value] of cache)`.
|
|
322
|
+
* @returns {IterableIterator<[K, V]>}
|
|
323
|
+
*/
|
|
324
|
+
[Symbol.iterator]() {
|
|
325
|
+
return this.entries()[Symbol.iterator]();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = Cache;
|