@zudojs/cache 0.1.0 → 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.
- package/LICENSE +21 -0
- package/README.md +90 -14
- package/dist/cache.d.ts +107 -18
- package/dist/cache.js +393 -33
- package/dist/constants.d.ts +46 -12
- package/dist/constants.js +47 -19
- package/dist/index.d.ts +10 -9
- package/dist/index.js +9 -8
- package/dist/invalidation.d.ts +23 -16
- package/dist/invalidation.js +43 -33
- package/dist/key-builder.d.ts +15 -0
- package/dist/key-builder.js +43 -3
- package/dist/lock.d.ts +36 -2
- package/dist/lock.js +160 -30
- package/dist/memory.d.ts +63 -7
- package/dist/memory.js +223 -39
- package/dist/metrics.d.ts +10 -0
- package/dist/metrics.js +49 -4
- package/dist/serializer.d.ts +19 -10
- package/dist/serializer.js +88 -21
- package/dist/store.d.ts +21 -11
- package/dist/store.js +125 -65
- package/dist/tags.d.ts +32 -12
- package/dist/tags.js +113 -33
- package/dist/types-adapter.d.ts +23 -9
- package/dist/types-config.d.ts +46 -9
- package/dist/types-health.d.ts +5 -2
- package/dist/types-keys.d.ts +8 -4
- package/dist/types-lock.d.ts +8 -0
- package/dist/types-operations.d.ts +13 -20
- package/dist/types-results.d.ts +7 -1
- package/dist/types-tags.d.ts +9 -0
- package/dist/types-utility.d.ts +6 -10
- package/dist/types-values.d.ts +9 -14
- package/dist/types.d.ts +6 -6
- package/dist/utils.d.ts +65 -0
- package/dist/utils.js +210 -0
- package/package.json +21 -14
- package/dist/.tsbuildinfo +0 -1
- package/dist/cache.d.ts.map +0 -1
- package/dist/cache.js.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/invalidation.d.ts.map +0 -1
- package/dist/invalidation.js.map +0 -1
- package/dist/key-builder.d.ts.map +0 -1
- package/dist/key-builder.js.map +0 -1
- package/dist/lock.d.ts.map +0 -1
- package/dist/lock.js.map +0 -1
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js.map +0 -1
- package/dist/metrics.d.ts.map +0 -1
- package/dist/metrics.js.map +0 -1
- package/dist/serializer.d.ts.map +0 -1
- package/dist/serializer.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/tags.d.ts.map +0 -1
- package/dist/tags.js.map +0 -1
- package/dist/types-adapter.d.ts.map +0 -1
- package/dist/types-adapter.js.map +0 -1
- package/dist/types-config.d.ts.map +0 -1
- package/dist/types-config.js.map +0 -1
- package/dist/types-events.d.ts.map +0 -1
- package/dist/types-events.js.map +0 -1
- package/dist/types-health.d.ts.map +0 -1
- package/dist/types-health.js.map +0 -1
- package/dist/types-keys.d.ts.map +0 -1
- package/dist/types-keys.js.map +0 -1
- package/dist/types-lock.d.ts.map +0 -1
- package/dist/types-lock.js.map +0 -1
- package/dist/types-metrics.d.ts.map +0 -1
- package/dist/types-metrics.js.map +0 -1
- package/dist/types-operations.d.ts.map +0 -1
- package/dist/types-operations.js.map +0 -1
- package/dist/types-results.d.ts.map +0 -1
- package/dist/types-results.js.map +0 -1
- package/dist/types-tags.d.ts.map +0 -1
- package/dist/types-tags.js.map +0 -1
- package/dist/types-utility.d.ts.map +0 -1
- package/dist/types-utility.js.map +0 -1
- package/dist/types-values.d.ts.map +0 -1
- package/dist/types-values.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- 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,
|
|
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
|
|
20
|
-
has(key: string
|
|
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
|
|
26
|
+
delete(key: string): Promise<CacheDeleteResult>;
|
|
24
27
|
clear(options?: CacheClearOptions): Promise<CacheClearResult>;
|
|
25
|
-
getMany<TValue = unknown>(keys: readonly string[]
|
|
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[]
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
26
|
-
return this.executeWithMiddleware(
|
|
29
|
+
async get(key) {
|
|
30
|
+
return this.executeWithMiddleware(CacheOperation.GET, key, () => this.adapter.get(key));
|
|
27
31
|
}
|
|
28
|
-
async has(key
|
|
29
|
-
return this.executeWithMiddleware(
|
|
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(
|
|
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(
|
|
39
|
+
return this.executeWithMiddleware(CacheOperation.SET, key, () => this.adapter.set(key, value, options), { ttl: options?.ttl });
|
|
36
40
|
}
|
|
37
|
-
async delete(key
|
|
38
|
-
return this.executeWithMiddleware(
|
|
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(
|
|
42
|
-
}
|
|
43
|
-
async getMany(keys
|
|
44
|
-
if (this.adapter.getMany)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
results
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
results
|
|
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
|
|
60
|
-
if (this.adapter.deleteMany)
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
const
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
164
|
+
if (operation === CacheOperation.GET) {
|
|
112
165
|
const hitResult = result;
|
|
113
|
-
|
|
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 ===
|
|
168
|
+
else if (operation === CacheOperation.SET) {
|
|
133
169
|
this.metrics?.incrementSet(key);
|
|
134
|
-
this.emit({
|
|
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 ===
|
|
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:
|
|
183
|
+
deleted: result.deleted,
|
|
143
184
|
});
|
|
144
185
|
}
|
|
145
|
-
else if (operation ===
|
|
186
|
+
else if (operation === CacheOperation.CLEAR) {
|
|
146
187
|
this.emit({
|
|
147
188
|
type: "cache.clear",
|
|
148
189
|
occurredAt: new Date(),
|
|
149
|
-
cleared:
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
/**
|
|
32
|
+
/** (namespace, tag) → Set of keys */
|
|
17
33
|
private readonly tagToKeys;
|
|
18
|
-
/** Key → Set of
|
|
34
|
+
/** Key → Set of (namespace, tag) */
|
|
19
35
|
private readonly keyToTags;
|
|
20
|
-
add(key: CacheKey, tags: readonly CacheTag[],
|
|
21
|
-
remove(key: CacheKey, tags: readonly CacheTag[],
|
|
22
|
-
getKeys(tag: CacheTag,
|
|
23
|
-
invalidate(tag: CacheTag,
|
|
24
|
-
/**
|
|
25
|
-
|
|
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
|
-
/**
|
|
56
|
+
/** (namespace, tag) → Set of keys */
|
|
19
57
|
tagToKeys = new Map();
|
|
20
|
-
/** Key → Set of
|
|
58
|
+
/** Key → Set of (namespace, tag) */
|
|
21
59
|
keyToTags = new Map();
|
|
22
60
|
/* ---- Add Tags ---- */
|
|
23
|
-
async add(key, tags,
|
|
61
|
+
async add(key, tags, options) {
|
|
62
|
+
for (const tag of tags)
|
|
63
|
+
assertValidTag(tag);
|
|
24
64
|
for (const tag of tags) {
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
52
|
-
const
|
|
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)
|
|
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(
|
|
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
|
|
67
|
-
|
|
68
|
-
return [...this.
|
|
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
|
-
|
|
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() {
|
package/dist/types-adapter.d.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
12
|
-
has(key: CacheKey
|
|
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[]
|
|
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[]
|
|
18
|
-
|
|
19
|
-
|
|
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
|