@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 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 with `CACHE_INVALID_KEY`, in the config
84
- and per call. A tenant id that failed to resolve to `""` can therefore never
85
- fall through to the unscoped global keyspace. Omit `namespace` to use that
86
- keyspace deliberately.
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
- /** Remaining TTL for a key (undefined = missing, null = never expires). */
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.keyBuilder.build(key, options);
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.keyBuilder.build(key, options);
114
+ const fullKey = this.buildKey(key, options);
115
115
  if (options?.tags)
116
- for (const tag of options.tags)
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.keyBuilder.build(key, options);
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.keyBuilder.build(key, options);
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
- /** Remaining TTL for a key (undefined = missing, null = never expires). */
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.keyBuilder.build(key, options);
217
+ const fullKey = this.buildKey(key, options);
216
218
  try {
217
- return await this.store.ttl?.(fullKey);
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.keyBuilder.build(key, options);
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.keyBuilder.build(key, options);
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
- for (const tag of tags)
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.keyBuilder.build(key, options?.namespace !== undefined
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
- if (this.keyBuilder.buildPattern) {
474
- return this.keyBuilder.buildPattern(pattern, namespace !== undefined ? { namespace } : undefined);
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;
@@ -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} `CACHE_INVALID_KEY` when `namespace` is `""`.
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
  /**
@@ -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} `CACHE_INVALID_KEY` when `namespace` is `""`.
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
- return entry.expiresAt - monotonicNow();
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: "CACHE_OPERATION_FAILED",
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: "CACHE_OPERATION_FAILED", statusCode: 400, expose: true });
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.1.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.1",
28
- "@zudojs/errors": "1.2.0",
29
- "@zudojs/serialization": "1.1.1",
30
- "@zudojs/types": "1.1.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": "^4.1.11"
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://github.com/oyinlola-tech/zudo#readme",
49
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-cache",
50
50
  "bugs": {
51
51
  "url": "https://github.com/oyinlola-tech/zudo/issues"
52
52
  },