@c9up/echo 0.1.5 → 0.1.7

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 +63 -28
  2. package/dist/CacheManager.d.ts.map +1 -1
  3. package/dist/CacheManager.js +348 -70
  4. package/dist/CacheManager.js.map +1 -1
  5. package/dist/EchoProvider.d.ts +15 -9
  6. package/dist/EchoProvider.d.ts.map +1 -1
  7. package/dist/EchoProvider.js +47 -13
  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 +67 -61
  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 +103 -55
  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 +15 -3
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -2
  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 +497 -99
  47. package/src/EchoProvider.ts +58 -15
  48. package/src/StoreManager.ts +104 -0
  49. package/src/drivers/MemoryDriver.ts +97 -66
  50. package/src/drivers/RedisDriver.ts +135 -58
  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 +43 -3
  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
@@ -9,7 +11,7 @@ import { setCache } from "./services/main.js";
9
11
  */
10
12
  interface EchoContainer {
11
13
  singleton(token: unknown, factory: () => unknown): void;
12
- resolve<T = unknown>(token: unknown): T;
14
+ resolve<T = unknown>(token: unknown): Promise<T>;
13
15
  }
14
16
  interface EchoConfigStore {
15
17
  get<T = unknown>(key: string): T | undefined;
@@ -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
+ async #resolveEmitter(): Promise<CacheEmitter | undefined> {
77
+ try {
78
+ const candidate = await 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
- this.app.container.singleton(CacheManager, () => {
54
- const config = this.app.config.get<EchoProviderConfig>("cache");
55
- const driver = config?.driver ?? "memory";
87
+ this.app.container.singleton(CacheManager, async () => {
88
+ const emitter = await 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),
@@ -67,7 +110,7 @@ export default class EchoProvider {
67
110
  }
68
111
 
69
112
  async boot(): Promise<void> {
70
- setCache(this.app.container.resolve<CacheManager>(CacheManager));
113
+ setCache(await this.app.container.resolve<CacheManager>(CacheManager));
71
114
  }
72
115
 
73
116
  async shutdown(): Promise<void> {}
@@ -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,7 +24,7 @@ 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) {
27
+ if (entry.staleUntil > 0 && entry.staleUntil < now) {
24
28
  this.#evict(key, entry);
25
29
  }
26
30
  }
@@ -38,42 +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()) {
58
+ const now = Date.now();
59
+ if (entry.staleUntil > 0 && entry.staleUntil < now) {
44
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
67
  /**
51
- * Delete an entry AND scrub its tag-index refs. TTL expiry (sweep + lazy
52
- * get) must go through this — a bare `#store.delete` left dangling refs in
53
- * `#tagIndex`, which a later `set(key, vNew)` reusing the key would turn into
54
- * a wrong-purge under flushTags (audit 2026-06-13).
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).
55
72
  */
56
- #evict(key: string, entry: CacheEntry): void {
73
+ #evict(key: string, entry: StoredEntry): void {
57
74
  for (const tag of entry.tags) this.#tagIndex.get(tag)?.delete(key);
58
75
  this.#store.delete(key);
59
76
  }
60
77
 
61
- async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
78
+ #write(
79
+ key: string,
80
+ value: unknown,
81
+ ttlSeconds: number | undefined,
82
+ graceSeconds: number,
83
+ tags: string[],
84
+ expiresAtOverride?: number,
85
+ ): void {
62
86
  if (value === null || value === undefined) {
63
87
  throw new TypeError(
64
88
  "Echo: caching null/undefined values is not supported",
65
89
  );
66
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.
67
95
  const expiresAt =
68
- ttlSeconds != null && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
69
- // Reconcile the tag index: overwriting a previously-tagged key with an
70
- // untagged value must drop its old tag-index refs, or a later flushTags
71
- // would purge this fresh value (the F3 fix only covered setWithTags).
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).
72
109
  const prev = this.#store.get(key);
73
110
  if (prev !== undefined) {
74
111
  for (const t of prev.tags) this.#tagIndex.get(t)?.delete(key);
75
112
  }
76
- this.#store.set(key, { value, expiresAt, tags: [] });
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
+ );
77
141
  }
78
142
 
79
143
  async delete(key: string): Promise<boolean> {
@@ -92,59 +156,24 @@ export class MemoryDriver implements CacheDriver {
92
156
  }
93
157
 
94
158
  async has(key: string): Promise<boolean> {
95
- const val = await this.get(key);
96
- return val !== null;
159
+ return (await this.get(key)) !== null;
97
160
  }
98
161
 
99
- /** Set with tags for group invalidation. */
162
+ /** Set with tags for group invalidation (bento parity; no grace). */
100
163
  async setWithTags(
101
164
  key: string,
102
165
  value: unknown,
103
166
  tags: string[],
104
167
  ttlSeconds?: number,
105
168
  ): Promise<void> {
106
- // Audit 2026-05-22 F4: align with `set()` — both paths now treat any
107
- // `ttlSeconds <= 0` (and undefined) as "no expiration". Previously
108
- // `setWithTags` used a truthy check (`ttlSeconds ?`), which let a
109
- // negative value through and produced `Date.now() + (-N * 1000)` —
110
- // an already-past timestamp — so the entry was born already-expired.
111
- // `set()` correctly returned the immortal-entry branch on the same
112
- // input. The divergence made cache semantics depend on whether the
113
- // caller used tags or not, which is the worst kind of "very hard to
114
- // diagnose in prod" bug.
115
- const expiresAt =
116
- ttlSeconds != null && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
117
- // Audit 2026-05-22 F3 (overwrite leg): if `key` already exists with
118
- // a different tag set, the old #tagIndex entries become dangling
119
- // refs to the (now overwritten) key. Clean them up before re-tagging
120
- // so subsequent flushTags doesn't iterate stale references.
121
- const prev = this.#store.get(key);
122
- if (prev !== undefined) {
123
- for (const t of prev.tags) this.#tagIndex.get(t)?.delete(key);
124
- }
125
- this.#store.set(key, { value, expiresAt, tags });
126
- for (const tag of tags) {
127
- let set = this.#tagIndex.get(tag);
128
- if (!set) {
129
- set = new Set();
130
- this.#tagIndex.set(tag, set);
131
- }
132
- set.add(key);
133
- }
169
+ this.#write(key, value, ttlSeconds, 0, tags);
134
170
  }
135
171
 
136
- /** Flush all entries tagged with any of the given tags. */
137
- async flushTags(tags: string[]): Promise<void> {
138
- // Audit 2026-05-22 F3: when a key is multi-tagged (e.g. `[news, fr]`)
139
- // and we flush by `news`, the old code deleted the key from #store
140
- // and dropped the `news` Set, but `fr`'s Set kept a dangling
141
- // reference. A later flushTags(`fr`) would iterate over the
142
- // stale entry, attempt `#store.delete(key)` (no-op), and the entry
143
- // would silently linger in #tagIndex forever. Worse — if a new
144
- // entry was later written under the same key with different tags,
145
- // flushTags(`fr`) would WRONGLY purge it because of the residue.
146
- // Collect keys first, then scrub each one from EVERY tag set it
147
- // 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.
148
177
  const toDelete = new Set<string>();
149
178
  for (const tag of tags) {
150
179
  const keys = this.#tagIndex.get(tag);
@@ -161,9 +190,11 @@ export class MemoryDriver implements CacheDriver {
161
190
  }
162
191
  this.#store.delete(key);
163
192
  }
164
- // Now drop the flushed tag sets themselves (any other keys still
165
- // referenced by them have been processed in the toDelete loop and
166
- // already removed via the inner scrub).
167
193
  for (const tag of tags) this.#tagIndex.delete(tag);
168
194
  }
195
+
196
+ /** @deprecated alias of {@link deleteByTag}. */
197
+ async flushTags(tags: string[]): Promise<void> {
198
+ return this.deleteByTag(tags);
199
+ }
169
200
  }