@zudojs/cache 1.0.1 → 1.1.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
@@ -2,6 +2,12 @@
2
2
 
3
3
  Cache abstraction with a memory adapter, namespaced tags, locking, events, middleware, and metrics for Zudojs applications.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-cache](https://zudojs.oyinlola.site/docs/packages-cache) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-cache.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -74,6 +80,34 @@ await cache.withLock("import", runImport, { namespace: tenantId });
74
80
  In glob patterns, `*` matches within a single key segment and never crosses
75
81
  the `:` separator; `**` as a whole segment spans namespaces deliberately.
76
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.
87
+
88
+ ## Tags across instances
89
+
90
+ By default each `CacheService` keeps its tag mappings in process, so
91
+ `invalidateByTag` only sees entries written by the same instance. Replicas that
92
+ share one adapter must also share a tag store. Pass any `CacheTagStore`
93
+ implementation (for example one backed by Redis sets) as `config.tagStore`:
94
+
95
+ ```typescript
96
+ const tagStore = createTagStore(); // or your shared implementation
97
+ const a = createCacheService({ adapter, config: { tagStore } });
98
+ const b = createCacheService({ adapter, config: { tagStore } });
99
+
100
+ await a.set("user.1", user, { tags: ["users"] });
101
+ await b.invalidateByTag(["users"]); // clears the entry a wrote
102
+ ```
103
+
104
+ ## Serialization
105
+
106
+ `JsonCacheSerializer` preserves `Date`, `BigInt`, `Map`, `Set` and `Uint8Array`
107
+ by default and drops `__proto__`, `constructor` and `prototype` keys on read.
108
+ A value with its own `$type` field (a domain discriminator) is ordinary data
109
+ and reads back unchanged.
110
+
77
111
  ## Observability
78
112
 
79
113
  ```typescript
package/dist/cache.d.ts CHANGED
@@ -25,6 +25,8 @@ export declare class CacheService implements CacheHealthChecker {
25
25
  private readonly store;
26
26
  private readonly keyBuilder;
27
27
  private readonly tagStore;
28
+ /** False when the tag store was injected and may be shared with peers. */
29
+ private readonly ownsTagStore;
28
30
  private readonly invalidation;
29
31
  private readonly lockManager;
30
32
  private readonly metrics;
package/dist/cache.js CHANGED
@@ -15,9 +15,9 @@
15
15
  * patterns), so it can never widen an operation beyond its own tenant.
16
16
  */
17
17
  import { DEFAULT_LOCK_RETRY_DELAY_MS, DEFAULT_SEPARATOR, DEFAULT_TTL_MS, } from "./constants.js";
18
- import { DefaultKeyBuilder } from "./key-builder.js";
18
+ import { assertNonEmptyNamespace, DefaultKeyBuilder } from "./key-builder.js";
19
19
  import { createCacheStore, DefaultCacheStore } from "./store.js";
20
- import { assertValidTag, createTagStore, InMemoryTagStore } from "./tags.js";
20
+ import { assertValidTag, createTagStore } from "./tags.js";
21
21
  import { CacheInvalidationManager, createInvalidationManager, } from "./invalidation.js";
22
22
  import { CacheLockManager, createLockManager } from "./lock.js";
23
23
  import { createCacheMetrics, InMemoryCacheMetrics } from "./metrics.js";
@@ -27,6 +27,8 @@ export class CacheService {
27
27
  store;
28
28
  keyBuilder;
29
29
  tagStore;
30
+ /** False when the tag store was injected and may be shared with peers. */
31
+ ownsTagStore;
30
32
  invalidation;
31
33
  lockManager;
32
34
  metrics;
@@ -48,6 +50,7 @@ export class CacheService {
48
50
  : DEFAULT_TTL_MS;
49
51
  this.serializer = options.config?.serializer ?? null;
50
52
  this.namespace = options.config?.namespace;
53
+ assertNonEmptyNamespace(this.namespace);
51
54
  this.separator = options.config?.separator ?? DEFAULT_SEPARATOR;
52
55
  this.metrics =
53
56
  options.config?.collectStats !== false ? createCacheMetrics() : null;
@@ -71,7 +74,8 @@ export class CacheService {
71
74
  ? { namespace: options.config.namespace }
72
75
  : {}),
73
76
  });
74
- this.tagStore = createTagStore();
77
+ this.tagStore = options.config?.tagStore ?? createTagStore();
78
+ this.ownsTagStore = options.config?.tagStore === undefined;
75
79
  // Route invalidation through the instrumented store so metrics and
76
80
  // events fire for invalidation-driven deletions too.
77
81
  this.invalidation = createInvalidationManager({
@@ -127,7 +131,7 @@ export class CacheService {
127
131
  // The entry now carries exactly the tags of this write. Mappings
128
132
  // left over from an earlier write would let `invalidateByTag` on a
129
133
  // tag the entry no longer has delete the new value.
130
- this.tagStore.removeKey(fullKey);
134
+ await this.tagStore.removeKey?.(fullKey);
131
135
  if (options?.tags && options.tags.length > 0)
132
136
  await this.tagStore.add(fullKey, options.tags, this.tagScope(options));
133
137
  }
@@ -145,7 +149,7 @@ export class CacheService {
145
149
  const fullKey = this.keyBuilder.build(key, options);
146
150
  try {
147
151
  const result = await this.store.delete(fullKey);
148
- this.tagStore.removeKey(fullKey);
152
+ await this.tagStore.removeKey?.(fullKey);
149
153
  return result;
150
154
  }
151
155
  catch (error) {
@@ -184,7 +188,7 @@ export class CacheService {
184
188
  const pattern = this.qualifyPattern(options.pattern ?? "*", options.namespace);
185
189
  try {
186
190
  const result = await this.store.clear({ pattern });
187
- this.purgeTagsMatching(pattern);
191
+ await this.purgeTagsMatching(pattern);
188
192
  return result;
189
193
  }
190
194
  catch (error) {
@@ -195,7 +199,7 @@ export class CacheService {
195
199
  }
196
200
  try {
197
201
  const result = await this.store.clear();
198
- this.tagStore.clear();
202
+ await this.tagStore.clear?.();
199
203
  return result;
200
204
  }
201
205
  catch (error) {
@@ -433,7 +437,10 @@ export class CacheService {
433
437
  // Drop service-local state so a reconnect does not resurrect stale
434
438
  // in-flight computations or tag mappings.
435
439
  this.inFlight.clear();
436
- this.tagStore.clear();
440
+ // An injected store may be shared with other instances: their mappings
441
+ // are not this instance's to drop.
442
+ if (this.ownsTagStore)
443
+ await this.tagStore.clear?.();
437
444
  }
438
445
  /* ---- Internals ---- */
439
446
  async applyBatchOperation(operation, options) {
@@ -459,6 +466,7 @@ export class CacheService {
459
466
  /** The namespace scope applied to tag registrations and lookups. */
460
467
  tagScope(options) {
461
468
  const namespace = options?.namespace ?? this.namespace;
469
+ assertNonEmptyNamespace(namespace);
462
470
  return namespace !== undefined ? { namespace } : {};
463
471
  }
464
472
  qualifyPattern(pattern, namespace) {
@@ -467,11 +475,14 @@ export class CacheService {
467
475
  }
468
476
  return pattern;
469
477
  }
470
- purgeTagsMatching(pattern) {
478
+ async purgeTagsMatching(pattern) {
479
+ const store = this.tagStore;
480
+ if (!store.trackedKeys || !store.removeKey)
481
+ return;
471
482
  const matches = createGlobMatcher(pattern, { separator: this.separator });
472
- for (const key of this.tagStore.trackedKeys()) {
483
+ for (const key of await store.trackedKeys()) {
473
484
  if (matches(key))
474
- this.tagStore.removeKey(key);
485
+ await store.removeKey(key);
475
486
  }
476
487
  }
477
488
  serialize(value) {
@@ -50,7 +50,7 @@ export class CacheInvalidationManager {
50
50
  cleared++;
51
51
  // The key is gone from the cache, so every tag mapping it still has
52
52
  // (in any scope) is dead too.
53
- this.tagStore.removeKey?.(key);
53
+ await this.tagStore.removeKey?.(key);
54
54
  }
55
55
  for (const tag of tags) {
56
56
  await this.tagStore.invalidate(tag, options);
@@ -97,7 +97,7 @@ export class CacheInvalidationManager {
97
97
  */
98
98
  async flushAll() {
99
99
  const result = await this.adapter.clear();
100
- this.tagStore.clear?.();
100
+ await this.tagStore.clear?.();
101
101
  return result;
102
102
  }
103
103
  }
@@ -10,6 +10,17 @@
10
10
  */
11
11
  import type { CacheKey, CacheKeyOptions, CacheNamespace } from "./types.js";
12
12
  import type { CacheKeyBuilder } from "./types-keys.js";
13
+ /**
14
+ * Rejects an empty-string namespace.
15
+ *
16
+ * `""` is falsy, so it used to be dropped as "no namespace" and moved a
17
+ * tenant whose id failed to resolve into the shared global keyspace. A
18
+ * namespace is either absent (`undefined`) or a valid identity part.
19
+ *
20
+ * @param namespace - The namespace to check.
21
+ * @throws {CacheError} `CACHE_INVALID_KEY` when `namespace` is `""`.
22
+ */
23
+ export declare function assertNonEmptyNamespace(namespace: CacheNamespace | undefined): void;
13
24
  /**
14
25
  * Default implementation of the `CacheKeyBuilder` contract.
15
26
  *
@@ -11,6 +11,21 @@
11
11
  import { CACHE_KEY_PATTERN, DEFAULT_PREFIX, DEFAULT_SEPARATOR, MAX_KEY_LENGTH, } from "./constants.js";
12
12
  import { cacheInvalidKeyError } from "./errors.js";
13
13
  import { assertValidPatternPart } from "./utils.js";
14
+ /**
15
+ * Rejects an empty-string namespace.
16
+ *
17
+ * `""` is falsy, so it used to be dropped as "no namespace" and moved a
18
+ * tenant whose id failed to resolve into the shared global keyspace. A
19
+ * namespace is either absent (`undefined`) or a valid identity part.
20
+ *
21
+ * @param namespace - The namespace to check.
22
+ * @throws {CacheError} `CACHE_INVALID_KEY` when `namespace` is `""`.
23
+ */
24
+ export function assertNonEmptyNamespace(namespace) {
25
+ if (namespace === "") {
26
+ throw cacheInvalidKeyError(namespace, "Cache namespace must not be empty. Omit it for the unscoped keyspace.");
27
+ }
28
+ }
14
29
  /* -------------------------------------------------------------------------- */
15
30
  /* Default Key Builder */
16
31
  /* -------------------------------------------------------------------------- */
@@ -26,6 +41,7 @@ export class DefaultKeyBuilder {
26
41
  constructor(options) {
27
42
  this.globalPrefix = options?.prefix ?? DEFAULT_PREFIX;
28
43
  this.globalSeparator = options?.separator ?? DEFAULT_SEPARATOR;
44
+ assertNonEmptyNamespace(options?.namespace);
29
45
  this.currentNamespace = options?.namespace;
30
46
  }
31
47
  build(key, options) {
@@ -39,7 +55,7 @@ export class DefaultKeyBuilder {
39
55
  if (prefix) {
40
56
  parts.push(prefix);
41
57
  }
42
- if (namespace) {
58
+ if (namespace !== undefined) {
43
59
  parts.push(namespace);
44
60
  }
45
61
  parts.push(key);
@@ -68,7 +84,7 @@ export class DefaultKeyBuilder {
68
84
  const identityParts = [];
69
85
  if (prefix)
70
86
  identityParts.push(prefix);
71
- if (namespace)
87
+ if (namespace !== undefined)
72
88
  identityParts.push(namespace);
73
89
  for (const part of identityParts)
74
90
  this.validatePart(part, separator);
package/dist/lock.js CHANGED
@@ -203,8 +203,18 @@ export class CacheLockManager {
203
203
  // `release()` returning false is the one signal that the lease expired or
204
204
  // was taken over mid-critical-section. Surfacing it is the whole point of
205
205
  // minting a fencing token. An error thrown by `fn` still wins, so the
206
- // real cause is never masked.
207
- const released = await lock.release();
206
+ // real cause is never masked — including by `release()` itself throwing
207
+ // (a distributed store that is down). The release error is then attached
208
+ // to the thrown error as a non-enumerable `suppressed` property.
209
+ let released = false;
210
+ try {
211
+ released = await lock.release();
212
+ }
213
+ catch (releaseError) {
214
+ if (failed)
215
+ throw attachSuppressed(failure, releaseError);
216
+ throw releaseError;
217
+ }
208
218
  if (failed)
209
219
  throw failure;
210
220
  if (!released || leaseLost) {
@@ -219,6 +229,25 @@ export class CacheLockManager {
219
229
  return value;
220
230
  }
221
231
  }
232
+ /**
233
+ * Record a secondary error on the primary one without replacing it, mirroring
234
+ * `SuppressedError.suppressed`. Only an extensible object that does not
235
+ * already carry `suppressed` is annotated; the primary is returned either way.
236
+ */
237
+ function attachSuppressed(primary, secondary) {
238
+ if (typeof primary === "object" &&
239
+ primary !== null &&
240
+ Object.isExtensible(primary) &&
241
+ !Object.hasOwn(primary, "suppressed")) {
242
+ Object.defineProperty(primary, "suppressed", {
243
+ value: secondary,
244
+ enumerable: false,
245
+ configurable: true,
246
+ writable: true,
247
+ });
248
+ }
249
+ return primary;
250
+ }
222
251
  function sleep(ms) {
223
252
  return new Promise((resolve) => setTimeout(resolve, ms));
224
253
  }
@@ -9,13 +9,17 @@ import type { CacheSerializer } from "./types.js";
9
9
  /**
10
10
  * Strips prototype-polluting own keys from a freshly deserialized value.
11
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.
12
+ * A cached payload can come from a shared backing store, a restored backup,
13
+ * or another writer, so the bytes are not necessarily ones this process
14
+ * serialized. `JSON.parse` leaves `__proto__` as a genuine own property,
15
+ * which re-triggers the setter on any later spread or `Object.assign`.
16
+ *
17
+ * The key list is `SCHEMA_FORBIDDEN_KEYS` from `@zudojs/constants`.
18
+ *
19
+ * `JsonCacheSerializer` needs this only for `preserveTypes: false`: on the
20
+ * type-preserving path `@zudojs/serialization` already drops these keys
21
+ * (`allowUnsafeKeys: false`), but its plain-JSON fast path returns
22
+ * `JSON.parse` output untouched.
19
23
  */
20
24
  export declare function stripUnsafeKeys<T>(value: T): T;
21
25
  /**
@@ -5,21 +5,25 @@
5
5
  * to and from storable representations. Delegates to @zudojs/serialization
6
6
  * for the actual JSON serialization with type preservation.
7
7
  */
8
+ import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
8
9
  import { JSONSerializer } from "@zudojs/serialization";
9
10
  /* -------------------------------------------------------------------------- */
10
11
  /* Prototype-pollution hardening */
11
12
  /* -------------------------------------------------------------------------- */
12
- const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
13
13
  /**
14
14
  * Strips prototype-polluting own keys from a freshly deserialized value.
15
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.
16
+ * A cached payload can come from a shared backing store, a restored backup,
17
+ * or another writer, so the bytes are not necessarily ones this process
18
+ * serialized. `JSON.parse` leaves `__proto__` as a genuine own property,
19
+ * which re-triggers the setter on any later spread or `Object.assign`.
20
+ *
21
+ * The key list is `SCHEMA_FORBIDDEN_KEYS` from `@zudojs/constants`.
22
+ *
23
+ * `JsonCacheSerializer` needs this only for `preserveTypes: false`: on the
24
+ * type-preserving path `@zudojs/serialization` already drops these keys
25
+ * (`allowUnsafeKeys: false`), but its plain-JSON fast path returns
26
+ * `JSON.parse` output untouched.
23
27
  */
24
28
  export function stripUnsafeKeys(value) {
25
29
  const seen = new Set();
@@ -46,7 +50,7 @@ export function stripUnsafeKeys(value) {
46
50
  return;
47
51
  }
48
52
  for (const key of Object.getOwnPropertyNames(obj)) {
49
- if (UNSAFE_KEYS.has(key)) {
53
+ if (SCHEMA_FORBIDDEN_KEYS.has(key)) {
50
54
  Reflect.deleteProperty(obj, key);
51
55
  continue;
52
56
  }
@@ -106,14 +110,15 @@ export class JsonCacheSerializer {
106
110
  });
107
111
  }
108
112
  deserialize(value) {
113
+ // Not `strict`: strict mode throws on any `$type` the registry does not
114
+ // know, so a domain object carrying a `$type` discriminator could be
115
+ // written but never read back, poisoning its key. An unknown tag is
116
+ // ordinary data.
109
117
  const parsed = this.inner.deserialize(value, {
110
118
  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
119
  allowUnsafeKeys: false,
115
120
  });
116
- return stripUnsafeKeys(parsed);
121
+ return this.preserveTypes ? parsed : stripUnsafeKeys(parsed);
117
122
  }
118
123
  }
119
124
  /* -------------------------------------------------------------------------- */
@@ -3,6 +3,7 @@ import type { CacheTTL } from "./types-values.js";
3
3
  import type { CacheMiddleware } from "./types-metrics.js";
4
4
  import type { CacheSerializer } from "./types-health.js";
5
5
  import type { CacheLockStore } from "./types-lock.js";
6
+ import type { CacheTagStore } from "./types-tags.js";
6
7
  export interface CacheConfig {
7
8
  readonly enabled?: boolean;
8
9
  readonly defaultTtl?: CacheTTL;
@@ -45,6 +46,14 @@ export interface CacheConfig {
45
46
  * processes.
46
47
  */
47
48
  readonly lockStore?: CacheLockStore;
49
+ /**
50
+ * Tag store backing `set({ tags })` and `invalidateByTag`. Defaults to a
51
+ * fresh in-process store per service, which means tag invalidation only
52
+ * sees entries written by the same instance. Replicas sharing one adapter
53
+ * (one Redis) must share a tag store too, e.g. one backed by Redis sets.
54
+ * An injected store is never flushed by `disconnect()`.
55
+ */
56
+ readonly tagStore?: CacheTagStore;
48
57
  }
49
58
  /**
50
59
  * Error codes set by errors this package constructs directly. Every member
@@ -14,9 +14,20 @@ export interface CacheTagStore {
14
14
  remove(key: CacheKey, tags: readonly CacheTag[], options?: CacheTagOptions): Promise<void>;
15
15
  getKeys(tag: CacheTag, options?: CacheTagOptions): Promise<readonly CacheKey[]>;
16
16
  invalidate(tag: CacheTag, options?: CacheTagOptions): Promise<CacheClearResult>;
17
- /** Removes all tag mappings for a key, across every namespace. */
18
- removeKey?(key: CacheKey): void;
19
- /** Removes all tag mappings. */
20
- clear?(): void;
17
+ /**
18
+ * Removes all tag mappings for a key, across every namespace.
19
+ *
20
+ * May be async: `CacheService` awaits it, so a shared (e.g. Redis-backed)
21
+ * store can complete the removal before the write is reported.
22
+ */
23
+ removeKey?(key: CacheKey): void | Promise<void>;
24
+ /** Removes all tag mappings. May be async. */
25
+ clear?(): void | Promise<void>;
26
+ /**
27
+ * Every key that currently has at least one tag mapping. Used to drop the
28
+ * mappings of keys removed by a pattern clear; when absent, those mappings
29
+ * are left for the next write or tag invalidation of the key.
30
+ */
31
+ trackedKeys?(): readonly CacheKey[] | Promise<readonly CacheKey[]>;
21
32
  }
22
33
  //# sourceMappingURL=types-tags.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/cache",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Caching primitives, abstractions, and adapters for the Zudojs framework.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,9 +24,10 @@
24
24
  "!dist/.tsbuildinfo"
25
25
  ],
26
26
  "dependencies": {
27
- "@zudojs/errors": "1.0.1",
28
- "@zudojs/types": "1.0.0",
29
- "@zudojs/serialization": "1.0.1"
27
+ "@zudojs/constants": "1.1.0",
28
+ "@zudojs/errors": "1.1.0",
29
+ "@zudojs/serialization": "1.1.0",
30
+ "@zudojs/types": "1.1.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "typescript": "7.0.2",