@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.
Files changed (56) hide show
  1. package/dist/CacheManager.d.ts +68 -21
  2. package/dist/CacheManager.d.ts.map +1 -1
  3. package/dist/CacheManager.js +355 -60
  4. package/dist/CacheManager.js.map +1 -1
  5. package/dist/EchoProvider.d.ts +14 -8
  6. package/dist/EchoProvider.d.ts.map +1 -1
  7. package/dist/EchoProvider.js +45 -11
  8. package/dist/EchoProvider.js.map +1 -1
  9. package/dist/StoreManager.d.ts +61 -0
  10. package/dist/StoreManager.d.ts.map +1 -0
  11. package/dist/StoreManager.js +70 -0
  12. package/dist/StoreManager.js.map +1 -0
  13. package/dist/drivers/MemoryDriver.d.ts +13 -6
  14. package/dist/drivers/MemoryDriver.d.ts.map +1 -1
  15. package/dist/drivers/MemoryDriver.js +81 -56
  16. package/dist/drivers/MemoryDriver.js.map +1 -1
  17. package/dist/drivers/RedisDriver.d.ts +17 -17
  18. package/dist/drivers/RedisDriver.d.ts.map +1 -1
  19. package/dist/drivers/RedisDriver.js +107 -47
  20. package/dist/drivers/RedisDriver.js.map +1 -1
  21. package/dist/drivers/TieredDriver.d.ts +41 -0
  22. package/dist/drivers/TieredDriver.d.ts.map +1 -0
  23. package/dist/drivers/TieredDriver.js +132 -0
  24. package/dist/drivers/TieredDriver.js.map +1 -0
  25. package/dist/duration.d.ts +32 -0
  26. package/dist/duration.d.ts.map +1 -0
  27. package/dist/duration.js +75 -0
  28. package/dist/duration.js.map +1 -0
  29. package/dist/errors.d.ts +21 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +31 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/index.d.ts +21 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +17 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/testing/main.d.ts +41 -0
  38. package/dist/testing/main.d.ts.map +1 -0
  39. package/dist/testing/main.js +41 -0
  40. package/dist/testing/main.js.map +1 -0
  41. package/dist/types.d.ts +146 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +6 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +6 -1
  46. package/src/CacheManager.ts +505 -89
  47. package/src/EchoProvider.ts +55 -12
  48. package/src/StoreManager.ts +104 -0
  49. package/src/drivers/MemoryDriver.ts +109 -60
  50. package/src/drivers/RedisDriver.ts +139 -51
  51. package/src/drivers/TieredDriver.ts +186 -0
  52. package/src/duration.ts +86 -0
  53. package/src/errors.ts +33 -0
  54. package/src/index.ts +53 -1
  55. package/src/testing/main.ts +69 -0
  56. package/src/types.ts +156 -0
@@ -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 default in-memory `CacheManager` so apps
36
- * that don't need Redis can `import cache from '@c9up/echo/services/main'`
37
- * and `await cache.get(...)` straight away.
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
- * // anywhere
46
- * import cache from '@c9up/echo/services/main'
47
- * await cache.set('k', v, 60)
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 config = this.app.config.get<EchoProviderConfig>("cache");
55
- const driver = config?.driver ?? "memory";
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 support.
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 { CacheDriver } from "../CacheManager.js";
7
+ import type { CacheEntry, DriverSetOptions, TaggableDriver } from "../types.js";
7
8
 
8
- interface CacheEntry {
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 CacheDriver {
15
- #store: Map<string, CacheEntry> = new Map();
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.expiresAt > 0 && entry.expiresAt < now) {
24
- this.#store.delete(key);
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
- if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) {
44
- this.#store.delete(key);
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
- return entry.value as T;
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
- async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
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
- ttlSeconds != null && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
58
- this.#store.set(key, { value, expiresAt, tags: [] });
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
- const val = await this.get(key);
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
- // Audit 2026-05-22 F4: align with `set()` — both paths now treat any
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
- /** Flush all entries tagged with any of the given tags. */
119
- async flushTags(tags: string[]): Promise<void> {
120
- // Audit 2026-05-22 F3: when a key is multi-tagged (e.g. `[news, fr]`)
121
- // and we flush by `news`, the old code deleted the key from #store
122
- // and dropped the `news` Set, but `fr`'s Set kept a dangling
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
  }