@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 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,19 @@ 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
+ ? 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.keyBuilder.build(key, options);
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.keyBuilder.build(key, options);
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
- for (const tag of tags)
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.keyBuilder.build(key, options?.namespace !== undefined
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
- if (this.keyBuilder.buildPattern) {
474
- return this.keyBuilder.buildPattern(pattern, namespace !== undefined ? { namespace } : undefined);
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;
@@ -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,9 @@ 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….
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: "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.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.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.0",
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
  },