@zudojs/cache 1.1.1 → 1.2.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/README.md +31 -4
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +46 -17
- package/dist/key-builder.d.ts +2 -1
- package/dist/key-builder.js +2 -1
- package/dist/memory.js +3 -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,19 @@ 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
|
+
? Math.max(0, Math.floor(remaining))
|
|
222
|
+
: remaining;
|
|
218
223
|
}
|
|
219
224
|
catch (error) {
|
|
220
225
|
if (this.failSilently)
|
|
@@ -226,7 +231,7 @@ export class CacheService {
|
|
|
226
231
|
async expire(key, ttl, options) {
|
|
227
232
|
if (!this.enabled)
|
|
228
233
|
return false;
|
|
229
|
-
const fullKey = this.
|
|
234
|
+
const fullKey = this.buildKey(key, options);
|
|
230
235
|
try {
|
|
231
236
|
return (await this.store.expire?.(fullKey, ttl)) ?? false;
|
|
232
237
|
}
|
|
@@ -239,7 +244,7 @@ export class CacheService {
|
|
|
239
244
|
async getOrSet(key, fn, options) {
|
|
240
245
|
if (!this.enabled)
|
|
241
246
|
return { value: await fn(), cached: false };
|
|
242
|
-
const fullKey = this.
|
|
247
|
+
const fullKey = this.buildKey(key, options);
|
|
243
248
|
if (!options?.forceRefresh) {
|
|
244
249
|
const cached = await this.get(key, options);
|
|
245
250
|
if (cached.hit)
|
|
@@ -271,8 +276,7 @@ export class CacheService {
|
|
|
271
276
|
async invalidateByTag(tags, options) {
|
|
272
277
|
if (!this.enabled)
|
|
273
278
|
return { cleared: 0 };
|
|
274
|
-
|
|
275
|
-
assertValidTag(tag);
|
|
279
|
+
this.validateTags(tags.join(","), tags);
|
|
276
280
|
try {
|
|
277
281
|
return await this.invalidation.invalidateByTag(tags, this.tagScope(options));
|
|
278
282
|
}
|
|
@@ -324,7 +328,7 @@ export class CacheService {
|
|
|
324
328
|
const code = "CACHE_DISABLED";
|
|
325
329
|
throw new CacheError(`Cannot acquire lock "${key}": the cache is disabled.`, { code, statusCode: 503 });
|
|
326
330
|
}
|
|
327
|
-
const lockKey = this.
|
|
331
|
+
const lockKey = this.buildKey(key, options?.namespace !== undefined
|
|
328
332
|
? { namespace: options.namespace }
|
|
329
333
|
: undefined);
|
|
330
334
|
return this.lockManager.withLock(lockKey, fn, {
|
|
@@ -470,10 +474,35 @@ export class CacheService {
|
|
|
470
474
|
return namespace !== undefined ? { namespace } : {};
|
|
471
475
|
}
|
|
472
476
|
qualifyPattern(pattern, namespace) {
|
|
473
|
-
|
|
474
|
-
|
|
477
|
+
const build = this.keyBuilder.buildPattern?.bind(this.keyBuilder);
|
|
478
|
+
if (!build)
|
|
479
|
+
return pattern;
|
|
480
|
+
return this.guardInput(pattern, () => build(pattern, namespace !== undefined ? { namespace } : undefined));
|
|
481
|
+
}
|
|
482
|
+
/** Builds a full key, counting a rejected key in the error stats. */
|
|
483
|
+
buildKey(key, options) {
|
|
484
|
+
return this.guardInput(key, () => this.keyBuilder.build(key, options));
|
|
485
|
+
}
|
|
486
|
+
/** Validates tags, counting a rejected tag in the error stats. */
|
|
487
|
+
validateTags(key, tags) {
|
|
488
|
+
this.guardInput(key, () => {
|
|
489
|
+
for (const tag of tags)
|
|
490
|
+
assertValidTag(tag);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Runs input validation. A rejection is recorded like an adapter failure
|
|
495
|
+
* (`getStats().errors`, `cache.error`) and rethrown: invalid input is a
|
|
496
|
+
* caller error, so `failSilently` never hides it.
|
|
497
|
+
*/
|
|
498
|
+
guardInput(key, validate) {
|
|
499
|
+
try {
|
|
500
|
+
return validate();
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
this.store.recordError(key, error);
|
|
504
|
+
throw error;
|
|
475
505
|
}
|
|
476
|
-
return pattern;
|
|
477
506
|
}
|
|
478
507
|
async purgeTagsMatching(pattern) {
|
|
479
508
|
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,9 @@ 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….
|
|
244
|
+
return Math.max(0, Math.floor(entry.expiresAt - monotonicNow()));
|
|
243
245
|
}
|
|
244
246
|
async expire(key, ttl) {
|
|
245
247
|
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.0",
|
|
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.0",
|
|
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
|
},
|