@c9up/echo 0.1.13 → 0.1.15

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 (43) hide show
  1. package/dist/CacheManager.d.ts.map +1 -1
  2. package/dist/CacheManager.js +20 -3
  3. package/dist/CacheManager.js.map +1 -1
  4. package/dist/EchoProvider.d.ts +1 -0
  5. package/dist/EchoProvider.d.ts.map +1 -1
  6. package/dist/EchoProvider.js +9 -1
  7. package/dist/EchoProvider.js.map +1 -1
  8. package/dist/StoreManager.d.ts +18 -0
  9. package/dist/StoreManager.d.ts.map +1 -1
  10. package/dist/StoreManager.js +18 -2
  11. package/dist/StoreManager.js.map +1 -1
  12. package/dist/augmentations.d.ts +29 -0
  13. package/dist/augmentations.d.ts.map +1 -0
  14. package/dist/augmentations.js +18 -0
  15. package/dist/augmentations.js.map +1 -0
  16. package/dist/drivers/MemoryDriver.d.ts +1 -1
  17. package/dist/drivers/MemoryDriver.d.ts.map +1 -1
  18. package/dist/drivers/MemoryDriver.js +41 -1
  19. package/dist/drivers/MemoryDriver.js.map +1 -1
  20. package/dist/drivers/TieredDriver.d.ts +17 -0
  21. package/dist/drivers/TieredDriver.d.ts.map +1 -1
  22. package/dist/drivers/TieredDriver.js +88 -17
  23. package/dist/drivers/TieredDriver.js.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/quasar.d.ts +17 -0
  29. package/dist/quasar.d.ts.map +1 -1
  30. package/dist/quasar.js +77 -0
  31. package/dist/quasar.js.map +1 -1
  32. package/dist/types.d.ts +12 -1
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +5 -3
  35. package/src/CacheManager.ts +29 -4
  36. package/src/EchoProvider.ts +10 -3
  37. package/src/StoreManager.ts +22 -3
  38. package/src/augmentations.ts +33 -0
  39. package/src/drivers/MemoryDriver.ts +39 -1
  40. package/src/drivers/TieredDriver.ts +94 -17
  41. package/src/index.ts +2 -0
  42. package/src/quasar.ts +101 -0
  43. package/src/types.ts +12 -1
@@ -19,6 +19,23 @@ import type {
19
19
  export interface BusMessage {
20
20
  type: "delete" | "clear";
21
21
  keys: string[];
22
+ /**
23
+ * The tier that published it.
24
+ *
25
+ * A pub/sub bus delivers to every subscriber INCLUDING the publisher —
26
+ * Redis does, and Redis pub/sub is what this is for. Without a sender to
27
+ * recognise, every `set` published a delete, received it back, and dropped
28
+ * the L1 copy it had just written: L1 held nothing after any write, and
29
+ * every read went to L2. Upstream draws the same line one layer down, where
30
+ * each bus transport stamps its own id and skips what it sent
31
+ * (`@boringnode/bus`, `transports/memory.js`: `if (busId === this.#id)
32
+ * continue`).
33
+ *
34
+ * Optional, and a message without one is acted on: a bus that predates this
35
+ * field keeps working, at worst doing the redundant local invalidation it
36
+ * already did.
37
+ */
38
+ senderId?: string;
22
39
  }
23
40
 
24
41
  /** Duck-typed pub/sub bus for cross-instance L1 invalidation (e.g. Redis pub/sub). */
@@ -68,16 +85,42 @@ async function writeEntry(
68
85
  await driver.set(key, value, options.ttlSeconds);
69
86
  }
70
87
 
88
+ /**
89
+ * Run one tier's operation, capturing a rejection instead of letting it escape.
90
+ *
91
+ * The two tiers are attempted independently on purpose: awaiting L1 first meant
92
+ * a local driver that threw — a Redis L1 whose socket just dropped, one
93
+ * mid-shutdown — aborted the call before the SHARED tier was touched, so a
94
+ * `delete` left the copy every other instance reads. Upstream reaches the same
95
+ * end by never awaiting L1 at all (`this.l1?.set(...)` with no await, then
96
+ * `await this.l2?.set(...)`). The failure is still reported, after both tiers
97
+ * have had their turn.
98
+ */
99
+ async function settle<T>(
100
+ run: () => Promise<T>,
101
+ ): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
102
+ try {
103
+ return { ok: true, value: await run() };
104
+ } catch (error) {
105
+ return { ok: false, error };
106
+ }
107
+ }
108
+
71
109
  export class TieredDriver implements TaggableDriver {
72
110
  #l1: CacheDriver;
73
111
  #l2: CacheDriver;
74
112
  #bus: CacheBus | undefined;
113
+ /** This tier, as a bus sender. */
114
+ readonly #id = crypto.randomUUID();
75
115
 
76
116
  constructor(options: TieredDriverOptions) {
77
117
  this.#l1 = options.l1;
78
118
  this.#l2 = options.l2;
79
119
  this.#bus = options.bus;
80
120
  this.#bus?.subscribe((message) => {
121
+ // Our own invalidation, come back round the bus. Acting on it would
122
+ // undo the write that sent it.
123
+ if (message.senderId === this.#id) return;
81
124
  // Peer invalidation: only the local L1 needs clearing (L2 is shared).
82
125
  if (message.type === "clear") {
83
126
  this.#invalidate("flush", () => this.#l1.flush());
@@ -157,22 +200,46 @@ export class TieredDriver implements TaggableDriver {
157
200
  value: unknown,
158
201
  options: DriverSetOptions,
159
202
  ): Promise<void> {
160
- await writeEntry(this.#l1, key, value, options);
161
- await writeEntry(this.#l2, key, value, options);
162
- await this.#bus?.publish({ type: "delete", keys: [key] });
203
+ const l1 = await settle(() => writeEntry(this.#l1, key, value, options));
204
+ const l2 = await settle(() => writeEntry(this.#l2, key, value, options));
205
+ // Peers are told only when the shared write landed. Telling them to drop
206
+ // their L1 for a value that never reached L2 sends every one of them to a
207
+ // tier that does not have it. Upstream gates the same publish on the same
208
+ // thing (`if (this.l2 && l2Success || !this.l2)`).
209
+ if (l2.ok) {
210
+ await this.#bus?.publish({
211
+ type: "delete",
212
+ keys: [key],
213
+ senderId: this.#id,
214
+ });
215
+ }
216
+ if (!l1.ok) throw l1.error;
217
+ if (!l2.ok) throw l2.error;
163
218
  }
164
219
 
165
220
  async delete(key: string): Promise<boolean> {
166
- const l1 = await this.#l1.delete(key);
167
- const l2 = await this.#l2.delete(key);
168
- await this.#bus?.publish({ type: "delete", keys: [key] });
169
- return l1 || l2;
221
+ const l1 = await settle(() => this.#l1.delete(key));
222
+ const l2 = await settle(() => this.#l2.delete(key));
223
+ if (l2.ok) {
224
+ await this.#bus?.publish({
225
+ type: "delete",
226
+ keys: [key],
227
+ senderId: this.#id,
228
+ });
229
+ }
230
+ if (!l1.ok) throw l1.error;
231
+ if (!l2.ok) throw l2.error;
232
+ return l1.value || l2.value;
170
233
  }
171
234
 
172
235
  async flush(): Promise<void> {
173
- await this.#l1.flush();
174
- await this.#l2.flush();
175
- await this.#bus?.publish({ type: "clear", keys: [] });
236
+ const l1 = await settle(() => this.#l1.flush());
237
+ const l2 = await settle(() => this.#l2.flush());
238
+ if (l2.ok) {
239
+ await this.#bus?.publish({ type: "clear", keys: [], senderId: this.#id });
240
+ }
241
+ if (!l1.ok) throw l1.error;
242
+ if (!l2.ok) throw l2.error;
176
243
  }
177
244
 
178
245
  async has(key: string): Promise<boolean> {
@@ -196,11 +263,15 @@ export class TieredDriver implements TaggableDriver {
196
263
  "Echo: TieredDriver.deleteByTag requires both tiers to be taggable",
197
264
  );
198
265
  }
199
- await l1.deleteByTag(tags);
200
- await l2.deleteByTag(tags);
266
+ const local = await settle(() => l1.deleteByTag(tags));
267
+ const shared = await settle(() => l2.deleteByTag(tags));
201
268
  // Peers can't map tags → keys locally; broadcast a clear so their L1 drops
202
269
  // any tagged copies (conservative but correct).
203
- await this.#bus?.publish({ type: "clear", keys: [] });
270
+ if (shared.ok) {
271
+ await this.#bus?.publish({ type: "clear", keys: [], senderId: this.#id });
272
+ }
273
+ if (!local.ok) throw local.error;
274
+ if (!shared.ok) throw shared.error;
204
275
  }
205
276
 
206
277
  /** @deprecated alias of {@link deleteByTag}. */
@@ -209,13 +280,19 @@ export class TieredDriver implements TaggableDriver {
209
280
  }
210
281
  /** Prune both layers (bentocache `prune`). */
211
282
  async prune(): Promise<void> {
212
- await this.#l1.prune?.();
213
- await this.#l2.prune?.();
283
+ const l1 = await settle(async () => this.#l1.prune?.());
284
+ const l2 = await settle(async () => this.#l2.prune?.());
285
+ if (!l1.ok) throw l1.error;
286
+ if (!l2.ok) throw l2.error;
214
287
  }
215
288
 
216
289
  /** Release both layers (bentocache `disconnect`). */
217
290
  async disconnect(): Promise<void> {
218
- await this.#l1.disconnect?.();
219
- await this.#l2.disconnect?.();
291
+ // Both are released even when the first refuses: a tier left connected
292
+ // because its neighbour threw is a socket nobody closes.
293
+ const l1 = await settle(async () => this.#l1.disconnect?.());
294
+ const l2 = await settle(async () => this.#l2.disconnect?.());
295
+ if (!l1.ok) throw l1.error;
296
+ if (!l2.ok) throw l2.error;
220
297
  }
221
298
  }
package/src/index.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * @implements MISS-10
10
10
  */
11
11
 
12
+ import "./augmentations.js";
13
+
12
14
  export type { CacheConfig, CacheDriver } from "./CacheManager.js";
13
15
  export { CacheManager } from "./CacheManager.js";
14
16
  export { MemoryDriver } from "./drivers/MemoryDriver.js";
package/src/quasar.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { RedisClient } from "./drivers/RedisDriver.js";
14
+ import type { BusMessage, CacheBus } from "./drivers/TieredDriver.js";
14
15
 
15
16
  /** The slice of quasar's manager this needs: a connection, by name. */
16
17
  interface ConnectionSource {
@@ -87,3 +88,103 @@ export function quasarConnection(name?: string): () => Promise<RedisClient> {
87
88
  return connection;
88
89
  };
89
90
  }
91
+
92
+ /** The slice of quasar's manager a pub/sub bus needs. */
93
+ interface PubSubSource {
94
+ publish(channel: string, message: string): unknown;
95
+ subscribe(channel: string, handler: (message: string) => void): unknown;
96
+ }
97
+
98
+ function isPubSubSource(value: unknown): value is PubSubSource {
99
+ return (
100
+ typeof value === "object" &&
101
+ value !== null &&
102
+ typeof Reflect.get(value, "publish") === "function" &&
103
+ typeof Reflect.get(value, "subscribe") === "function"
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Resolve quasar's manager, loading it on first use.
109
+ *
110
+ * Shared by the connection resolver and the bus: echo declares quasar an
111
+ * optional peer and never imports it statically, so an application that caches
112
+ * in memory neither installs it nor pays for it.
113
+ */
114
+ async function loadQuasar(context: string): Promise<unknown> {
115
+ const specifier = "@c9up/quasar/services/main";
116
+ try {
117
+ const loaded = await import(/* @vite-ignore */ specifier);
118
+ return isConnectionSource(loaded) || isPubSubSource(loaded)
119
+ ? loaded
120
+ : Reflect.get(Object(loaded), "default");
121
+ } catch (cause) {
122
+ throw new Error(
123
+ `Echo: ${context}, but @c9up/quasar is not installed.\n pnpm add @c9up/quasar`,
124
+ { cause },
125
+ );
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Redis pub/sub as a cache bus, for keeping each instance's L1 in step.
131
+ *
132
+ * A two-layer store without one is wrong the moment a second instance exists:
133
+ * each process keeps serving its own L1 copy of a key another process has
134
+ * already deleted, and nothing ever tells it. That is what the bus is for, and
135
+ * it is why `useBus` sits next to `useL2Layer` in the generated config.
136
+ *
137
+ * Quasar opens the subscriber socket lazily and on its own connection — Redis
138
+ * puts a subscribed client into a mode where it accepts nothing else, so a
139
+ * connection that both publishes and listens needs two.
140
+ */
141
+ export function quasarBus(options?: {
142
+ connection?: string;
143
+ channel?: string;
144
+ }): CacheBus {
145
+ const channel = options?.channel ?? "echo::invalidate";
146
+ let ready: Promise<PubSubSource> | undefined;
147
+
148
+ const manager = (): Promise<PubSubSource> => {
149
+ ready ??= loadQuasar(
150
+ `the cache bus asks for the "${options?.connection ?? "default"}" quasar connection`,
151
+ ).then((loaded) => {
152
+ const source = isConnectionSource(loaded)
153
+ ? loaded.connection(options?.connection)
154
+ : loaded;
155
+ if (!isPubSubSource(source)) {
156
+ throw new Error(
157
+ "Echo: the quasar connection does not expose publish()/subscribe()",
158
+ );
159
+ }
160
+ return source;
161
+ });
162
+ return ready;
163
+ };
164
+
165
+ return {
166
+ async publish(message: BusMessage): Promise<void> {
167
+ const source = await manager();
168
+ await source.publish(channel, JSON.stringify(message));
169
+ },
170
+ subscribe(handler: (message: BusMessage) => void): void {
171
+ // Not awaited: `subscribe` is synchronous in the bus contract, and the
172
+ // socket opens on quasar's own schedule. A failure to reach Redis must
173
+ // not take down the store — a bus that is down costs staleness, and
174
+ // throwing here would cost every cache read.
175
+ void manager()
176
+ .then((source) =>
177
+ source.subscribe(channel, (raw: string) => {
178
+ try {
179
+ handler(JSON.parse(raw) as BusMessage);
180
+ } catch {
181
+ /* a malformed frame is not a reason to stop listening */
182
+ }
183
+ }),
184
+ )
185
+ .catch(() => {
186
+ /* reported by the first publish, which does surface its error */
187
+ });
188
+ },
189
+ };
190
+ }
package/src/types.ts CHANGED
@@ -163,5 +163,16 @@ export interface CacheEventMap {
163
163
  * (ream's emitter, Node's EventEmitter, mitt, …). echo never imports one.
164
164
  */
165
165
  export interface CacheEmitter {
166
- emit(event: string, payload: unknown): void;
166
+ /**
167
+ * NAMED DEVIATION from `@adonisjs/events`, which declares
168
+ * `emit(): Promise<void>` on its own class. This is a DUCK-TYPE of an
169
+ * emitter echo does not own, so it must also accept a synchronous one — a
170
+ * Node `EventEmitter` returns `boolean`, for instance.
171
+ *
172
+ * `unknown` rather than `void`, because `void` ACCEPTS a
173
+ * promise-returning function and then reads as if there were nothing to
174
+ * handle: that is what hid an Adonis emitter's rejection here. Same choice
175
+ * warden's `AuthManager` already made.
176
+ */
177
+ emit(event: string, payload: unknown): unknown;
167
178
  }