@zudojs/cache 0.0.1 → 1.0.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.
Files changed (88) hide show
  1. package/README.md +90 -14
  2. package/dist/cache.d.ts +107 -18
  3. package/dist/cache.js +393 -33
  4. package/dist/constants.d.ts +46 -12
  5. package/dist/constants.js +47 -19
  6. package/dist/index.d.ts +10 -9
  7. package/dist/index.js +9 -8
  8. package/dist/invalidation.d.ts +23 -16
  9. package/dist/invalidation.js +43 -33
  10. package/dist/key-builder.d.ts +15 -0
  11. package/dist/key-builder.js +43 -3
  12. package/dist/lock.d.ts +36 -2
  13. package/dist/lock.js +160 -30
  14. package/dist/memory.d.ts +63 -7
  15. package/dist/memory.js +223 -39
  16. package/dist/metrics.d.ts +10 -0
  17. package/dist/metrics.js +49 -4
  18. package/dist/serializer.d.ts +19 -10
  19. package/dist/serializer.js +88 -21
  20. package/dist/store.d.ts +21 -11
  21. package/dist/store.js +125 -65
  22. package/dist/tags.d.ts +32 -12
  23. package/dist/tags.js +113 -33
  24. package/dist/types-adapter.d.ts +23 -9
  25. package/dist/types-config.d.ts +46 -9
  26. package/dist/types-health.d.ts +5 -2
  27. package/dist/types-keys.d.ts +8 -4
  28. package/dist/types-lock.d.ts +8 -0
  29. package/dist/types-operations.d.ts +13 -20
  30. package/dist/types-results.d.ts +7 -1
  31. package/dist/types-tags.d.ts +9 -0
  32. package/dist/types-utility.d.ts +6 -10
  33. package/dist/types-values.d.ts +9 -14
  34. package/dist/types.d.ts +6 -6
  35. package/dist/utils.d.ts +65 -0
  36. package/dist/utils.js +210 -0
  37. package/package.json +14 -7
  38. package/dist/.tsbuildinfo +0 -1
  39. package/dist/cache.d.ts.map +0 -1
  40. package/dist/cache.js.map +0 -1
  41. package/dist/constants.d.ts.map +0 -1
  42. package/dist/constants.js.map +0 -1
  43. package/dist/errors.d.ts.map +0 -1
  44. package/dist/errors.js.map +0 -1
  45. package/dist/index.d.ts.map +0 -1
  46. package/dist/index.js.map +0 -1
  47. package/dist/invalidation.d.ts.map +0 -1
  48. package/dist/invalidation.js.map +0 -1
  49. package/dist/key-builder.d.ts.map +0 -1
  50. package/dist/key-builder.js.map +0 -1
  51. package/dist/lock.d.ts.map +0 -1
  52. package/dist/lock.js.map +0 -1
  53. package/dist/memory.d.ts.map +0 -1
  54. package/dist/memory.js.map +0 -1
  55. package/dist/metrics.d.ts.map +0 -1
  56. package/dist/metrics.js.map +0 -1
  57. package/dist/serializer.d.ts.map +0 -1
  58. package/dist/serializer.js.map +0 -1
  59. package/dist/store.d.ts.map +0 -1
  60. package/dist/store.js.map +0 -1
  61. package/dist/tags.d.ts.map +0 -1
  62. package/dist/tags.js.map +0 -1
  63. package/dist/types-adapter.d.ts.map +0 -1
  64. package/dist/types-adapter.js.map +0 -1
  65. package/dist/types-config.d.ts.map +0 -1
  66. package/dist/types-config.js.map +0 -1
  67. package/dist/types-events.d.ts.map +0 -1
  68. package/dist/types-events.js.map +0 -1
  69. package/dist/types-health.d.ts.map +0 -1
  70. package/dist/types-health.js.map +0 -1
  71. package/dist/types-keys.d.ts.map +0 -1
  72. package/dist/types-keys.js.map +0 -1
  73. package/dist/types-lock.d.ts.map +0 -1
  74. package/dist/types-lock.js.map +0 -1
  75. package/dist/types-metrics.d.ts.map +0 -1
  76. package/dist/types-metrics.js.map +0 -1
  77. package/dist/types-operations.d.ts.map +0 -1
  78. package/dist/types-operations.js.map +0 -1
  79. package/dist/types-results.d.ts.map +0 -1
  80. package/dist/types-results.js.map +0 -1
  81. package/dist/types-tags.d.ts.map +0 -1
  82. package/dist/types-tags.js.map +0 -1
  83. package/dist/types-utility.d.ts.map +0 -1
  84. package/dist/types-utility.js.map +0 -1
  85. package/dist/types-values.d.ts.map +0 -1
  86. package/dist/types-values.js.map +0 -1
  87. package/dist/types.d.ts.map +0 -1
  88. package/dist/types.js.map +0 -1
package/dist/store.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * @zudojs/cache — Store
3
3
  * Wraps a CacheAdapter with metrics, events, middleware, and error handling.
4
+ * Batch operations run through the same instrumentation path as single
5
+ * operations: per-key hit/miss metrics and events fire, and errors are
6
+ * wrapped in CacheError.
4
7
  */
5
- import type { CacheAdapter, CacheClearOptions, CacheClearResult, CacheDeleteOptions, CacheDeleteResult, CacheEvent, CacheEventHandler, CacheEventSubscription, CacheGetManyOptions, CacheGetOptions, CacheGetResult, CacheHasOptions, CacheKeysOptions, CacheMiddleware, CacheMetrics, CacheSetManyOptions, CacheSetOptions, CacheSetResult, CacheStore } from "./types.js";
8
+ import type { CacheAdapter, CacheClearOptions, CacheClearResult, CacheDeleteManyResult, CacheDeleteResult, CacheEvent, CacheEventHandler, CacheEventSubscription, CacheGetResult, CacheKeysOptions, CacheMiddleware, CacheMetrics, CacheSetManyOptions, CacheSetOptions, CacheSetResult, CacheStore, CacheTTL } from "./types.js";
6
9
  export declare class DefaultCacheStore implements CacheStore {
7
10
  readonly name: string;
8
11
  private readonly adapter;
@@ -16,23 +19,30 @@ export declare class DefaultCacheStore implements CacheStore {
16
19
  });
17
20
  connect(): Promise<void>;
18
21
  disconnect(): Promise<void>;
19
- get<TValue = unknown>(key: string, options?: CacheGetOptions): Promise<CacheGetResult<TValue>>;
20
- has(key: string, options?: CacheHasOptions): Promise<boolean>;
22
+ get<TValue = unknown>(key: string): Promise<CacheGetResult<TValue>>;
23
+ has(key: string): Promise<boolean>;
21
24
  keys(options?: CacheKeysOptions): Promise<readonly string[]>;
22
25
  set<TValue = unknown>(key: string, value: TValue, options?: CacheSetOptions): Promise<CacheSetResult>;
23
- delete(key: string, options?: CacheDeleteOptions): Promise<CacheDeleteResult>;
26
+ delete(key: string): Promise<CacheDeleteResult>;
24
27
  clear(options?: CacheClearOptions): Promise<CacheClearResult>;
25
- getMany<TValue = unknown>(keys: readonly string[], options?: CacheGetManyOptions): Promise<ReadonlyMap<string, CacheGetResult<TValue>>>;
28
+ getMany<TValue = unknown>(keys: readonly string[]): Promise<ReadonlyMap<string, CacheGetResult<TValue>>>;
26
29
  setMany<TValue = unknown>(entries: ReadonlyMap<string, TValue>, options?: CacheSetManyOptions): Promise<readonly CacheSetResult[]>;
27
- deleteMany(keys: readonly string[], options?: {
28
- readonly namespace?: string;
29
- }): Promise<{
30
- readonly deleted: number;
31
- readonly keys: readonly string[];
32
- }>;
30
+ deleteMany(keys: readonly string[]): Promise<CacheDeleteManyResult>;
31
+ ttl(key: string): Promise<number | null | undefined>;
32
+ expire(key: string, ttl: CacheTTL): Promise<boolean>;
33
+ /** Number of live entries, when the underlying adapter can report it. */
34
+ size(): Promise<number | undefined>;
33
35
  subscribe(eventType: CacheEvent["type"] | "*", handler: CacheEventHandler): CacheEventSubscription;
34
36
  private emit;
37
+ private recordGetOutcome;
35
38
  private executeWithMiddleware;
39
+ /**
40
+ * A middleware that forgets to return `next()`'s result resolves
41
+ * `undefined`, which would otherwise be laundered into `T` by an unchecked
42
+ * cast and surface as a `TypeError` far from the cause. Only `ttl` may
43
+ * legitimately resolve `undefined`.
44
+ */
45
+ private narrowMiddlewareResult;
36
46
  }
37
47
  export declare function createCacheStore(options: {
38
48
  readonly adapter: CacheAdapter;
package/dist/store.js CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * @zudojs/cache — Store
3
3
  * Wraps a CacheAdapter with metrics, events, middleware, and error handling.
4
+ * Batch operations run through the same instrumentation path as single
5
+ * operations: per-key hit/miss metrics and events fire, and errors are
6
+ * wrapped in CacheError.
4
7
  */
5
8
  import { CacheError, CacheOperation } from "./errors.js";
9
+ import { deleteManyViaDelete } from "./utils.js";
6
10
  export class DefaultCacheStore {
7
11
  name;
8
12
  adapter;
@@ -22,53 +26,88 @@ export class DefaultCacheStore {
22
26
  disconnect() {
23
27
  return this.adapter.disconnect?.() ?? Promise.resolve();
24
28
  }
25
- async get(key, options) {
26
- return this.executeWithMiddleware("get", key, () => this.adapter.get(key, options));
29
+ async get(key) {
30
+ return this.executeWithMiddleware(CacheOperation.GET, key, () => this.adapter.get(key));
27
31
  }
28
- async has(key, options) {
29
- return this.executeWithMiddleware("has", key, () => this.adapter.has(key, options));
32
+ async has(key) {
33
+ return this.executeWithMiddleware(CacheOperation.HAS, key, () => this.adapter.has(key));
30
34
  }
31
35
  async keys(options) {
32
- return this.executeWithMiddleware("keys", "*", () => this.adapter.keys?.(options) ?? Promise.resolve([]));
36
+ return this.executeWithMiddleware(CacheOperation.KEYS, "*", () => this.adapter.keys?.(options) ?? Promise.resolve([]));
33
37
  }
34
38
  async set(key, value, options) {
35
- return this.executeWithMiddleware("set", key, () => this.adapter.set(key, value, options));
39
+ return this.executeWithMiddleware(CacheOperation.SET, key, () => this.adapter.set(key, value, options), { ttl: options?.ttl });
36
40
  }
37
- async delete(key, options) {
38
- return this.executeWithMiddleware("delete", key, () => this.adapter.delete(key, options));
41
+ async delete(key) {
42
+ return this.executeWithMiddleware(CacheOperation.DELETE, key, () => this.adapter.delete(key));
39
43
  }
40
44
  async clear(options) {
41
- return this.executeWithMiddleware("clear", "*", () => this.adapter.clear(options));
42
- }
43
- async getMany(keys, options) {
44
- if (this.adapter.getMany)
45
- return this.adapter.getMany(keys, options);
46
- const results = new Map();
47
- for (const key of keys)
48
- results.set(key, await this.get(key, options));
45
+ return this.executeWithMiddleware(CacheOperation.CLEAR, "*", () => this.adapter.clear(options));
46
+ }
47
+ async getMany(keys) {
48
+ if (!this.adapter.getMany) {
49
+ const results = new Map();
50
+ for (const key of keys)
51
+ results.set(key, await this.get(key));
52
+ return results;
53
+ }
54
+ const results = await this.executeWithMiddleware(CacheOperation.GET_MANY, "*", () => this.adapter.getMany(keys));
55
+ for (const key of keys) {
56
+ this.recordGetOutcome(key, results.get(key)?.hit ?? false);
57
+ }
49
58
  return results;
50
59
  }
51
60
  async setMany(entries, options) {
52
- if (this.adapter.setMany)
53
- return this.adapter.setMany(entries, options);
54
- const results = [];
55
- for (const [key, value] of entries)
56
- results.push(await this.set(key, value, options));
61
+ if (!this.adapter.setMany) {
62
+ const results = [];
63
+ for (const [key, value] of entries)
64
+ results.push(await this.set(key, value, options));
65
+ return results;
66
+ }
67
+ const results = await this.executeWithMiddleware(CacheOperation.SET_MANY, "*", () => this.adapter.setMany(entries, options));
68
+ for (const result of results) {
69
+ if (!result.success)
70
+ continue;
71
+ this.metrics?.incrementSet(result.key);
72
+ this.emit({
73
+ type: "cache.set",
74
+ key: result.key,
75
+ occurredAt: new Date(),
76
+ ...(options?.ttl !== undefined ? { ttl: options.ttl } : {}),
77
+ });
78
+ }
57
79
  return results;
58
80
  }
59
- async deleteMany(keys, options) {
60
- if (this.adapter.deleteMany)
61
- return this.adapter.deleteMany(keys, options);
62
- let deleted = 0;
63
- const deletedKeys = [];
81
+ async deleteMany(keys) {
82
+ if (!this.adapter.deleteMany) {
83
+ return deleteManyViaDelete(keys, (key) => this.delete(key));
84
+ }
85
+ const result = await this.executeWithMiddleware(CacheOperation.DELETE_MANY, "*", () => this.adapter.deleteMany(keys));
86
+ const deletedKeys = new Set(result.keys);
64
87
  for (const key of keys) {
65
- const result = await this.delete(key, options);
66
- if (result.deleted) {
67
- deleted++;
68
- deletedKeys.push(key);
69
- }
88
+ this.metrics?.incrementDelete(key);
89
+ this.emit({
90
+ type: "cache.delete",
91
+ key,
92
+ occurredAt: new Date(),
93
+ deleted: deletedKeys.has(key),
94
+ });
70
95
  }
71
- return { deleted, keys: deletedKeys };
96
+ return result;
97
+ }
98
+ async ttl(key) {
99
+ if (!this.adapter.ttl)
100
+ return undefined;
101
+ return this.executeWithMiddleware(CacheOperation.TTL, key, () => this.adapter.ttl(key));
102
+ }
103
+ async expire(key, ttl) {
104
+ if (!this.adapter.expire)
105
+ return false;
106
+ return this.executeWithMiddleware(CacheOperation.EXPIRE, key, () => this.adapter.expire(key, ttl));
107
+ }
108
+ /** Number of live entries, when the underlying adapter can report it. */
109
+ async size() {
110
+ return this.adapter.size?.();
72
111
  }
73
112
  subscribe(eventType, handler) {
74
113
  if (!this.handlers.has(eventType))
@@ -89,14 +128,28 @@ export class DefaultCacheStore {
89
128
  ]);
90
129
  for (const handler of all) {
91
130
  try {
92
- handler(event);
131
+ // Swallow both sync throws and async rejections so a faulty
132
+ // handler can never crash the process.
133
+ Promise.resolve(handler(event)).catch(() => {
134
+ /* swallow */
135
+ });
93
136
  }
94
137
  catch {
95
138
  /* swallow */
96
139
  }
97
140
  }
98
141
  }
99
- async executeWithMiddleware(operation, key, fn) {
142
+ recordGetOutcome(key, hit, latencyMs) {
143
+ if (hit) {
144
+ this.metrics?.incrementHit(key);
145
+ this.emit({ type: "cache.hit", key, occurredAt: new Date(), latencyMs });
146
+ }
147
+ else {
148
+ this.metrics?.incrementMiss(key);
149
+ this.emit({ type: "cache.miss", key, occurredAt: new Date(), latencyMs });
150
+ }
151
+ }
152
+ async executeWithMiddleware(operation, key, fn, details) {
100
153
  const context = {
101
154
  key,
102
155
  operation,
@@ -108,45 +161,33 @@ export class DefaultCacheStore {
108
161
  const result = await fn();
109
162
  const latencyMs = performance.now() - start;
110
163
  this.metrics?.observeLatency(operation, latencyMs);
111
- if (operation === "get") {
164
+ if (operation === CacheOperation.GET) {
112
165
  const hitResult = result;
113
- if (hitResult.hit) {
114
- this.metrics?.incrementHit(key);
115
- this.emit({
116
- type: "cache.hit",
117
- key,
118
- occurredAt: new Date(),
119
- latencyMs,
120
- });
121
- }
122
- else {
123
- this.metrics?.incrementMiss(key);
124
- this.emit({
125
- type: "cache.miss",
126
- key,
127
- occurredAt: new Date(),
128
- latencyMs,
129
- });
130
- }
166
+ this.recordGetOutcome(key, hitResult.hit, latencyMs);
131
167
  }
132
- else if (operation === "set") {
168
+ else if (operation === CacheOperation.SET) {
133
169
  this.metrics?.incrementSet(key);
134
- this.emit({ type: "cache.set", key, occurredAt: new Date() });
170
+ this.emit({
171
+ type: "cache.set",
172
+ key,
173
+ occurredAt: new Date(),
174
+ ...(details?.ttl !== undefined ? { ttl: details.ttl } : {}),
175
+ });
135
176
  }
136
- else if (operation === "delete") {
177
+ else if (operation === CacheOperation.DELETE) {
137
178
  this.metrics?.incrementDelete(key);
138
179
  this.emit({
139
180
  type: "cache.delete",
140
181
  key,
141
182
  occurredAt: new Date(),
142
- deleted: true,
183
+ deleted: result.deleted,
143
184
  });
144
185
  }
145
- else if (operation === "clear") {
186
+ else if (operation === CacheOperation.CLEAR) {
146
187
  this.emit({
147
188
  type: "cache.clear",
148
189
  occurredAt: new Date(),
149
- cleared: 0,
190
+ cleared: result.cleared,
150
191
  });
151
192
  }
152
193
  return result;
@@ -154,24 +195,43 @@ export class DefaultCacheStore {
154
195
  catch (error) {
155
196
  this.metrics?.incrementError(key);
156
197
  this.emit({ type: "cache.error", key, occurredAt: new Date(), error });
198
+ if (error instanceof CacheError)
199
+ throw error;
200
+ const code = "CACHE_OPERATION_FAILED";
157
201
  throw new CacheError(`Cache ${operation} failed for key "${key}".`, {
202
+ code,
158
203
  cause: error,
159
204
  operation,
160
205
  key,
161
206
  });
162
207
  }
163
208
  };
164
- let index = 0;
165
- const chain = async () => {
209
+ // The position is a parameter, not shared mutable state: a middleware
210
+ // that calls next() twice (retry middlewares do) re-enters at the same
211
+ // position instead of skipping the middlewares after it.
212
+ const chain = async (index) => {
166
213
  if (index >= this.middlewares.length)
167
214
  return execute();
168
215
  const middleware = this.middlewares[index];
169
- index++;
170
216
  if (middleware === undefined)
171
- return execute();
172
- return middleware(context, chain);
217
+ return chain(index + 1);
218
+ const result = await middleware(context, () => chain(index + 1));
219
+ return this.narrowMiddlewareResult(result, operation, key);
173
220
  };
174
- return chain();
221
+ return chain(0);
222
+ }
223
+ /**
224
+ * A middleware that forgets to return `next()`'s result resolves
225
+ * `undefined`, which would otherwise be laundered into `T` by an unchecked
226
+ * cast and surface as a `TypeError` far from the cause. Only `ttl` may
227
+ * legitimately resolve `undefined`.
228
+ */
229
+ narrowMiddlewareResult(result, operation, key) {
230
+ if (result === undefined && operation !== CacheOperation.TTL) {
231
+ const code = "CACHE_MIDDLEWARE_RESULT_MISSING";
232
+ throw new CacheError(`A cache middleware resolved undefined for ${operation} on key "${key}". Middlewares must return the result of next().`, { code, operation, key });
233
+ }
234
+ return result;
175
235
  }
176
236
  }
177
237
  export function createCacheStore(options) {
package/dist/tags.d.ts CHANGED
@@ -3,30 +3,50 @@
3
3
  *
4
4
  * Tag-based cache invalidation registry. Maps tags to cache keys,
5
5
  * allowing bulk invalidation of related entries.
6
+ *
7
+ * Tags are scoped by namespace: the internal map key is the
8
+ * `(namespace, tag)` pair, so the same tag registered under two namespaces
9
+ * yields two independent sets. Without this, one tenant's
10
+ * `invalidateByTag(["users"])` would delete another tenant's entries — the
11
+ * keys are namespace-qualified, but a flat tag map would not be.
12
+ *
13
+ * NOTE: The tag store is not notified when entries expire or are evicted
14
+ * by the adapter, so cleanup of stale mappings is lazy — dead keys linger
15
+ * until `invalidateByTag`/`removeKey`/`clear` touches them, and
16
+ * invalidation tolerates keys that no longer exist in the cache.
6
17
  */
7
18
  import type { CacheClearResult, CacheKey, CacheTag, CacheTagOptions, CacheTagStore } from "./types.js";
19
+ /**
20
+ * Validates a tag. Tags are untrusted strings used as map keys, so they are
21
+ * required to be non-empty, length-bounded, and free of the scope separator.
22
+ */
23
+ export declare function assertValidTag(tag: CacheTag): void;
8
24
  /**
9
25
  * In-memory implementation of `CacheTagStore`.
10
26
  *
11
- * Maintains a bidirectional mapping between tags and cache keys.
27
+ * Maintains a bidirectional mapping between scoped tags and cache keys.
12
28
  * For production use, this should be backed by a persistent store
13
29
  * (e.g., Redis SETs).
14
30
  */
15
31
  export declare class InMemoryTagStore implements CacheTagStore {
16
- /** Tag → Set of keys */
32
+ /** (namespace, tag) → Set of keys */
17
33
  private readonly tagToKeys;
18
- /** Key → Set of tags */
34
+ /** Key → Set of (namespace, tag) */
19
35
  private readonly keyToTags;
20
- add(key: CacheKey, tags: readonly CacheTag[], _options?: CacheTagOptions): Promise<void>;
21
- remove(key: CacheKey, tags: readonly CacheTag[], _options?: CacheTagOptions): Promise<void>;
22
- getKeys(tag: CacheTag, _options?: CacheTagOptions): Promise<readonly CacheKey[]>;
23
- invalidate(tag: CacheTag, _options?: CacheTagOptions): Promise<CacheClearResult>;
24
- /** Returns all registered tags. */
25
- tags(): readonly CacheTag[];
36
+ add(key: CacheKey, tags: readonly CacheTag[], options?: CacheTagOptions): Promise<void>;
37
+ remove(key: CacheKey, tags: readonly CacheTag[], options?: CacheTagOptions): Promise<void>;
38
+ getKeys(tag: CacheTag, options?: CacheTagOptions): Promise<readonly CacheKey[]>;
39
+ invalidate(tag: CacheTag, options?: CacheTagOptions): Promise<CacheClearResult>;
40
+ /** Removes all tag mappings for a key (e.g. after the key is deleted). */
41
+ removeKey(key: CacheKey): void;
42
+ /** Returns all keys that currently have tag mappings. */
43
+ trackedKeys(): readonly CacheKey[];
44
+ /** Returns all registered tags, optionally scoped to a namespace. */
45
+ tags(options?: CacheTagOptions): readonly CacheTag[];
26
46
  /** Returns the number of keys mapped to a tag. */
27
- count(tag: CacheTag): number;
28
- /** Returns all tags for a given key. */
29
- tagsForKey(key: CacheKey): readonly CacheTag[];
47
+ count(tag: CacheTag, options?: CacheTagOptions): number;
48
+ /** Returns all tags for a given key, optionally scoped to a namespace. */
49
+ tagsForKey(key: CacheKey, options?: CacheTagOptions): readonly CacheTag[];
30
50
  /** Clears all tag mappings. */
31
51
  clear(): void;
32
52
  }
package/dist/tags.js CHANGED
@@ -3,77 +3,157 @@
3
3
  *
4
4
  * Tag-based cache invalidation registry. Maps tags to cache keys,
5
5
  * allowing bulk invalidation of related entries.
6
+ *
7
+ * Tags are scoped by namespace: the internal map key is the
8
+ * `(namespace, tag)` pair, so the same tag registered under two namespaces
9
+ * yields two independent sets. Without this, one tenant's
10
+ * `invalidateByTag(["users"])` would delete another tenant's entries — the
11
+ * keys are namespace-qualified, but a flat tag map would not be.
12
+ *
13
+ * NOTE: The tag store is not notified when entries expire or are evicted
14
+ * by the adapter, so cleanup of stale mappings is lazy — dead keys linger
15
+ * until `invalidateByTag`/`removeKey`/`clear` touches them, and
16
+ * invalidation tolerates keys that no longer exist in the cache.
17
+ */
18
+ import { MAX_TAG_LENGTH } from "./constants.js";
19
+ import { CacheError } from "./errors.js";
20
+ /** Separates the namespace from the tag in the internal map key. */
21
+ const SCOPE_SEPARATOR = "\u0000";
22
+ /**
23
+ * Composes the internal `(namespace, tag)` map key. A NUL byte cannot appear
24
+ * in a validated tag, so the composition is unambiguous.
6
25
  */
26
+ function scopedTag(tag, options) {
27
+ return `${options?.namespace ?? ""}${SCOPE_SEPARATOR}${tag}`;
28
+ }
29
+ /**
30
+ * Validates a tag. Tags are untrusted strings used as map keys, so they are
31
+ * required to be non-empty, length-bounded, and free of the scope separator.
32
+ */
33
+ export function assertValidTag(tag) {
34
+ if (typeof tag !== "string" || tag.length === 0) {
35
+ throw new CacheError("Cache tag must be a non-empty string.", {
36
+ code: "CACHE_OPERATION_FAILED",
37
+ statusCode: 400,
38
+ expose: true,
39
+ });
40
+ }
41
+ 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
+ }
44
+ }
7
45
  /* -------------------------------------------------------------------------- */
8
46
  /* In-Memory Tag Store */
9
47
  /* -------------------------------------------------------------------------- */
10
48
  /**
11
49
  * In-memory implementation of `CacheTagStore`.
12
50
  *
13
- * Maintains a bidirectional mapping between tags and cache keys.
51
+ * Maintains a bidirectional mapping between scoped tags and cache keys.
14
52
  * For production use, this should be backed by a persistent store
15
53
  * (e.g., Redis SETs).
16
54
  */
17
55
  export class InMemoryTagStore {
18
- /** Tag → Set of keys */
56
+ /** (namespace, tag) → Set of keys */
19
57
  tagToKeys = new Map();
20
- /** Key → Set of tags */
58
+ /** Key → Set of (namespace, tag) */
21
59
  keyToTags = new Map();
22
60
  /* ---- Add Tags ---- */
23
- async add(key, tags, _options) {
61
+ async add(key, tags, options) {
62
+ for (const tag of tags)
63
+ assertValidTag(tag);
24
64
  for (const tag of tags) {
25
- if (!this.tagToKeys.has(tag)) {
26
- this.tagToKeys.set(tag, new Set());
65
+ const scoped = scopedTag(tag, options);
66
+ if (!this.tagToKeys.has(scoped)) {
67
+ this.tagToKeys.set(scoped, new Set());
27
68
  }
28
- this.tagToKeys.get(tag).add(key);
29
- }
30
- if (!this.keyToTags.has(key)) {
31
- this.keyToTags.set(key, new Set());
32
- }
33
- for (const tag of tags) {
34
- this.keyToTags.get(key).add(tag);
69
+ this.tagToKeys.get(scoped).add(key);
70
+ if (!this.keyToTags.has(key)) {
71
+ this.keyToTags.set(key, new Set());
72
+ }
73
+ this.keyToTags.get(key).add(scoped);
35
74
  }
36
75
  }
37
76
  /* ---- Remove Tags ---- */
38
- async remove(key, tags, _options) {
39
- for (const tag of tags) {
40
- this.tagToKeys.get(tag)?.delete(key);
41
- }
77
+ async remove(key, tags, options) {
42
78
  for (const tag of tags) {
43
- this.keyToTags.get(key)?.delete(tag);
79
+ const scoped = scopedTag(tag, options);
80
+ const keys = this.tagToKeys.get(scoped);
81
+ keys?.delete(key);
82
+ if (keys && keys.size === 0)
83
+ this.tagToKeys.delete(scoped);
84
+ const owned = this.keyToTags.get(key);
85
+ owned?.delete(scoped);
86
+ if (owned && owned.size === 0)
87
+ this.keyToTags.delete(key);
44
88
  }
45
89
  }
46
90
  /* ---- Get Keys by Tag ---- */
47
- async getKeys(tag, _options) {
48
- return [...(this.tagToKeys.get(tag) ?? [])];
91
+ async getKeys(tag, options) {
92
+ return [...(this.tagToKeys.get(scopedTag(tag, options)) ?? [])];
49
93
  }
50
94
  /* ---- Invalidate by Tag ---- */
51
- async invalidate(tag, _options) {
52
- const keys = this.tagToKeys.get(tag);
95
+ async invalidate(tag, options) {
96
+ const scoped = scopedTag(tag, options);
97
+ const keys = this.tagToKeys.get(scoped);
53
98
  if (!keys) {
54
99
  return { cleared: 0 };
55
100
  }
56
101
  const count = keys.size;
57
- // Remove reverse mappings
102
+ // Remove reverse mappings, dropping keys that end up with no tags at
103
+ // all — otherwise every key ever tagged would leak an empty Set for the
104
+ // lifetime of the process and pollute `trackedKeys()`.
58
105
  for (const key of keys) {
59
- this.keyToTags.get(key)?.delete(tag);
106
+ const remaining = this.keyToTags.get(key);
107
+ remaining?.delete(scoped);
108
+ if (remaining && remaining.size === 0)
109
+ this.keyToTags.delete(key);
60
110
  }
61
111
  // Clear the tag
62
- this.tagToKeys.delete(tag);
112
+ this.tagToKeys.delete(scoped);
63
113
  return { cleared: count };
64
114
  }
115
+ /* ---- Remove Key ---- */
116
+ /** Removes all tag mappings for a key (e.g. after the key is deleted). */
117
+ removeKey(key) {
118
+ const tags = this.keyToTags.get(key);
119
+ if (!tags)
120
+ return;
121
+ for (const scoped of tags) {
122
+ const keys = this.tagToKeys.get(scoped);
123
+ keys?.delete(key);
124
+ if (keys && keys.size === 0)
125
+ this.tagToKeys.delete(scoped);
126
+ }
127
+ this.keyToTags.delete(key);
128
+ }
65
129
  /* ---- Utility ---- */
66
- /** Returns all registered tags. */
67
- tags() {
68
- return [...this.tagToKeys.keys()];
130
+ /** Returns all keys that currently have tag mappings. */
131
+ trackedKeys() {
132
+ return [...this.keyToTags.keys()];
133
+ }
134
+ /** Returns all registered tags, optionally scoped to a namespace. */
135
+ tags(options) {
136
+ const prefix = `${options?.namespace ?? ""}${SCOPE_SEPARATOR}`;
137
+ const result = [];
138
+ for (const scoped of this.tagToKeys.keys()) {
139
+ if (scoped.startsWith(prefix))
140
+ result.push(scoped.slice(prefix.length));
141
+ }
142
+ return result;
69
143
  }
70
144
  /** Returns the number of keys mapped to a tag. */
71
- count(tag) {
72
- return this.tagToKeys.get(tag)?.size ?? 0;
145
+ count(tag, options) {
146
+ return this.tagToKeys.get(scopedTag(tag, options))?.size ?? 0;
73
147
  }
74
- /** Returns all tags for a given key. */
75
- tagsForKey(key) {
76
- return [...(this.keyToTags.get(key) ?? [])];
148
+ /** Returns all tags for a given key, optionally scoped to a namespace. */
149
+ tagsForKey(key, options) {
150
+ const prefix = `${options?.namespace ?? ""}${SCOPE_SEPARATOR}`;
151
+ const result = [];
152
+ for (const scoped of this.keyToTags.get(key) ?? []) {
153
+ if (scoped.startsWith(prefix))
154
+ result.push(scoped.slice(prefix.length));
155
+ }
156
+ return result;
77
157
  }
78
158
  /** Clears all tag mappings. */
79
159
  clear() {
@@ -1,24 +1,38 @@
1
1
  import type { CacheKey } from "./types-keys.js";
2
2
  import type { CacheTTL } from "./types-values.js";
3
- import type { CacheClearOptions, CacheDeleteManyOptions, CacheDeleteOptions, CacheGetManyOptions, CacheGetOptions, CacheHasOptions, CacheKeysOptions, CacheSetManyOptions, CacheSetOptions } from "./types-operations.js";
3
+ import type { CacheClearOptions, CacheKeysOptions, CacheSetManyOptions, CacheSetOptions } from "./types-operations.js";
4
4
  import type { CacheClearResult, CacheDeleteManyResult, CacheDeleteResult, CacheGetResult, CacheSetResult } from "./types-results.js";
5
+ /**
6
+ * Low-level cache adapter contract.
7
+ *
8
+ * Adapters operate on fully-qualified keys — namespace/prefix resolution is
9
+ * the responsibility of `CacheService` and the key builder. Adapters only
10
+ * understand raw keys and glob patterns.
11
+ */
5
12
  export interface CacheAdapter {
6
13
  readonly name: string;
7
14
  connect?(): Promise<void>;
8
15
  disconnect?(): Promise<void>;
9
- get<TValue = unknown>(key: CacheKey, options?: CacheGetOptions): Promise<CacheGetResult<TValue>>;
16
+ get<TValue = unknown>(key: CacheKey): Promise<CacheGetResult<TValue>>;
10
17
  set<TValue = unknown>(key: CacheKey, value: TValue, options?: CacheSetOptions): Promise<CacheSetResult>;
11
- delete(key: CacheKey, options?: CacheDeleteOptions): Promise<CacheDeleteResult>;
12
- has(key: CacheKey, options?: CacheHasOptions): Promise<boolean>;
18
+ delete(key: CacheKey): Promise<CacheDeleteResult>;
19
+ has(key: CacheKey): Promise<boolean>;
13
20
  clear(options?: CacheClearOptions): Promise<CacheClearResult>;
14
21
  keys?(options?: CacheKeysOptions): Promise<readonly CacheKey[]>;
15
- getMany?<TValue = unknown>(keys: readonly CacheKey[], options?: CacheGetManyOptions): Promise<ReadonlyMap<CacheKey, CacheGetResult<TValue>>>;
22
+ getMany?<TValue = unknown>(keys: readonly CacheKey[]): Promise<ReadonlyMap<CacheKey, CacheGetResult<TValue>>>;
16
23
  setMany?<TValue = unknown>(entries: ReadonlyMap<CacheKey, TValue>, options?: CacheSetManyOptions): Promise<readonly CacheSetResult[]>;
17
- deleteMany?(keys: readonly CacheKey[], options?: CacheDeleteManyOptions): Promise<CacheDeleteManyResult>;
18
- ttl?(key: CacheKey, options?: CacheGetOptions): Promise<number | null>;
19
- expire?(key: CacheKey, ttl: CacheTTL, options?: CacheGetOptions): Promise<boolean>;
24
+ deleteMany?(keys: readonly CacheKey[]): Promise<CacheDeleteManyResult>;
25
+ /**
26
+ * Remaining TTL for a key in milliseconds.
27
+ * - `undefined` — the key does not exist (or has expired)
28
+ * - `null` — the key exists and never expires
29
+ * - `number` — remaining milliseconds until expiry
30
+ */
31
+ ttl?(key: CacheKey): Promise<number | null | undefined>;
32
+ expire?(key: CacheKey, ttl: CacheTTL): Promise<boolean>;
33
+ /** Number of live entries currently held, when the adapter can report it. */
34
+ size?(): Promise<number | undefined>;
20
35
  }
21
36
  export interface CacheStore extends CacheAdapter {
22
37
  }
23
- export type CacheAdapterFactory<TOptions = unknown> = (options: TOptions) => CacheAdapter | Promise<CacheAdapter>;
24
38
  //# sourceMappingURL=types-adapter.d.ts.map