@zudojs/cache 1.1.1 → 1.2.1
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/README.md +31 -4
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +48 -17
- package/dist/key-builder.d.ts +2 -1
- package/dist/key-builder.js +2 -1
- package/dist/memory.js +5 -1
- package/dist/store.d.ts +6 -0
- package/dist/store.js +9 -0
- package/dist/tags.js +3 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -80,10 +80,36 @@ await cache.withLock("import", runImport, { namespace: tenantId });
|
|
|
80
80
|
In glob patterns, `*` matches within a single key segment and never crosses
|
|
81
81
|
the `:` separator; `**` as a whole segment spans namespaces deliberately.
|
|
82
82
|
|
|
83
|
-
An empty-string namespace is rejected
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
An empty-string namespace is rejected, in the config and per call. A tenant id
|
|
84
|
+
that failed to resolve to `""` can therefore never fall through to the unscoped
|
|
85
|
+
global keyspace. Omit `namespace` to use that keyspace deliberately.
|
|
86
|
+
|
|
87
|
+
## Invalid input
|
|
88
|
+
|
|
89
|
+
A key, namespace, pattern or tag that fails validation throws a `CacheError`
|
|
90
|
+
whose `code` is `ErrorCode.INVALID_INPUT` from `@zudojs/errors` — the string
|
|
91
|
+
`"ERR_INVALID_INPUT"`. (Earlier docs named this `CACHE_INVALID_KEY`; no such
|
|
92
|
+
code has ever been thrown.) An invalid TTL uses its own code,
|
|
93
|
+
`"CACHE_INVALID_TTL"`.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { ErrorCode } from "@zudojs/errors";
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await cache.get("");
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof CacheError && error.code === ErrorCode.INVALID_INPUT) {
|
|
102
|
+
// reject the request: the key came from the caller
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Invalid input is a caller error, so `failSilently` never hides it. Each
|
|
108
|
+
rejection counts in `getStats().errors` and emits `cache.error`, exactly like
|
|
109
|
+
an invalid TTL or an adapter failure.
|
|
110
|
+
|
|
111
|
+
Since 1.2.0 an invalid tag (`tags: [""]`) is `ERR_INVALID_INPUT` too; it used
|
|
112
|
+
to be `CACHE_OPERATION_FAILED`, which reads as an adapter fault.
|
|
87
113
|
|
|
88
114
|
## Tags across instances
|
|
89
115
|
|
|
@@ -116,6 +142,7 @@ const subscription = cache.subscribe("cache.miss", (event) => {
|
|
|
116
142
|
});
|
|
117
143
|
|
|
118
144
|
cache.getStats(); // { hits, misses, sets, deletes, errors, hitRate }
|
|
145
|
+
await cache.ttl("user.1"); // remaining whole milliseconds, rounded down
|
|
119
146
|
cache.getLatencyStats(CacheOperation.GET); // p50 / p95 / p99
|
|
120
147
|
cache.getHotKeys(10);
|
|
121
148
|
await cache.size();
|
package/dist/cache.d.ts
CHANGED
|
@@ -68,7 +68,10 @@ export declare class CacheService implements CacheHealthChecker {
|
|
|
68
68
|
}): Promise<{
|
|
69
69
|
readonly cleared: number;
|
|
70
70
|
}>;
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Remaining TTL for a key in whole milliseconds, rounded down
|
|
73
|
+
* (undefined = missing, null = never expires).
|
|
74
|
+
*/
|
|
72
75
|
ttl(key: string, options?: NamespaceOptions): Promise<number | null | undefined>;
|
|
73
76
|
/** Updates the TTL of an existing key. Returns false when unsupported or missing. */
|
|
74
77
|
expire(key: string, ttl: CacheTTL, options?: NamespaceOptions): Promise<boolean>;
|
|
@@ -140,6 +143,16 @@ export declare class CacheService implements CacheHealthChecker {
|
|
|
140
143
|
/** The namespace scope applied to tag registrations and lookups. */
|
|
141
144
|
private tagScope;
|
|
142
145
|
private qualifyPattern;
|
|
146
|
+
/** Builds a full key, counting a rejected key in the error stats. */
|
|
147
|
+
private buildKey;
|
|
148
|
+
/** Validates tags, counting a rejected tag in the error stats. */
|
|
149
|
+
private validateTags;
|
|
150
|
+
/**
|
|
151
|
+
* Runs input validation. A rejection is recorded like an adapter failure
|
|
152
|
+
* (`getStats().errors`, `cache.error`) and rethrown: invalid input is a
|
|
153
|
+
* caller error, so `failSilently` never hides it.
|
|
154
|
+
*/
|
|
155
|
+
private guardInput;
|
|
143
156
|
private purgeTagsMatching;
|
|
144
157
|
private serialize;
|
|
145
158
|
private deserialize;
|
package/dist/cache.js
CHANGED
|
@@ -88,7 +88,7 @@ export class CacheService {
|
|
|
88
88
|
async get(key, options) {
|
|
89
89
|
if (!this.enabled)
|
|
90
90
|
return { hit: false, value: null };
|
|
91
|
-
const fullKey = this.
|
|
91
|
+
const fullKey = this.buildKey(key, options);
|
|
92
92
|
try {
|
|
93
93
|
const result = await this.store.get(fullKey);
|
|
94
94
|
if (!result.hit || !this.serializer)
|
|
@@ -111,10 +111,9 @@ export class CacheService {
|
|
|
111
111
|
async set(key, value, options) {
|
|
112
112
|
if (!this.enabled)
|
|
113
113
|
return { success: false, key, expiresAt: null };
|
|
114
|
-
const fullKey = this.
|
|
114
|
+
const fullKey = this.buildKey(key, options);
|
|
115
115
|
if (options?.tags)
|
|
116
|
-
|
|
117
|
-
assertValidTag(tag);
|
|
116
|
+
this.validateTags(key, options.tags);
|
|
118
117
|
try {
|
|
119
118
|
const stored = this.serializer ? this.serialize(value) : value;
|
|
120
119
|
const result = await this.store.set(fullKey, stored, {
|
|
@@ -146,7 +145,7 @@ export class CacheService {
|
|
|
146
145
|
async delete(key, options) {
|
|
147
146
|
if (!this.enabled)
|
|
148
147
|
return { deleted: false, key };
|
|
149
|
-
const fullKey = this.
|
|
148
|
+
const fullKey = this.buildKey(key, options);
|
|
150
149
|
try {
|
|
151
150
|
const result = await this.store.delete(fullKey);
|
|
152
151
|
await this.tagStore.removeKey?.(fullKey);
|
|
@@ -161,7 +160,7 @@ export class CacheService {
|
|
|
161
160
|
async has(key, options) {
|
|
162
161
|
if (!this.enabled)
|
|
163
162
|
return false;
|
|
164
|
-
const fullKey = this.
|
|
163
|
+
const fullKey = this.buildKey(key, options);
|
|
165
164
|
try {
|
|
166
165
|
return await this.store.has(fullKey);
|
|
167
166
|
}
|
|
@@ -208,13 +207,21 @@ export class CacheService {
|
|
|
208
207
|
throw error;
|
|
209
208
|
}
|
|
210
209
|
}
|
|
211
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Remaining TTL for a key in whole milliseconds, rounded down
|
|
212
|
+
* (undefined = missing, null = never expires).
|
|
213
|
+
*/
|
|
212
214
|
async ttl(key, options) {
|
|
213
215
|
if (!this.enabled)
|
|
214
216
|
return undefined;
|
|
215
|
-
const fullKey = this.
|
|
217
|
+
const fullKey = this.buildKey(key, options);
|
|
216
218
|
try {
|
|
217
|
-
|
|
219
|
+
const remaining = await this.store.ttl?.(fullKey);
|
|
220
|
+
return typeof remaining === "number" && Number.isFinite(remaining)
|
|
221
|
+
? remaining > 0
|
|
222
|
+
? Math.max(1, Math.floor(remaining))
|
|
223
|
+
: 0
|
|
224
|
+
: remaining;
|
|
218
225
|
}
|
|
219
226
|
catch (error) {
|
|
220
227
|
if (this.failSilently)
|
|
@@ -226,7 +233,7 @@ export class CacheService {
|
|
|
226
233
|
async expire(key, ttl, options) {
|
|
227
234
|
if (!this.enabled)
|
|
228
235
|
return false;
|
|
229
|
-
const fullKey = this.
|
|
236
|
+
const fullKey = this.buildKey(key, options);
|
|
230
237
|
try {
|
|
231
238
|
return (await this.store.expire?.(fullKey, ttl)) ?? false;
|
|
232
239
|
}
|
|
@@ -239,7 +246,7 @@ export class CacheService {
|
|
|
239
246
|
async getOrSet(key, fn, options) {
|
|
240
247
|
if (!this.enabled)
|
|
241
248
|
return { value: await fn(), cached: false };
|
|
242
|
-
const fullKey = this.
|
|
249
|
+
const fullKey = this.buildKey(key, options);
|
|
243
250
|
if (!options?.forceRefresh) {
|
|
244
251
|
const cached = await this.get(key, options);
|
|
245
252
|
if (cached.hit)
|
|
@@ -271,8 +278,7 @@ export class CacheService {
|
|
|
271
278
|
async invalidateByTag(tags, options) {
|
|
272
279
|
if (!this.enabled)
|
|
273
280
|
return { cleared: 0 };
|
|
274
|
-
|
|
275
|
-
assertValidTag(tag);
|
|
281
|
+
this.validateTags(tags.join(","), tags);
|
|
276
282
|
try {
|
|
277
283
|
return await this.invalidation.invalidateByTag(tags, this.tagScope(options));
|
|
278
284
|
}
|
|
@@ -324,7 +330,7 @@ export class CacheService {
|
|
|
324
330
|
const code = "CACHE_DISABLED";
|
|
325
331
|
throw new CacheError(`Cannot acquire lock "${key}": the cache is disabled.`, { code, statusCode: 503 });
|
|
326
332
|
}
|
|
327
|
-
const lockKey = this.
|
|
333
|
+
const lockKey = this.buildKey(key, options?.namespace !== undefined
|
|
328
334
|
? { namespace: options.namespace }
|
|
329
335
|
: undefined);
|
|
330
336
|
return this.lockManager.withLock(lockKey, fn, {
|
|
@@ -470,10 +476,35 @@ export class CacheService {
|
|
|
470
476
|
return namespace !== undefined ? { namespace } : {};
|
|
471
477
|
}
|
|
472
478
|
qualifyPattern(pattern, namespace) {
|
|
473
|
-
|
|
474
|
-
|
|
479
|
+
const build = this.keyBuilder.buildPattern?.bind(this.keyBuilder);
|
|
480
|
+
if (!build)
|
|
481
|
+
return pattern;
|
|
482
|
+
return this.guardInput(pattern, () => build(pattern, namespace !== undefined ? { namespace } : undefined));
|
|
483
|
+
}
|
|
484
|
+
/** Builds a full key, counting a rejected key in the error stats. */
|
|
485
|
+
buildKey(key, options) {
|
|
486
|
+
return this.guardInput(key, () => this.keyBuilder.build(key, options));
|
|
487
|
+
}
|
|
488
|
+
/** Validates tags, counting a rejected tag in the error stats. */
|
|
489
|
+
validateTags(key, tags) {
|
|
490
|
+
this.guardInput(key, () => {
|
|
491
|
+
for (const tag of tags)
|
|
492
|
+
assertValidTag(tag);
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Runs input validation. A rejection is recorded like an adapter failure
|
|
497
|
+
* (`getStats().errors`, `cache.error`) and rethrown: invalid input is a
|
|
498
|
+
* caller error, so `failSilently` never hides it.
|
|
499
|
+
*/
|
|
500
|
+
guardInput(key, validate) {
|
|
501
|
+
try {
|
|
502
|
+
return validate();
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
this.store.recordError(key, error);
|
|
506
|
+
throw error;
|
|
475
507
|
}
|
|
476
|
-
return pattern;
|
|
477
508
|
}
|
|
478
509
|
async purgeTagsMatching(pattern) {
|
|
479
510
|
const store = this.tagStore;
|
package/dist/key-builder.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type { CacheKeyBuilder } from "./types-keys.js";
|
|
|
18
18
|
* namespace is either absent (`undefined`) or a valid identity part.
|
|
19
19
|
*
|
|
20
20
|
* @param namespace - The namespace to check.
|
|
21
|
-
* @throws {CacheError} `
|
|
21
|
+
* @throws {CacheError} `ERR_INVALID_INPUT` (`ErrorCode.INVALID_INPUT`) when
|
|
22
|
+
* `namespace` is `""`.
|
|
22
23
|
*/
|
|
23
24
|
export declare function assertNonEmptyNamespace(namespace: CacheNamespace | undefined): void;
|
|
24
25
|
/**
|
package/dist/key-builder.js
CHANGED
|
@@ -19,7 +19,8 @@ import { assertValidPatternPart } from "./utils.js";
|
|
|
19
19
|
* namespace is either absent (`undefined`) or a valid identity part.
|
|
20
20
|
*
|
|
21
21
|
* @param namespace - The namespace to check.
|
|
22
|
-
* @throws {CacheError} `
|
|
22
|
+
* @throws {CacheError} `ERR_INVALID_INPUT` (`ErrorCode.INVALID_INPUT`) when
|
|
23
|
+
* `namespace` is `""`.
|
|
23
24
|
*/
|
|
24
25
|
export function assertNonEmptyNamespace(namespace) {
|
|
25
26
|
if (namespace === "") {
|
package/dist/memory.js
CHANGED
|
@@ -239,7 +239,11 @@ export class MemoryCacheAdapter {
|
|
|
239
239
|
}
|
|
240
240
|
if (entry.expiresAt === null)
|
|
241
241
|
return null;
|
|
242
|
-
|
|
242
|
+
// Whole milliseconds, rounded down: the monotonic clock is fractional,
|
|
243
|
+
// so a TTL of 10000 read back straight after `set` was 9999.52…. A key
|
|
244
|
+
// that is still present never reports 0 (its last fraction of a
|
|
245
|
+
// millisecond reads as 1), so 0 cannot be mistaken for "expired".
|
|
246
|
+
return Math.max(1, Math.floor(entry.expiresAt - monotonicNow()));
|
|
243
247
|
}
|
|
244
248
|
async expire(key, ttl) {
|
|
245
249
|
assertValidTtl(ttl);
|
package/dist/store.d.ts
CHANGED
|
@@ -33,6 +33,12 @@ export declare class DefaultCacheStore implements CacheStore {
|
|
|
33
33
|
/** Number of live entries, when the underlying adapter can report it. */
|
|
34
34
|
size(): Promise<number | undefined>;
|
|
35
35
|
subscribe(eventType: CacheEvent["type"] | "*", handler: CacheEventHandler): CacheEventSubscription;
|
|
36
|
+
/**
|
|
37
|
+
* Records an error raised before any adapter call — a rejected key, tag or
|
|
38
|
+
* pattern — so `getStats().errors` and `cache.error` cover input
|
|
39
|
+
* validation the way they already covered a rejected TTL.
|
|
40
|
+
*/
|
|
41
|
+
recordError(key: string, error: unknown): void;
|
|
36
42
|
private emit;
|
|
37
43
|
private recordGetOutcome;
|
|
38
44
|
private executeWithMiddleware;
|
package/dist/store.js
CHANGED
|
@@ -119,6 +119,15 @@ export class DefaultCacheStore {
|
|
|
119
119
|
},
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Records an error raised before any adapter call — a rejected key, tag or
|
|
124
|
+
* pattern — so `getStats().errors` and `cache.error` cover input
|
|
125
|
+
* validation the way they already covered a rejected TTL.
|
|
126
|
+
*/
|
|
127
|
+
recordError(key, error) {
|
|
128
|
+
this.metrics?.incrementError(key);
|
|
129
|
+
this.emit({ type: "cache.error", key, occurredAt: new Date(), error });
|
|
130
|
+
}
|
|
122
131
|
emit(event) {
|
|
123
132
|
const specific = this.handlers.get(event.type);
|
|
124
133
|
const wildcard = this.handlers.get("*");
|
package/dist/tags.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* until `invalidateByTag`/`removeKey`/`clear` touches them, and
|
|
16
16
|
* invalidation tolerates keys that no longer exist in the cache.
|
|
17
17
|
*/
|
|
18
|
+
import { ErrorCode } from "@zudojs/errors";
|
|
18
19
|
import { MAX_TAG_LENGTH } from "./constants.js";
|
|
19
20
|
import { CacheError } from "./errors.js";
|
|
20
21
|
/** Separates the namespace from the tag in the internal map key. */
|
|
@@ -33,13 +34,13 @@ function scopedTag(tag, options) {
|
|
|
33
34
|
export function assertValidTag(tag) {
|
|
34
35
|
if (typeof tag !== "string" || tag.length === 0) {
|
|
35
36
|
throw new CacheError("Cache tag must be a non-empty string.", {
|
|
36
|
-
code:
|
|
37
|
+
code: ErrorCode.INVALID_INPUT,
|
|
37
38
|
statusCode: 400,
|
|
38
39
|
expose: true,
|
|
39
40
|
});
|
|
40
41
|
}
|
|
41
42
|
if (tag.length > MAX_TAG_LENGTH || tag.includes(SCOPE_SEPARATOR)) {
|
|
42
|
-
throw new CacheError(`Invalid cache tag: tags must be at most ${MAX_TAG_LENGTH} characters and must not contain NUL.`, { code:
|
|
43
|
+
throw new CacheError(`Invalid cache tag: tags must be at most ${MAX_TAG_LENGTH} characters and must not contain NUL.`, { code: ErrorCode.INVALID_INPUT, statusCode: 400, expose: true });
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
/* -------------------------------------------------------------------------- */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/cache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Caching primitives, abstractions, and adapters for the Zudojs framework.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
"!dist/.tsbuildinfo"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@zudojs/constants": "1.1.
|
|
28
|
-
"@zudojs/errors": "1.
|
|
29
|
-
"@zudojs/serialization": "1.
|
|
30
|
-
"@zudojs/types": "1.
|
|
27
|
+
"@zudojs/constants": "1.1.2",
|
|
28
|
+
"@zudojs/errors": "1.3.0",
|
|
29
|
+
"@zudojs/serialization": "1.2.1",
|
|
30
|
+
"@zudojs/types": "1.2.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"typescript": "7.0.2",
|
|
34
|
-
"vitest": "^
|
|
34
|
+
"vitest": "^5.0.1"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=24.0.0"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"memory",
|
|
47
47
|
"tags"
|
|
48
48
|
],
|
|
49
|
-
"homepage": "https://
|
|
49
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-cache",
|
|
50
50
|
"bugs": {
|
|
51
51
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
52
52
|
},
|