@zudojs/cache 0.1.0 → 1.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/LICENSE +21 -0
- package/README.md +90 -14
- package/dist/cache.d.ts +107 -18
- package/dist/cache.js +393 -33
- package/dist/constants.d.ts +46 -12
- package/dist/constants.js +47 -19
- package/dist/index.d.ts +10 -9
- package/dist/index.js +9 -8
- package/dist/invalidation.d.ts +23 -16
- package/dist/invalidation.js +43 -33
- package/dist/key-builder.d.ts +15 -0
- package/dist/key-builder.js +43 -3
- package/dist/lock.d.ts +36 -2
- package/dist/lock.js +160 -30
- package/dist/memory.d.ts +63 -7
- package/dist/memory.js +223 -39
- package/dist/metrics.d.ts +10 -0
- package/dist/metrics.js +49 -4
- package/dist/serializer.d.ts +19 -10
- package/dist/serializer.js +88 -21
- package/dist/store.d.ts +21 -11
- package/dist/store.js +125 -65
- package/dist/tags.d.ts +32 -12
- package/dist/tags.js +113 -33
- package/dist/types-adapter.d.ts +23 -9
- package/dist/types-config.d.ts +46 -9
- package/dist/types-health.d.ts +5 -2
- package/dist/types-keys.d.ts +8 -4
- package/dist/types-lock.d.ts +8 -0
- package/dist/types-operations.d.ts +13 -20
- package/dist/types-results.d.ts +7 -1
- package/dist/types-tags.d.ts +9 -0
- package/dist/types-utility.d.ts +6 -10
- package/dist/types-values.d.ts +9 -14
- package/dist/types.d.ts +6 -6
- package/dist/utils.d.ts +65 -0
- package/dist/utils.js +210 -0
- package/package.json +21 -14
- package/dist/.tsbuildinfo +0 -1
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/invalidation.d.ts.map +0 -1
- package/dist/invalidation.js.map +0 -1
- package/dist/key-builder.d.ts.map +0 -1
- package/dist/key-builder.js.map +0 -1
- package/dist/lock.d.ts.map +0 -1
- package/dist/lock.js.map +0 -1
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js.map +0 -1
- package/dist/metrics.d.ts.map +0 -1
- package/dist/metrics.js.map +0 -1
- package/dist/serializer.d.ts.map +0 -1
- package/dist/serializer.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/tags.d.ts.map +0 -1
- package/dist/tags.js.map +0 -1
- package/dist/types-adapter.d.ts.map +0 -1
- package/dist/types-adapter.js.map +0 -1
- package/dist/types-config.d.ts.map +0 -1
- package/dist/types-config.js.map +0 -1
- package/dist/types-events.d.ts.map +0 -1
- package/dist/types-events.js.map +0 -1
- package/dist/types-health.d.ts.map +0 -1
- package/dist/types-health.js.map +0 -1
- package/dist/types-keys.d.ts.map +0 -1
- package/dist/types-keys.js.map +0 -1
- package/dist/types-lock.d.ts.map +0 -1
- package/dist/types-lock.js.map +0 -1
- package/dist/types-metrics.d.ts.map +0 -1
- package/dist/types-metrics.js.map +0 -1
- package/dist/types-operations.d.ts.map +0 -1
- package/dist/types-operations.js.map +0 -1
- package/dist/types-results.d.ts.map +0 -1
- package/dist/types-results.js.map +0 -1
- package/dist/types-tags.d.ts.map +0 -1
- package/dist/types-tags.js.map +0 -1
- package/dist/types-utility.d.ts.map +0 -1
- package/dist/types-utility.js.map +0 -1
- package/dist/types-values.d.ts.map +0 -1
- package/dist/types-values.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/memory.js
CHANGED
|
@@ -1,47 +1,154 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/cache — Memory Adapter
|
|
3
3
|
* In-memory cache adapter using a Map. Suitable for development, testing, and single-process deployments.
|
|
4
|
+
*
|
|
5
|
+
* Receives fully-qualified keys; namespace resolution happens in CacheService.
|
|
6
|
+
*
|
|
7
|
+
* Bounds: the store is capped both by entry count (`maxEntries`) and by an
|
|
8
|
+
* approximate byte budget (`maxBytes`). Eviction is LRU — reads move an
|
|
9
|
+
* entry to the back of the queue, so the hottest keys survive.
|
|
10
|
+
*
|
|
11
|
+
* Expiry is measured on a monotonic clock, so an NTP step or a VM resume
|
|
12
|
+
* cannot extend (or collapse) an entry's lifetime. The wall-clock
|
|
13
|
+
* `expiresAt` returned to callers is derived from it for display only.
|
|
4
14
|
*/
|
|
5
|
-
import { DEFAULT_MAX_ENTRIES, DEFAULT_TTL_MS } from "./constants.js";
|
|
15
|
+
import { DEFAULT_MAX_ENTRIES, DEFAULT_MAX_MEMORY_BYTES, DEFAULT_SEPARATOR, DEFAULT_TTL_MS, EXPIRED_PURGE_INTERVAL_MS, SIZE_ESTIMATE_NODE_BUDGET, } from "./constants.js";
|
|
16
|
+
import { assertValidTtl, createGlobMatcher, deleteManyViaDelete, monotonicNow, wallClockFor, } from "./utils.js";
|
|
17
|
+
/**
|
|
18
|
+
* Approximates the retained size of a value in bytes.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately bounded: at most `SIZE_ESTIMATE_NODE_BUDGET` nodes are
|
|
21
|
+
* visited, so `set` stays cheap for large object graphs. Beyond the budget
|
|
22
|
+
* the remainder is charged a flat per-node estimate, which keeps the number
|
|
23
|
+
* monotone in value size without walking the whole graph.
|
|
24
|
+
*/
|
|
25
|
+
export function estimateValueBytes(value) {
|
|
26
|
+
let budget = SIZE_ESTIMATE_NODE_BUDGET;
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const walk = (node) => {
|
|
29
|
+
if (budget <= 0)
|
|
30
|
+
return 16;
|
|
31
|
+
budget--;
|
|
32
|
+
switch (typeof node) {
|
|
33
|
+
case "string":
|
|
34
|
+
return 16 + node.length * 2;
|
|
35
|
+
case "number":
|
|
36
|
+
return 8;
|
|
37
|
+
case "boolean":
|
|
38
|
+
return 4;
|
|
39
|
+
case "bigint":
|
|
40
|
+
return 16;
|
|
41
|
+
case "undefined":
|
|
42
|
+
return 0;
|
|
43
|
+
case "function":
|
|
44
|
+
return 32;
|
|
45
|
+
default:
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (node === null)
|
|
49
|
+
return 0;
|
|
50
|
+
const obj = node;
|
|
51
|
+
if (seen.has(obj))
|
|
52
|
+
return 0;
|
|
53
|
+
seen.add(obj);
|
|
54
|
+
if (ArrayBuffer.isView(obj))
|
|
55
|
+
return 16 + obj.byteLength;
|
|
56
|
+
if (obj instanceof ArrayBuffer)
|
|
57
|
+
return 16 + obj.byteLength;
|
|
58
|
+
if (obj instanceof Date)
|
|
59
|
+
return 16;
|
|
60
|
+
if (Array.isArray(obj)) {
|
|
61
|
+
let total = 16;
|
|
62
|
+
for (const item of obj)
|
|
63
|
+
total += walk(item);
|
|
64
|
+
return total;
|
|
65
|
+
}
|
|
66
|
+
if (obj instanceof Map) {
|
|
67
|
+
let total = 16;
|
|
68
|
+
for (const [k, v] of obj)
|
|
69
|
+
total += walk(k) + walk(v);
|
|
70
|
+
return total;
|
|
71
|
+
}
|
|
72
|
+
if (obj instanceof Set) {
|
|
73
|
+
let total = 16;
|
|
74
|
+
for (const item of obj)
|
|
75
|
+
total += walk(item);
|
|
76
|
+
return total;
|
|
77
|
+
}
|
|
78
|
+
let total = 16;
|
|
79
|
+
for (const [k, v] of Object.entries(obj))
|
|
80
|
+
total += 16 + k.length * 2 + walk(v);
|
|
81
|
+
return total;
|
|
82
|
+
};
|
|
83
|
+
return walk(value);
|
|
84
|
+
}
|
|
6
85
|
export class MemoryCacheAdapter {
|
|
7
86
|
name = "memory";
|
|
8
87
|
store = new Map();
|
|
9
88
|
maxEntries;
|
|
89
|
+
maxBytes;
|
|
10
90
|
defaultTtl;
|
|
91
|
+
separator;
|
|
92
|
+
bytesUsed = 0;
|
|
93
|
+
lastPurgeAt = monotonicNow();
|
|
11
94
|
constructor(options) {
|
|
12
95
|
this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
96
|
+
this.maxBytes = options?.maxBytes ?? DEFAULT_MAX_MEMORY_BYTES;
|
|
13
97
|
this.defaultTtl = options?.defaultTtl ?? DEFAULT_TTL_MS;
|
|
98
|
+
this.separator = options?.separator ?? DEFAULT_SEPARATOR;
|
|
14
99
|
}
|
|
15
100
|
async get(key) {
|
|
16
101
|
const entry = this.store.get(key);
|
|
17
102
|
if (!entry)
|
|
18
103
|
return { hit: false, value: null };
|
|
19
104
|
if (this.isExpired(entry)) {
|
|
20
|
-
this.
|
|
105
|
+
this.remove(key);
|
|
21
106
|
return { hit: false, value: null };
|
|
22
107
|
}
|
|
23
|
-
|
|
108
|
+
// LRU: a read moves the entry to the back of the eviction queue, so the
|
|
109
|
+
// hottest key is not evicted merely for being the oldest write.
|
|
110
|
+
this.store.delete(key);
|
|
111
|
+
this.store.set(key, entry);
|
|
112
|
+
return {
|
|
113
|
+
hit: true,
|
|
114
|
+
value: entry.value,
|
|
115
|
+
entry: this.toCacheEntry(key, entry),
|
|
116
|
+
};
|
|
24
117
|
}
|
|
25
118
|
async set(key, value, options) {
|
|
26
|
-
this.
|
|
27
|
-
|
|
28
|
-
|
|
119
|
+
const ttl = options?.ttl !== undefined ? options.ttl : this.defaultTtl;
|
|
120
|
+
assertValidTtl(ttl);
|
|
121
|
+
if (options?.overwrite === false && (await this.has(key))) {
|
|
122
|
+
return { success: false, key, expiresAt: null, skipped: true };
|
|
123
|
+
}
|
|
124
|
+
const now = monotonicNow();
|
|
125
|
+
const bytes = estimateValueBytes(value) + key.length * 2;
|
|
126
|
+
// The opportunistic purge runs on the overwrite path too — time-gated,
|
|
127
|
+
// so re-setting hot keys stays cheap while dead entries elsewhere in the
|
|
128
|
+
// store still get collected. Overwriting releases the old entry's budget
|
|
129
|
+
// first, so it never counts twice.
|
|
130
|
+
this.maybePurgeExpired(now);
|
|
131
|
+
if (this.store.has(key))
|
|
132
|
+
this.remove(key);
|
|
133
|
+
this.evictIfNeeded(bytes);
|
|
29
134
|
this.store.set(key, {
|
|
30
135
|
value,
|
|
31
136
|
createdAt: now,
|
|
32
137
|
expiresAt: ttl !== null ? now + ttl : null,
|
|
33
138
|
tags: options?.tags ?? [],
|
|
34
139
|
metadata: options?.metadata ?? {},
|
|
140
|
+
bytes,
|
|
35
141
|
});
|
|
142
|
+
this.bytesUsed += bytes;
|
|
36
143
|
return {
|
|
37
144
|
success: true,
|
|
38
145
|
key,
|
|
39
|
-
expiresAt: ttl !== null ?
|
|
146
|
+
expiresAt: ttl !== null ? wallClockFor(now + ttl) : null,
|
|
40
147
|
};
|
|
41
148
|
}
|
|
42
149
|
async delete(key) {
|
|
43
150
|
const existed = this.store.has(key);
|
|
44
|
-
this.
|
|
151
|
+
this.remove(key);
|
|
45
152
|
return { deleted: existed, key };
|
|
46
153
|
}
|
|
47
154
|
async has(key) {
|
|
@@ -49,7 +156,7 @@ export class MemoryCacheAdapter {
|
|
|
49
156
|
if (!entry)
|
|
50
157
|
return false;
|
|
51
158
|
if (this.isExpired(entry)) {
|
|
52
|
-
this.
|
|
159
|
+
this.remove(key);
|
|
53
160
|
return false;
|
|
54
161
|
}
|
|
55
162
|
return true;
|
|
@@ -58,28 +165,49 @@ export class MemoryCacheAdapter {
|
|
|
58
165
|
if (!options?.pattern) {
|
|
59
166
|
const size = this.store.size;
|
|
60
167
|
this.store.clear();
|
|
168
|
+
this.bytesUsed = 0;
|
|
61
169
|
return { cleared: size };
|
|
62
170
|
}
|
|
63
171
|
let cleared = 0;
|
|
64
|
-
const
|
|
172
|
+
const matches = createGlobMatcher(options.pattern, {
|
|
173
|
+
separator: this.separator,
|
|
174
|
+
});
|
|
65
175
|
for (const key of [...this.store.keys()]) {
|
|
66
|
-
if (
|
|
67
|
-
this.
|
|
176
|
+
if (matches(key)) {
|
|
177
|
+
this.remove(key);
|
|
68
178
|
cleared++;
|
|
69
179
|
}
|
|
70
180
|
}
|
|
71
181
|
return { cleared };
|
|
72
182
|
}
|
|
73
183
|
async keys(options) {
|
|
74
|
-
let filtered = [
|
|
184
|
+
let filtered = [];
|
|
185
|
+
for (const [key, entry] of [...this.store]) {
|
|
186
|
+
if (this.isExpired(entry)) {
|
|
187
|
+
this.remove(key);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
filtered.push(key);
|
|
191
|
+
}
|
|
75
192
|
if (options?.pattern) {
|
|
76
|
-
const
|
|
77
|
-
|
|
193
|
+
const matches = createGlobMatcher(options.pattern, {
|
|
194
|
+
separator: this.separator,
|
|
195
|
+
});
|
|
196
|
+
filtered = filtered.filter(matches);
|
|
78
197
|
}
|
|
79
198
|
if (options?.limit !== undefined)
|
|
80
199
|
filtered = filtered.slice(0, options.limit);
|
|
81
200
|
return filtered;
|
|
82
201
|
}
|
|
202
|
+
/** Number of live entries currently held. */
|
|
203
|
+
async size() {
|
|
204
|
+
this.purgeExpired();
|
|
205
|
+
return this.store.size;
|
|
206
|
+
}
|
|
207
|
+
/** Estimated bytes currently retained by cached values. */
|
|
208
|
+
get estimatedBytes() {
|
|
209
|
+
return this.bytesUsed;
|
|
210
|
+
}
|
|
83
211
|
async getMany(keys) {
|
|
84
212
|
const results = new Map();
|
|
85
213
|
for (const key of keys)
|
|
@@ -93,45 +221,101 @@ export class MemoryCacheAdapter {
|
|
|
93
221
|
return results;
|
|
94
222
|
}
|
|
95
223
|
async deleteMany(keys) {
|
|
96
|
-
|
|
97
|
-
const deletedKeys = [];
|
|
98
|
-
for (const key of keys) {
|
|
99
|
-
const result = await this.delete(key);
|
|
100
|
-
if (result.deleted) {
|
|
101
|
-
deleted++;
|
|
102
|
-
deletedKeys.push(key);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return { deleted, keys: deletedKeys };
|
|
224
|
+
return deleteManyViaDelete(keys, (key) => this.delete(key));
|
|
106
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Remaining TTL for a key in milliseconds.
|
|
228
|
+
* - `undefined` — the key does not exist (expired entries are deleted)
|
|
229
|
+
* - `null` — the key exists and never expires
|
|
230
|
+
* - `number` — remaining milliseconds until expiry
|
|
231
|
+
*/
|
|
107
232
|
async ttl(key) {
|
|
108
233
|
const entry = this.store.get(key);
|
|
109
|
-
if (!entry
|
|
234
|
+
if (!entry)
|
|
235
|
+
return undefined;
|
|
236
|
+
if (this.isExpired(entry)) {
|
|
237
|
+
this.remove(key);
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
if (entry.expiresAt === null)
|
|
110
241
|
return null;
|
|
111
|
-
|
|
112
|
-
return remaining > 0 ? remaining : null;
|
|
242
|
+
return entry.expiresAt - monotonicNow();
|
|
113
243
|
}
|
|
114
244
|
async expire(key, ttl) {
|
|
245
|
+
assertValidTtl(ttl);
|
|
115
246
|
const entry = this.store.get(key);
|
|
116
247
|
if (!entry)
|
|
117
248
|
return false;
|
|
118
|
-
this.
|
|
249
|
+
if (this.isExpired(entry)) {
|
|
250
|
+
this.remove(key);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
entry.expiresAt = ttl !== null ? monotonicNow() + ttl : null;
|
|
119
254
|
return true;
|
|
120
255
|
}
|
|
256
|
+
/* ---- Internals ---- */
|
|
257
|
+
toCacheEntry(key, entry) {
|
|
258
|
+
return {
|
|
259
|
+
key,
|
|
260
|
+
value: entry.value,
|
|
261
|
+
createdAt: wallClockFor(entry.createdAt),
|
|
262
|
+
expiresAt: entry.expiresAt !== null ? wallClockFor(entry.expiresAt) : null,
|
|
263
|
+
tags: entry.tags,
|
|
264
|
+
metadata: entry.metadata,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* An entry is dead *at* its deadline, not one millisecond after it, so
|
|
269
|
+
* `ttl(key) === 0` and "present" are never both true.
|
|
270
|
+
*/
|
|
121
271
|
isExpired(entry) {
|
|
122
|
-
return entry.expiresAt !== null &&
|
|
272
|
+
return entry.expiresAt !== null && monotonicNow() >= entry.expiresAt;
|
|
123
273
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
274
|
+
remove(key) {
|
|
275
|
+
const entry = this.store.get(key);
|
|
276
|
+
if (!entry)
|
|
277
|
+
return;
|
|
278
|
+
this.store.delete(key);
|
|
279
|
+
this.bytesUsed -= entry.bytes;
|
|
280
|
+
if (this.bytesUsed < 0)
|
|
281
|
+
this.bytesUsed = 0;
|
|
282
|
+
}
|
|
283
|
+
purgeExpired() {
|
|
284
|
+
for (const [key, entry] of [...this.store]) {
|
|
285
|
+
if (this.isExpired(entry))
|
|
286
|
+
this.remove(key);
|
|
129
287
|
}
|
|
288
|
+
this.lastPurgeAt = monotonicNow();
|
|
289
|
+
}
|
|
290
|
+
maybePurgeExpired(now) {
|
|
291
|
+
if (now - this.lastPurgeAt < EXPIRED_PURGE_INTERVAL_MS)
|
|
292
|
+
return;
|
|
293
|
+
this.purgeExpired();
|
|
130
294
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Makes room for an incoming entry of `incomingBytes`, honouring both the
|
|
297
|
+
* entry-count cap and the memory budget. Expired entries are purged before
|
|
298
|
+
* live ones are evicted; live eviction is least-recently-used.
|
|
299
|
+
*/
|
|
300
|
+
evictIfNeeded(incomingBytes) {
|
|
301
|
+
const overCount = this.store.size >= this.maxEntries;
|
|
302
|
+
const overBytes = this.bytesUsed + incomingBytes > this.maxBytes;
|
|
303
|
+
if (!overCount && !overBytes)
|
|
304
|
+
return;
|
|
305
|
+
this.purgeExpired();
|
|
306
|
+
while (this.store.size >= this.maxEntries) {
|
|
307
|
+
const lru = this.store.keys().next().value;
|
|
308
|
+
if (lru === undefined)
|
|
309
|
+
break;
|
|
310
|
+
this.remove(lru);
|
|
311
|
+
}
|
|
312
|
+
while (this.bytesUsed + incomingBytes > this.maxBytes &&
|
|
313
|
+
this.store.size > 0) {
|
|
314
|
+
const lru = this.store.keys().next().value;
|
|
315
|
+
if (lru === undefined)
|
|
316
|
+
break;
|
|
317
|
+
this.remove(lru);
|
|
318
|
+
}
|
|
135
319
|
}
|
|
136
320
|
}
|
|
137
321
|
export function createMemoryCacheAdapter(options) {
|
package/dist/metrics.d.ts
CHANGED
|
@@ -12,6 +12,11 @@ export declare class InMemoryCacheMetrics implements CacheMetrics {
|
|
|
12
12
|
private readonly keyHits;
|
|
13
13
|
private readonly latencies;
|
|
14
14
|
incrementHit(key?: CacheKey): void;
|
|
15
|
+
/**
|
|
16
|
+
* Removes the least-hit tracked key and returns its count. Ties are
|
|
17
|
+
* broken by insertion order, so the oldest of the coldest goes first.
|
|
18
|
+
*/
|
|
19
|
+
private evictColdestKey;
|
|
15
20
|
incrementMiss(_key?: CacheKey): void;
|
|
16
21
|
incrementSet(_key?: CacheKey): void;
|
|
17
22
|
incrementDelete(_key?: CacheKey): void;
|
|
@@ -27,6 +32,11 @@ export declare class InMemoryCacheMetrics implements CacheMetrics {
|
|
|
27
32
|
readonly p95: number;
|
|
28
33
|
readonly p99: number;
|
|
29
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* The most-read keys currently tracked, hottest first. The tracked set is
|
|
37
|
+
* bounded by MAX_TRACKED_KEYS and evicts its coldest member, so the
|
|
38
|
+
* ranking follows the live workload rather than freezing at start-up.
|
|
39
|
+
*/
|
|
30
40
|
getHotKeys(topN?: number): readonly {
|
|
31
41
|
readonly key: CacheKey;
|
|
32
42
|
readonly hits: number;
|
package/dist/metrics.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @zudojs/cache — Metrics
|
|
3
3
|
* Tracks cache operation metrics including hit/miss ratios, latency histograms, and error counts.
|
|
4
4
|
*/
|
|
5
|
-
import { LATENCY_BUCKETS, MAX_LATENCY_SAMPLES } from "./constants.js";
|
|
5
|
+
import { LATENCY_BUCKETS, MAX_LATENCY_SAMPLES, MAX_TRACKED_KEYS, } from "./constants.js";
|
|
6
6
|
export class InMemoryCacheMetrics {
|
|
7
7
|
hits = 0;
|
|
8
8
|
misses = 0;
|
|
@@ -13,8 +13,43 @@ export class InMemoryCacheMetrics {
|
|
|
13
13
|
latencies = new Map();
|
|
14
14
|
incrementHit(key) {
|
|
15
15
|
this.hits++;
|
|
16
|
-
if (key)
|
|
17
|
-
|
|
16
|
+
if (!key)
|
|
17
|
+
return;
|
|
18
|
+
const current = this.keyHits.get(key);
|
|
19
|
+
if (current !== undefined) {
|
|
20
|
+
this.keyHits.set(key, current + 1);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// The tracked-key cap is an eviction policy, not a wall. A hard stop
|
|
24
|
+
// would freeze `getHotKeys()` on the first MAX_TRACKED_KEYS keys the
|
|
25
|
+
// process ever saw, so it would answer "what was hot at start-up" while
|
|
26
|
+
// looking live. Instead the coldest tracked key is evicted, and the new
|
|
27
|
+
// key inherits its count so it is not immediately evicted in turn and
|
|
28
|
+
// can climb the ranking if it really is hot.
|
|
29
|
+
if (this.keyHits.size >= MAX_TRACKED_KEYS) {
|
|
30
|
+
const coldest = this.evictColdestKey();
|
|
31
|
+
this.keyHits.set(key, coldest + 1);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.keyHits.set(key, 1);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Removes the least-hit tracked key and returns its count. Ties are
|
|
38
|
+
* broken by insertion order, so the oldest of the coldest goes first.
|
|
39
|
+
*/
|
|
40
|
+
evictColdestKey() {
|
|
41
|
+
let coldestKey;
|
|
42
|
+
let coldestHits = Number.POSITIVE_INFINITY;
|
|
43
|
+
for (const [key, hits] of this.keyHits) {
|
|
44
|
+
if (hits < coldestHits) {
|
|
45
|
+
coldestHits = hits;
|
|
46
|
+
coldestKey = key;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (coldestKey === undefined)
|
|
50
|
+
return 0;
|
|
51
|
+
this.keyHits.delete(coldestKey);
|
|
52
|
+
return coldestHits;
|
|
18
53
|
}
|
|
19
54
|
incrementMiss(_key) {
|
|
20
55
|
this.misses++;
|
|
@@ -62,6 +97,11 @@ export class InMemoryCacheMetrics {
|
|
|
62
97
|
p99: percentile(samples, 99),
|
|
63
98
|
};
|
|
64
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The most-read keys currently tracked, hottest first. The tracked set is
|
|
102
|
+
* bounded by MAX_TRACKED_KEYS and evicts its coldest member, so the
|
|
103
|
+
* ranking follows the live workload rather than freezing at start-up.
|
|
104
|
+
*/
|
|
65
105
|
getHotKeys(topN = 10) {
|
|
66
106
|
return [...this.keyHits.entries()]
|
|
67
107
|
.sort((a, b) => b[1] - a[1])
|
|
@@ -70,7 +110,12 @@ export class InMemoryCacheMetrics {
|
|
|
70
110
|
}
|
|
71
111
|
getLatencyHistogram(operation) {
|
|
72
112
|
const samples = this.latencies.get(operation) ?? [];
|
|
73
|
-
|
|
113
|
+
// A +Infinity bucket captures samples above the largest finite boundary.
|
|
114
|
+
const boundaries = [
|
|
115
|
+
...LATENCY_BUCKETS,
|
|
116
|
+
Number.POSITIVE_INFINITY,
|
|
117
|
+
];
|
|
118
|
+
return boundaries.map((boundary) => ({
|
|
74
119
|
bucket: boundary,
|
|
75
120
|
count: samples.filter((s) => s <= boundary).length,
|
|
76
121
|
}));
|
package/dist/serializer.d.ts
CHANGED
|
@@ -6,11 +6,27 @@
|
|
|
6
6
|
* for the actual JSON serialization with type preservation.
|
|
7
7
|
*/
|
|
8
8
|
import type { CacheSerializer } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* Strips prototype-polluting own keys from a freshly deserialized value.
|
|
11
|
+
*
|
|
12
|
+
* This is defence in depth at the trust boundary: a cached payload can come
|
|
13
|
+
* from a shared backing store, a restored backup, or another writer, so the
|
|
14
|
+
* bytes are not necessarily ones this process serialized. `@zudojs/errors`'
|
|
15
|
+
* sibling `@zudojs/serialization` accepts an `allowUnsafeKeys` option but
|
|
16
|
+
* the published build does not implement it, and `JSON.parse` leaves
|
|
17
|
+
* `__proto__` as a genuine own property — which re-triggers the setter on
|
|
18
|
+
* any later spread or `Object.assign`. Both routes are closed here.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stripUnsafeKeys<T>(value: T): T;
|
|
9
21
|
/**
|
|
10
22
|
* Serializes values to JSON strings and deserializes them back.
|
|
11
23
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
24
|
+
* Preserves special types (Date, BigInt, Map, Set, Uint8Array) by default —
|
|
25
|
+
* round-trip fidelity is the point of a cache, and a silently lossy default
|
|
26
|
+
* turns `set(k, { at: new Date() })` into a string on the way back out. Pass
|
|
27
|
+
* `{ preserveTypes: false }` for plain JSON semantics.
|
|
28
|
+
*
|
|
29
|
+
* Deserialization is hardened against prototype-polluting keys.
|
|
14
30
|
*/
|
|
15
31
|
export declare class JsonCacheSerializer implements CacheSerializer<unknown, string> {
|
|
16
32
|
private readonly inner;
|
|
@@ -29,15 +45,8 @@ export declare class RawCacheSerializer implements CacheSerializer<unknown, unkn
|
|
|
29
45
|
serialize(value: unknown): unknown;
|
|
30
46
|
deserialize(value: unknown): unknown;
|
|
31
47
|
}
|
|
32
|
-
/** Default JSON serializer instance. */
|
|
48
|
+
/** Default JSON serializer instance (type-preserving). */
|
|
33
49
|
export declare const defaultSerializer: JsonCacheSerializer;
|
|
34
50
|
/** Default raw (pass-through) serializer instance. */
|
|
35
51
|
export declare const rawSerializer: RawCacheSerializer;
|
|
36
|
-
/**
|
|
37
|
-
* Returns the appropriate serializer for the given value type.
|
|
38
|
-
*
|
|
39
|
-
* - Objects, arrays, dates → JSON serializer
|
|
40
|
-
* - Strings, numbers, booleans, null → raw serializer
|
|
41
|
-
*/
|
|
42
|
-
export declare function getSerializer(value: unknown): CacheSerializer<unknown, unknown>;
|
|
43
52
|
//# sourceMappingURL=serializer.d.ts.map
|
package/dist/serializer.js
CHANGED
|
@@ -7,19 +7,97 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { JSONSerializer } from "@zudojs/serialization";
|
|
9
9
|
/* -------------------------------------------------------------------------- */
|
|
10
|
+
/* Prototype-pollution hardening */
|
|
11
|
+
/* -------------------------------------------------------------------------- */
|
|
12
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
13
|
+
/**
|
|
14
|
+
* Strips prototype-polluting own keys from a freshly deserialized value.
|
|
15
|
+
*
|
|
16
|
+
* This is defence in depth at the trust boundary: a cached payload can come
|
|
17
|
+
* from a shared backing store, a restored backup, or another writer, so the
|
|
18
|
+
* bytes are not necessarily ones this process serialized. `@zudojs/errors`'
|
|
19
|
+
* sibling `@zudojs/serialization` accepts an `allowUnsafeKeys` option but
|
|
20
|
+
* the published build does not implement it, and `JSON.parse` leaves
|
|
21
|
+
* `__proto__` as a genuine own property — which re-triggers the setter on
|
|
22
|
+
* any later spread or `Object.assign`. Both routes are closed here.
|
|
23
|
+
*/
|
|
24
|
+
export function stripUnsafeKeys(value) {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const walk = (node) => {
|
|
27
|
+
if (node === null || typeof node !== "object")
|
|
28
|
+
return;
|
|
29
|
+
const obj = node;
|
|
30
|
+
if (seen.has(obj))
|
|
31
|
+
return;
|
|
32
|
+
seen.add(obj);
|
|
33
|
+
if (Array.isArray(obj)) {
|
|
34
|
+
for (const item of obj)
|
|
35
|
+
walk(item);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (obj instanceof Map) {
|
|
39
|
+
for (const entry of obj.values())
|
|
40
|
+
walk(entry);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (obj instanceof Set) {
|
|
44
|
+
for (const entry of obj)
|
|
45
|
+
walk(entry);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const key of Object.getOwnPropertyNames(obj)) {
|
|
49
|
+
if (UNSAFE_KEYS.has(key)) {
|
|
50
|
+
Reflect.deleteProperty(obj, key);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
walk(obj[key]);
|
|
54
|
+
}
|
|
55
|
+
// A hijacked prototype survives key deletion, so reset it too.
|
|
56
|
+
if (Object.getPrototypeOf(obj) !== Object.prototype && isPlainish(obj)) {
|
|
57
|
+
Object.setPrototypeOf(obj, Object.prototype);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
walk(value);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* True when an object is a plain data container whose prototype should be
|
|
65
|
+
* `Object.prototype` — i.e. not a Date/Map/Set/typed array/class instance we
|
|
66
|
+
* deliberately reconstructed.
|
|
67
|
+
*/
|
|
68
|
+
function isPlainish(obj) {
|
|
69
|
+
if (obj instanceof Date ||
|
|
70
|
+
obj instanceof Map ||
|
|
71
|
+
obj instanceof Set ||
|
|
72
|
+
obj instanceof RegExp ||
|
|
73
|
+
obj instanceof Error ||
|
|
74
|
+
ArrayBuffer.isView(obj) ||
|
|
75
|
+
obj instanceof ArrayBuffer) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
const proto = Object.getPrototypeOf(obj);
|
|
79
|
+
// Only a null prototype or a foreign literal object indicates tampering;
|
|
80
|
+
// anything with a real constructor is left alone.
|
|
81
|
+
return proto === null || proto.constructor === Object;
|
|
82
|
+
}
|
|
83
|
+
/* -------------------------------------------------------------------------- */
|
|
10
84
|
/* JSON Serializer */
|
|
11
85
|
/* -------------------------------------------------------------------------- */
|
|
12
86
|
/**
|
|
13
87
|
* Serializes values to JSON strings and deserializes them back.
|
|
14
88
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
89
|
+
* Preserves special types (Date, BigInt, Map, Set, Uint8Array) by default —
|
|
90
|
+
* round-trip fidelity is the point of a cache, and a silently lossy default
|
|
91
|
+
* turns `set(k, { at: new Date() })` into a string on the way back out. Pass
|
|
92
|
+
* `{ preserveTypes: false }` for plain JSON semantics.
|
|
93
|
+
*
|
|
94
|
+
* Deserialization is hardened against prototype-polluting keys.
|
|
17
95
|
*/
|
|
18
96
|
export class JsonCacheSerializer {
|
|
19
97
|
inner;
|
|
20
98
|
preserveTypes;
|
|
21
99
|
constructor(options) {
|
|
22
|
-
this.preserveTypes = options?.preserveTypes ??
|
|
100
|
+
this.preserveTypes = options?.preserveTypes ?? true;
|
|
23
101
|
this.inner = new JSONSerializer();
|
|
24
102
|
}
|
|
25
103
|
serialize(value) {
|
|
@@ -28,9 +106,14 @@ export class JsonCacheSerializer {
|
|
|
28
106
|
});
|
|
29
107
|
}
|
|
30
108
|
deserialize(value) {
|
|
31
|
-
|
|
109
|
+
const parsed = this.inner.deserialize(value, {
|
|
32
110
|
preserveTypes: this.preserveTypes,
|
|
111
|
+
// Stated at the call site even though the published sibling build
|
|
112
|
+
// ignores them; `stripUnsafeKeys` enforces the intent regardless.
|
|
113
|
+
strict: true,
|
|
114
|
+
allowUnsafeKeys: false,
|
|
33
115
|
});
|
|
116
|
+
return stripUnsafeKeys(parsed);
|
|
34
117
|
}
|
|
35
118
|
}
|
|
36
119
|
/* -------------------------------------------------------------------------- */
|
|
@@ -51,24 +134,8 @@ export class RawCacheSerializer {
|
|
|
51
134
|
/* -------------------------------------------------------------------------- */
|
|
52
135
|
/* Default Singleton */
|
|
53
136
|
/* -------------------------------------------------------------------------- */
|
|
54
|
-
/** Default JSON serializer instance. */
|
|
137
|
+
/** Default JSON serializer instance (type-preserving). */
|
|
55
138
|
export const defaultSerializer = new JsonCacheSerializer();
|
|
56
139
|
/** Default raw (pass-through) serializer instance. */
|
|
57
140
|
export const rawSerializer = new RawCacheSerializer();
|
|
58
|
-
/**
|
|
59
|
-
* Returns the appropriate serializer for the given value type.
|
|
60
|
-
*
|
|
61
|
-
* - Objects, arrays, dates → JSON serializer
|
|
62
|
-
* - Strings, numbers, booleans, null → raw serializer
|
|
63
|
-
*/
|
|
64
|
-
export function getSerializer(value) {
|
|
65
|
-
if (value === null ||
|
|
66
|
-
value === undefined ||
|
|
67
|
-
typeof value === "string" ||
|
|
68
|
-
typeof value === "number" ||
|
|
69
|
-
typeof value === "boolean") {
|
|
70
|
-
return rawSerializer;
|
|
71
|
-
}
|
|
72
|
-
return defaultSerializer;
|
|
73
|
-
}
|
|
74
141
|
//# sourceMappingURL=serializer.js.map
|