@c9up/echo 0.1.4 → 0.1.6
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/dist/CacheManager.d.ts +68 -21
- package/dist/CacheManager.d.ts.map +1 -1
- package/dist/CacheManager.js +355 -60
- package/dist/CacheManager.js.map +1 -1
- package/dist/EchoProvider.d.ts +14 -8
- package/dist/EchoProvider.d.ts.map +1 -1
- package/dist/EchoProvider.js +45 -11
- package/dist/EchoProvider.js.map +1 -1
- package/dist/StoreManager.d.ts +61 -0
- package/dist/StoreManager.d.ts.map +1 -0
- package/dist/StoreManager.js +70 -0
- package/dist/StoreManager.js.map +1 -0
- package/dist/drivers/MemoryDriver.d.ts +13 -6
- package/dist/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/drivers/MemoryDriver.js +81 -56
- package/dist/drivers/MemoryDriver.js.map +1 -1
- package/dist/drivers/RedisDriver.d.ts +17 -17
- package/dist/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/drivers/RedisDriver.js +107 -47
- package/dist/drivers/RedisDriver.js.map +1 -1
- package/dist/drivers/TieredDriver.d.ts +41 -0
- package/dist/drivers/TieredDriver.d.ts.map +1 -0
- package/dist/drivers/TieredDriver.js +132 -0
- package/dist/drivers/TieredDriver.js.map +1 -0
- package/dist/duration.d.ts +32 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +75 -0
- package/dist/duration.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +21 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/testing/main.d.ts +41 -0
- package/dist/testing/main.d.ts.map +1 -0
- package/dist/testing/main.js +41 -0
- package/dist/testing/main.js.map +1 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +6 -1
- package/src/CacheManager.ts +505 -89
- package/src/EchoProvider.ts +55 -12
- package/src/StoreManager.ts +104 -0
- package/src/drivers/MemoryDriver.ts +109 -60
- package/src/drivers/RedisDriver.ts +139 -51
- package/src/drivers/TieredDriver.ts +186 -0
- package/src/duration.ts +86 -0
- package/src/errors.ts +33 -0
- package/src/index.ts +53 -1
- package/src/testing/main.ts +69 -0
- package/src/types.ts +156 -0
package/src/EchoProvider.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type CacheConfig, CacheManager } from "./CacheManager.js";
|
|
2
2
|
import { MemoryDriver } from "./drivers/MemoryDriver.js";
|
|
3
|
+
import { CacheStoreManager, type MultiStoreConfig } from "./StoreManager.js";
|
|
3
4
|
import { setCache } from "./services/main.js";
|
|
5
|
+
import type { CacheEmitter } from "./types.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Duck-typed host context — echo stays publishable without importing
|
|
@@ -24,42 +26,83 @@ export interface EchoProviderConfig extends CacheConfig {
|
|
|
24
26
|
* Driver to bind by default. Only `"memory"` is created
|
|
25
27
|
* automatically — other drivers (Redis etc.) need custom client
|
|
26
28
|
* wiring, so apps build the `CacheManager` themselves and call
|
|
27
|
-
* `setCache(...)` from `@c9up/echo/services/main
|
|
29
|
+
* `setCache(...)` from `@c9up/echo/services/main`, or use the
|
|
30
|
+
* multi-store `{ default, stores }` config with `drivers.*`.
|
|
28
31
|
*
|
|
29
32
|
* Default `"memory"`.
|
|
30
33
|
*/
|
|
31
34
|
driver?: "memory";
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
function isEmitter(value: unknown): value is CacheEmitter {
|
|
38
|
+
return (
|
|
39
|
+
typeof value === "object" &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
typeof Reflect.get(value, "emit") === "function"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isMultiStoreConfig(value: unknown): value is MultiStoreConfig {
|
|
46
|
+
return (
|
|
47
|
+
typeof value === "object" &&
|
|
48
|
+
value !== null &&
|
|
49
|
+
"stores" in value &&
|
|
50
|
+
"default" in value
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
/**
|
|
35
|
-
* EchoProvider — registers a
|
|
36
|
-
*
|
|
37
|
-
*
|
|
55
|
+
* EchoProvider — registers a `CacheManager` (single-store default, or the
|
|
56
|
+
* default store of a `{ default, stores }` config) so apps can
|
|
57
|
+
* `import cache from '@c9up/echo/services/main'` and use it straight away. If
|
|
58
|
+
* the host container exposes an `emitter`, cache events (`cache:hit` / `miss` /
|
|
59
|
+
* `written` / `deleted` / `cleared`) are wired through it.
|
|
38
60
|
*
|
|
39
61
|
* // reamrc.ts
|
|
40
62
|
* providers: [() => import('@c9up/echo/provider')]
|
|
41
63
|
*
|
|
42
|
-
* // config/cache.ts
|
|
64
|
+
* // config/cache.ts (single store)
|
|
43
65
|
* export default { driver: 'memory', prefix: 'myapp', ttl: 300 }
|
|
44
66
|
*
|
|
45
|
-
* //
|
|
46
|
-
*
|
|
47
|
-
*
|
|
67
|
+
* // config/cache.ts (multi-store)
|
|
68
|
+
* export default defineConfig({
|
|
69
|
+
* default: 'memory',
|
|
70
|
+
* stores: { memory: { driver: drivers.memory() } },
|
|
71
|
+
* })
|
|
48
72
|
*/
|
|
49
73
|
export default class EchoProvider {
|
|
50
74
|
constructor(protected app: EchoAppContext) {}
|
|
51
75
|
|
|
76
|
+
#resolveEmitter(): CacheEmitter | undefined {
|
|
77
|
+
try {
|
|
78
|
+
const candidate = this.app.container.resolve<unknown>("emitter");
|
|
79
|
+
if (isEmitter(candidate)) return candidate;
|
|
80
|
+
} catch {
|
|
81
|
+
// No emitter bound — events are simply not emitted.
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
register(): void {
|
|
53
87
|
this.app.container.singleton(CacheManager, () => {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
88
|
+
const emitter = this.#resolveEmitter();
|
|
89
|
+
const raw = this.app.config.get<unknown>("cache");
|
|
90
|
+
|
|
91
|
+
if (isMultiStoreConfig(raw)) {
|
|
92
|
+
const manager = new CacheStoreManager({ ...raw, emitter });
|
|
93
|
+
return manager.use();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = (raw ?? {}) as EchoProviderConfig;
|
|
97
|
+
const driver = config.driver ?? "memory";
|
|
56
98
|
if (driver !== "memory") {
|
|
57
99
|
throw new Error(
|
|
58
100
|
`[echo] Unsupported driver '${driver}' for default provider — ` +
|
|
59
|
-
"wire CacheManager yourself for non-memory drivers
|
|
101
|
+
"wire CacheManager yourself for non-memory drivers, or use the " +
|
|
102
|
+
"multi-store `{ default, stores }` config.",
|
|
60
103
|
);
|
|
61
104
|
}
|
|
62
|
-
return new CacheManager(new MemoryDriver(), config);
|
|
105
|
+
return new CacheManager(new MemoryDriver(), { ...config, emitter });
|
|
63
106
|
});
|
|
64
107
|
this.app.container.singleton("cache", () =>
|
|
65
108
|
this.app.container.resolve<CacheManager>(CacheManager),
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-store cache manager — `{ default, stores }` config + `cache.use(name)`
|
|
3
|
+
* (bentocache `BentoCache` / @adonisjs/cache parity), plus the `drivers.*`
|
|
4
|
+
* factory helpers.
|
|
5
|
+
*
|
|
6
|
+
* const cache = new CacheStoreManager(defineConfig({
|
|
7
|
+
* default: "memory",
|
|
8
|
+
* stores: {
|
|
9
|
+
* memory: { driver: drivers.memory() },
|
|
10
|
+
* redis: { driver: drivers.redis({ client }) },
|
|
11
|
+
* },
|
|
12
|
+
* }))
|
|
13
|
+
*
|
|
14
|
+
* await cache.use().set({ key: "k", value: 1 }) // default store
|
|
15
|
+
* await cache.use("redis").get({ key: "k" }) // named store
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { CacheManager } from "./CacheManager.js";
|
|
19
|
+
import { MemoryDriver } from "./drivers/MemoryDriver.js";
|
|
20
|
+
import { type RedisClient, RedisDriver } from "./drivers/RedisDriver.js";
|
|
21
|
+
import { type CacheBus, TieredDriver } from "./drivers/TieredDriver.js";
|
|
22
|
+
import type { Duration } from "./duration.js";
|
|
23
|
+
import type { CacheDriver, CacheEmitter } from "./types.js";
|
|
24
|
+
|
|
25
|
+
/** A lazily-instantiated driver (built once per store, on first `use`). */
|
|
26
|
+
export type DriverFactory = () => CacheDriver;
|
|
27
|
+
|
|
28
|
+
export interface StoreConfig {
|
|
29
|
+
driver: DriverFactory;
|
|
30
|
+
prefix?: string;
|
|
31
|
+
/** Default TTL in seconds. */
|
|
32
|
+
ttl?: number;
|
|
33
|
+
grace?: Duration;
|
|
34
|
+
timeout?: Duration;
|
|
35
|
+
hardTimeout?: Duration;
|
|
36
|
+
lockTimeout?: Duration;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MultiStoreConfig {
|
|
40
|
+
default: string;
|
|
41
|
+
stores: Record<string, StoreConfig>;
|
|
42
|
+
/** Shared emitter for all stores' events. */
|
|
43
|
+
emitter?: CacheEmitter;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Driver factory helpers (bento `drivers.memory` / `drivers.redis`). */
|
|
47
|
+
export const drivers = {
|
|
48
|
+
memory(options?: { sweepIntervalMs?: number }): DriverFactory {
|
|
49
|
+
return () => new MemoryDriver(options?.sweepIntervalMs);
|
|
50
|
+
},
|
|
51
|
+
redis(options: { client: RedisClient; prefix?: string }): DriverFactory {
|
|
52
|
+
return () => new RedisDriver(options.client, options.prefix);
|
|
53
|
+
},
|
|
54
|
+
tiered(options: {
|
|
55
|
+
l1: DriverFactory;
|
|
56
|
+
l2: DriverFactory;
|
|
57
|
+
bus?: CacheBus;
|
|
58
|
+
}): DriverFactory {
|
|
59
|
+
return () =>
|
|
60
|
+
new TieredDriver({
|
|
61
|
+
l1: options.l1(),
|
|
62
|
+
l2: options.l2(),
|
|
63
|
+
bus: options.bus,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export class CacheStoreManager {
|
|
69
|
+
#config: MultiStoreConfig;
|
|
70
|
+
#built: Map<string, CacheManager> = new Map();
|
|
71
|
+
|
|
72
|
+
constructor(config: MultiStoreConfig) {
|
|
73
|
+
this.#config = config;
|
|
74
|
+
if (!config.stores[config.default]) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Echo: default store "${config.default}" is not defined in stores`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a store by name (or the default). Instances are built once and cached. */
|
|
82
|
+
use(name?: string): CacheManager {
|
|
83
|
+
const store = name ?? this.#config.default;
|
|
84
|
+
const existing = this.#built.get(store);
|
|
85
|
+
if (existing) return existing;
|
|
86
|
+
|
|
87
|
+
const cfg = this.#config.stores[store];
|
|
88
|
+
if (!cfg) {
|
|
89
|
+
throw new Error(`Echo: unknown cache store "${store}"`);
|
|
90
|
+
}
|
|
91
|
+
const manager = new CacheManager(cfg.driver(), {
|
|
92
|
+
prefix: cfg.prefix,
|
|
93
|
+
ttl: cfg.ttl,
|
|
94
|
+
grace: cfg.grace,
|
|
95
|
+
timeout: cfg.timeout,
|
|
96
|
+
hardTimeout: cfg.hardTimeout,
|
|
97
|
+
lockTimeout: cfg.lockTimeout,
|
|
98
|
+
name: store,
|
|
99
|
+
emitter: this.#config.emitter,
|
|
100
|
+
});
|
|
101
|
+
this.#built.set(store, manager);
|
|
102
|
+
return manager;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Memory cache driver — in-process Map with TTL
|
|
3
|
-
* Suitable for development and single-process deployments
|
|
2
|
+
* Memory cache driver — in-process Map with TTL, grace (stale-while-revalidate)
|
|
3
|
+
* and tag support. Suitable for development and single-process deployments, and
|
|
4
|
+
* as the L1 tier of {@link TieredDriver}.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import type {
|
|
7
|
+
import type { CacheEntry, DriverSetOptions, TaggableDriver } from "../types.js";
|
|
7
8
|
|
|
8
|
-
interface
|
|
9
|
+
interface StoredEntry {
|
|
9
10
|
value: unknown;
|
|
11
|
+
/** Logical expiry (epoch ms); `0` means never. Past this the entry is stale. */
|
|
10
12
|
expiresAt: number;
|
|
13
|
+
/** Physical eviction (epoch ms); `0` means never. Grace window ends here. */
|
|
14
|
+
staleUntil: number;
|
|
11
15
|
tags: string[];
|
|
12
16
|
}
|
|
13
17
|
|
|
14
|
-
export class MemoryDriver implements
|
|
15
|
-
#store: Map<string,
|
|
18
|
+
export class MemoryDriver implements TaggableDriver {
|
|
19
|
+
#store: Map<string, StoredEntry> = new Map();
|
|
16
20
|
#tagIndex: Map<string, Set<string>> = new Map();
|
|
17
21
|
#sweepInterval: ReturnType<typeof setInterval>;
|
|
18
22
|
|
|
@@ -20,8 +24,8 @@ export class MemoryDriver implements CacheDriver {
|
|
|
20
24
|
this.#sweepInterval = setInterval(() => {
|
|
21
25
|
const now = Date.now();
|
|
22
26
|
for (const [key, entry] of this.#store) {
|
|
23
|
-
if (entry.
|
|
24
|
-
this.#
|
|
27
|
+
if (entry.staleUntil > 0 && entry.staleUntil < now) {
|
|
28
|
+
this.#evict(key, entry);
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
}, sweepIntervalMs);
|
|
@@ -38,24 +42,102 @@ export class MemoryDriver implements CacheDriver {
|
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
async get<T = unknown>(key: string): Promise<T | null> {
|
|
45
|
+
const entry = this.getEntrySync<T>(key);
|
|
46
|
+
if (entry === null || entry.stale) return null;
|
|
47
|
+
return entry.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async getEntry<T = unknown>(key: string): Promise<CacheEntry<T> | null> {
|
|
51
|
+
return this.getEntrySync<T>(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Synchronous read used by both `get` and `getEntry` (and by TieredDriver's L1 fast path). */
|
|
55
|
+
getEntrySync<T = unknown>(key: string): CacheEntry<T> | null {
|
|
41
56
|
const entry = this.#store.get(key);
|
|
42
57
|
if (!entry) return null;
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
if (entry.staleUntil > 0 && entry.staleUntil < now) {
|
|
60
|
+
this.#evict(key, entry);
|
|
45
61
|
return null;
|
|
46
62
|
}
|
|
47
|
-
|
|
63
|
+
const stale = entry.expiresAt > 0 && entry.expiresAt < now;
|
|
64
|
+
return { value: entry.value as T, stale, expiresAt: entry.expiresAt };
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Delete an entry AND scrub its tag-index refs. TTL/grace expiry (sweep +
|
|
69
|
+
* lazy get) must go through this — a bare `#store.delete` left dangling refs
|
|
70
|
+
* in `#tagIndex`, which a later `set(key, vNew)` reusing the key would turn
|
|
71
|
+
* into a wrong-purge under deleteByTag (audit 2026-06-13).
|
|
72
|
+
*/
|
|
73
|
+
#evict(key: string, entry: StoredEntry): void {
|
|
74
|
+
for (const tag of entry.tags) this.#tagIndex.get(tag)?.delete(key);
|
|
75
|
+
this.#store.delete(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#write(
|
|
79
|
+
key: string,
|
|
80
|
+
value: unknown,
|
|
81
|
+
ttlSeconds: number | undefined,
|
|
82
|
+
graceSeconds: number,
|
|
83
|
+
tags: string[],
|
|
84
|
+
expiresAtOverride?: number,
|
|
85
|
+
): void {
|
|
51
86
|
if (value === null || value === undefined) {
|
|
52
87
|
throw new TypeError(
|
|
53
88
|
"Echo: caching null/undefined values is not supported",
|
|
54
89
|
);
|
|
55
90
|
}
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const hasTtl = ttlSeconds != null && ttlSeconds > 0;
|
|
93
|
+
// An absolute `expiresAtOverride` (e.g. `expire()` marking stale-now) wins
|
|
94
|
+
// over the ttl-derived expiry; a past value makes the entry immediately stale.
|
|
56
95
|
const expiresAt =
|
|
57
|
-
|
|
58
|
-
|
|
96
|
+
expiresAtOverride !== undefined
|
|
97
|
+
? expiresAtOverride
|
|
98
|
+
: hasTtl
|
|
99
|
+
? now + ttlSeconds * 1000
|
|
100
|
+
: 0;
|
|
101
|
+
const graceMs = graceSeconds > 0 ? graceSeconds * 1000 : 0;
|
|
102
|
+
// A never-expiring entry never goes stale either; otherwise grace extends
|
|
103
|
+
// physical retention past the logical expiry. Measure grace from the LATER
|
|
104
|
+
// of the expiry and now, so an already-past expiry still survives its grace.
|
|
105
|
+
const staleUntil = expiresAt > 0 ? Math.max(expiresAt, now) + graceMs : 0;
|
|
106
|
+
// Reconcile the tag index: overwriting a previously-tagged key must drop
|
|
107
|
+
// its old tag-index refs, or a later deleteByTag could purge this fresh
|
|
108
|
+
// value (audit 2026-05-22 F3).
|
|
109
|
+
const prev = this.#store.get(key);
|
|
110
|
+
if (prev !== undefined) {
|
|
111
|
+
for (const t of prev.tags) this.#tagIndex.get(t)?.delete(key);
|
|
112
|
+
}
|
|
113
|
+
this.#store.set(key, { value, expiresAt, staleUntil, tags });
|
|
114
|
+
for (const tag of tags) {
|
|
115
|
+
let set = this.#tagIndex.get(tag);
|
|
116
|
+
if (!set) {
|
|
117
|
+
set = new Set();
|
|
118
|
+
this.#tagIndex.set(tag, set);
|
|
119
|
+
}
|
|
120
|
+
set.add(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
|
125
|
+
this.#write(key, value, ttlSeconds, 0, []);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async setEntry(
|
|
129
|
+
key: string,
|
|
130
|
+
value: unknown,
|
|
131
|
+
options: DriverSetOptions,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
this.#write(
|
|
134
|
+
key,
|
|
135
|
+
value,
|
|
136
|
+
options.ttlSeconds,
|
|
137
|
+
options.graceSeconds ?? 0,
|
|
138
|
+
options.tags ?? [],
|
|
139
|
+
options.expiresAt,
|
|
140
|
+
);
|
|
59
141
|
}
|
|
60
142
|
|
|
61
143
|
async delete(key: string): Promise<boolean> {
|
|
@@ -74,59 +156,24 @@ export class MemoryDriver implements CacheDriver {
|
|
|
74
156
|
}
|
|
75
157
|
|
|
76
158
|
async has(key: string): Promise<boolean> {
|
|
77
|
-
|
|
78
|
-
return val !== null;
|
|
159
|
+
return (await this.get(key)) !== null;
|
|
79
160
|
}
|
|
80
161
|
|
|
81
|
-
/** Set with tags for group invalidation. */
|
|
162
|
+
/** Set with tags for group invalidation (bento parity; no grace). */
|
|
82
163
|
async setWithTags(
|
|
83
164
|
key: string,
|
|
84
165
|
value: unknown,
|
|
85
166
|
tags: string[],
|
|
86
167
|
ttlSeconds?: number,
|
|
87
168
|
): Promise<void> {
|
|
88
|
-
|
|
89
|
-
// `ttlSeconds <= 0` (and undefined) as "no expiration". Previously
|
|
90
|
-
// `setWithTags` used a truthy check (`ttlSeconds ?`), which let a
|
|
91
|
-
// negative value through and produced `Date.now() + (-N * 1000)` —
|
|
92
|
-
// an already-past timestamp — so the entry was born already-expired.
|
|
93
|
-
// `set()` correctly returned the immortal-entry branch on the same
|
|
94
|
-
// input. The divergence made cache semantics depend on whether the
|
|
95
|
-
// caller used tags or not, which is the worst kind of "very hard to
|
|
96
|
-
// diagnose in prod" bug.
|
|
97
|
-
const expiresAt =
|
|
98
|
-
ttlSeconds != null && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
|
|
99
|
-
// Audit 2026-05-22 F3 (overwrite leg): if `key` already exists with
|
|
100
|
-
// a different tag set, the old #tagIndex entries become dangling
|
|
101
|
-
// refs to the (now overwritten) key. Clean them up before re-tagging
|
|
102
|
-
// so subsequent flushTags doesn't iterate stale references.
|
|
103
|
-
const prev = this.#store.get(key);
|
|
104
|
-
if (prev !== undefined) {
|
|
105
|
-
for (const t of prev.tags) this.#tagIndex.get(t)?.delete(key);
|
|
106
|
-
}
|
|
107
|
-
this.#store.set(key, { value, expiresAt, tags });
|
|
108
|
-
for (const tag of tags) {
|
|
109
|
-
let set = this.#tagIndex.get(tag);
|
|
110
|
-
if (!set) {
|
|
111
|
-
set = new Set();
|
|
112
|
-
this.#tagIndex.set(tag, set);
|
|
113
|
-
}
|
|
114
|
-
set.add(key);
|
|
115
|
-
}
|
|
169
|
+
this.#write(key, value, ttlSeconds, 0, tags);
|
|
116
170
|
}
|
|
117
171
|
|
|
118
|
-
/**
|
|
119
|
-
async
|
|
120
|
-
// Audit 2026-05-22 F3:
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// reference. A later flushTags(`fr`) would iterate over the
|
|
124
|
-
// stale entry, attempt `#store.delete(key)` (no-op), and the entry
|
|
125
|
-
// would silently linger in #tagIndex forever. Worse — if a new
|
|
126
|
-
// entry was later written under the same key with different tags,
|
|
127
|
-
// flushTags(`fr`) would WRONGLY purge it because of the residue.
|
|
128
|
-
// Collect keys first, then scrub each one from EVERY tag set it
|
|
129
|
-
// belonged to (including the tags we're not flushing this round).
|
|
172
|
+
/** Invalidate all entries tagged with any of the given tags. */
|
|
173
|
+
async deleteByTag(tags: string[]): Promise<void> {
|
|
174
|
+
// Audit 2026-05-22 F3: scrub each key from EVERY tag set it belongs to
|
|
175
|
+
// (including tags not being flushed this round), else a multi-tagged key
|
|
176
|
+
// leaves a dangling ref that later wrong-purges a reused key.
|
|
130
177
|
const toDelete = new Set<string>();
|
|
131
178
|
for (const tag of tags) {
|
|
132
179
|
const keys = this.#tagIndex.get(tag);
|
|
@@ -143,9 +190,11 @@ export class MemoryDriver implements CacheDriver {
|
|
|
143
190
|
}
|
|
144
191
|
this.#store.delete(key);
|
|
145
192
|
}
|
|
146
|
-
// Now drop the flushed tag sets themselves (any other keys still
|
|
147
|
-
// referenced by them have been processed in the toDelete loop and
|
|
148
|
-
// already removed via the inner scrub).
|
|
149
193
|
for (const tag of tags) this.#tagIndex.delete(tag);
|
|
150
194
|
}
|
|
195
|
+
|
|
196
|
+
/** @deprecated alias of {@link deleteByTag}. */
|
|
197
|
+
async flushTags(tags: string[]): Promise<void> {
|
|
198
|
+
return this.deleteByTag(tags);
|
|
199
|
+
}
|
|
151
200
|
}
|