@voltro/plugin-broadcast 0.55.0 → 0.57.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/dist/index.d.ts CHANGED
@@ -24,20 +24,22 @@ export declare interface AttachBroadcastBusOptions {
24
24
  /** Channel override (tests). Default `voltro:changes`. */
25
25
  readonly channel?: string;
26
26
  /**
27
- * Called when this replica proves it missed changes from a peer.
27
+ * Called when this replica has a hole in what it received.
28
28
  *
29
- * The count is EXACT the difference between the serial in hand and the last
30
- * one seen from that origin not an estimate. There is nothing to replay
31
- * (pub/sub keeps no log), so the caller's job is to make the loss irrelevant:
32
- * a live query is idempotent, so re-running every one of them is always safe
33
- * and always correct. `voltro dev` and `voltro serve` wire this to the
34
- * dispatcher's refresh.
29
+ * Two causes, one shape (see `BroadcastGap`), because there is one recovery:
30
+ * re-run every live query. A live query is idempotent, so re-running all of
31
+ * them is always safe and always complete which is the only recovery
32
+ * available, since pub/sub keeps no log and there is nothing to replay.
35
33
  *
36
- * Absent the gap is still DETECTED and logged; only the recovery is
34
+ * - a peer's serial jumped: the count is EXACT, not an estimate.
35
+ * - this replica was not subscribed for a while: no origin, no count, and
36
+ * the same instruction.
37
+ *
38
+ * Absent ⇒ the hole is still DETECTED and logged; only the recovery is
37
39
  * missing. That is deliberate: a bus used without a dispatcher (a test, an
38
40
  * embedder) should still say what it lost.
39
41
  */
40
- readonly onGap?: (origin: string, missed: number) => void;
42
+ readonly onGap?: (gap: BroadcastGap) => void;
41
43
  }
42
44
 
43
45
  /**
@@ -89,6 +91,28 @@ export declare interface BroadcastEnvelope {
89
91
  * than as a gap.
90
92
  */
91
93
  readonly n?: number;
94
+ /**
95
+ * Which PROCESS of that origin published this — a nonce minted once per bus
96
+ * attach, meaningless except for being different after a restart.
97
+ *
98
+ * `origin` is a replica's NAME, and a name can outlive the process wearing
99
+ * it: a StatefulSet pod keeps `POD_NAME` across a restart, and
100
+ * `VOLTRO_REPLICA_ID` is stable by definition. The serial, however, restarts
101
+ * at 1 — so a receiver holding a watermark of 500 sees the new process's
102
+ * 1, 2, 3… as "not newer than what I have", never advances, and reports no
103
+ * gap for the next 500 changes. Gap detection for that peer is simply off,
104
+ * silently, and precisely after the event that most deserves a refresh.
105
+ *
106
+ * The epoch turns that into a fact the receiver can read: a different epoch
107
+ * under a known origin means a NEW process, so reset the watermark. It is
108
+ * NOT reported as a gap — a restart is not evidence that this replica missed
109
+ * anything, and a gap is a claim about loss.
110
+ *
111
+ * Optional for the same reason `n` is: a message from an older replica
112
+ * mid-rolling-deploy carries none, and "cannot tell" must not read as
113
+ * "restarted".
114
+ */
115
+ readonly epoch?: string;
92
116
  }
93
117
 
94
118
  /**
@@ -115,6 +139,24 @@ declare const BroadcastError_base: Schema.TaggedErrorClass<BroadcastError, "Broa
115
139
  cause: Schema.optional<typeof Schema.Unknown>;
116
140
  }>;
117
141
 
142
+ /**
143
+ * A proven — or presumed — hole in what this replica received.
144
+ *
145
+ * ONE shape for both causes, because the recovery is one thing: re-run every
146
+ * live query. Two callbacks would have been two things for `voltro dev` and
147
+ * `voltro serve` to each wire, and this repo has the history to say how that
148
+ * ends.
149
+ */
150
+ export declare interface BroadcastGap {
151
+ /** What happened, for the refresh's log line. */
152
+ readonly reason: string;
153
+ /** The peer whose serial jumped — absent when the hole is this replica's own
154
+ * (it was not subscribed). */
155
+ readonly origin?: string;
156
+ /** EXACT count of missed messages, when it is known. Never an estimate. */
157
+ readonly missed?: number;
158
+ }
159
+
118
160
  /**
119
161
  * A broadcast plugin instance also exposes the resolved provider so the
120
162
  * CLI's serve pipeline can attach the bus to the live DataStore. The
@@ -189,6 +231,22 @@ export declare interface BroadcastProvider {
189
231
  readonly publish: (channel: string, payload: string) => Effect.Effect<void, BroadcastError>;
190
232
  readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, BroadcastError>;
191
233
  readonly close: () => Effect.Effect<void>;
234
+ /**
235
+ * Subscribe to the TRANSPORT's own connection lifecycle, when the backend
236
+ * has one to report.
237
+ *
238
+ * A broker outage is not visible in `publish`/`subscribe`: a driver that
239
+ * reconnects on its own hands back a working transport and says nothing, and
240
+ * every message published while it was down is gone. The bus's serial
241
+ * accounting catches that on the next message from a peer — but only if a
242
+ * peer publishes again. On a quiet table, "no peer published again" and "we
243
+ * are up to date" look identical, and one of them is stale forever.
244
+ *
245
+ * Optional: the memory provider has no connection to lose, and a provider a
246
+ * user brings themselves need not implement it. Absent means the bus falls
247
+ * back to serial-only detection, which is where it was before this existed.
248
+ */
249
+ readonly onTransportEvent?: (listener: (event: BroadcastTransportEvent) => void) => () => void;
192
250
  }
193
251
 
194
252
  /** Provider id understood by `resolveBroadcastProvider`. */
@@ -201,6 +259,12 @@ export declare interface BroadcastStore {
201
259
  injectExternalChange: (event: ChangeEvent) => void;
202
260
  }
203
261
 
262
+ export declare interface BroadcastTransportEvent {
263
+ readonly kind: 'disconnected' | 'reconnected';
264
+ /** Driver detail for the log line — never parsed. */
265
+ readonly detail?: string;
266
+ }
267
+
204
268
  /**
205
269
  * One framework channel inside a namespace.
206
270
  *
@@ -268,6 +332,12 @@ declare interface NatsLike {
268
332
  publish: (subject: string, data: Uint8Array) => void;
269
333
  subscribe: (subject: string) => NatsSubscription;
270
334
  drain: () => Promise<void>;
335
+ /** nats.js' connection events. Read for two reasons: to report a reconnect as
336
+ * the hole it is, and to notice a connection that gave up. */
337
+ status: () => AsyncIterable<{
338
+ type: string;
339
+ data?: unknown;
340
+ }>;
271
341
  }
272
342
 
273
343
  export declare const natsProvider: (options: NatsProviderOptions) => BroadcastProvider;
@@ -291,8 +361,17 @@ declare interface RedisLike {
291
361
  publish: (channel: string, message: string) => Promise<number>;
292
362
  subscribe: (channel: string) => Promise<unknown>;
293
363
  unsubscribe: (channel: string) => Promise<unknown>;
294
- on: (event: 'message', listener: (channel: string, message: string) => void) => void;
295
- off: (event: 'message', listener: (channel: string, message: string) => void) => void;
364
+ on(event: 'message', listener: (channel: string, message: string) => void): void;
365
+ /** ioredis' connection lifecycle. The subscriber re-subscribes its channels
366
+ * itself on `ready`; what it does NOT do is tell anyone that the messages
367
+ * sent while it was away are gone. That is what these are read for. */
368
+ on(event: 'ready' | 'reconnecting' | 'end' | 'close', listener: () => void): void;
369
+ /** `error` is REQUIRED, not optional decoration: an `EventEmitter` with no
370
+ * `error` listener turns the event into an uncaught exception. See the
371
+ * registration below. */
372
+ on(event: 'error', listener: (err: unknown) => void): void;
373
+ off(event: 'message', listener: (channel: string, message: string) => void): void;
374
+ off(event: 'ready' | 'reconnecting' | 'end' | 'close', listener: () => void): void;
296
375
  duplicate: () => RedisLike;
297
376
  quit: () => Promise<unknown>;
298
377
  }
@@ -349,4 +428,10 @@ export declare const resolveBroadcastProvider: (options: ResolveBroadcastOptions
349
428
  crossReplica: boolean;
350
429
  };
351
430
 
431
+ /** Retry cadence for a subscribe that could not land: 500ms doubling to a 10s
432
+ * ceiling, forever. A bus that gives up subscribing is a replica that is
433
+ * permanently deaf and says so once, which is the state this retry exists to
434
+ * make impossible. */
435
+ export declare const subscribeRetryDelayMs: (attempt: number) => number;
436
+
352
437
  export { }
package/dist/index.js CHANGED
@@ -34,13 +34,25 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
34
34
  message: t,
35
35
  transient: !1,
36
36
  ...n === void 0 ? {} : { cause: n }
37
- }), p = /* @__PURE__ */ new Map(), m = (e) => {
38
- let t = p.get(e);
39
- return t || (t = { handlers: /* @__PURE__ */ new Map() }, p.set(e, t)), t;
40
- }, h = (e) => {
41
- p.get(e)?.handlers.clear();
42
- }, g = (t = `default-${Math.random().toString(36).slice(2)}`) => {
43
- let n = m(t);
37
+ }), p = () => {
38
+ let e = /* @__PURE__ */ new Set();
39
+ return {
40
+ subscribe: (t) => (e.add(t), () => {
41
+ e.delete(t);
42
+ }),
43
+ emit: (t) => {
44
+ for (let n of e) try {
45
+ n(t);
46
+ } catch {}
47
+ }
48
+ };
49
+ }, m = /* @__PURE__ */ new Map(), h = (e) => {
50
+ let t = m.get(e);
51
+ return t || (t = { handlers: /* @__PURE__ */ new Map() }, m.set(e, t)), t;
52
+ }, g = (e) => {
53
+ m.get(e)?.handlers.clear();
54
+ }, _ = (t = `default-${Math.random().toString(36).slice(2)}`) => {
55
+ let n = h(t);
44
56
  return {
45
57
  name: "memory",
46
58
  publish: (t, r) => e.sync(() => {
@@ -57,8 +69,8 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
57
69
  }),
58
70
  close: () => e.void
59
71
  };
60
- }, _ = (t) => {
61
- let n = null, r = () => n || (n = (async () => {
72
+ }, v = (t) => {
73
+ let n = null, r = p(), i = !0, a = () => n || (n = (async () => {
62
74
  let e;
63
75
  if (t.client) e = t.client;
64
76
  else {
@@ -71,27 +83,37 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
71
83
  e = new n(t.url);
72
84
  }
73
85
  let n = e.duplicate();
74
- return {
86
+ return e.on("error", () => {}), n.on("error", () => {}), n.on("reconnecting", () => {
87
+ i && (i = !1, r.emit({
88
+ kind: "disconnected",
89
+ detail: "ioredis is re-dialling"
90
+ }));
91
+ }), n.on("ready", () => {
92
+ i || (i = !0, r.emit({ kind: "reconnected" }));
93
+ }), {
75
94
  pub: e,
76
95
  sub: n
77
96
  };
78
- })(), n);
97
+ })().catch((e) => {
98
+ throw n = null, e;
99
+ }), n);
79
100
  return {
80
101
  name: "redis",
102
+ onTransportEvent: r.subscribe,
81
103
  publish: (t, n) => e.tryPromise({
82
104
  try: async () => {
83
- let { pub: e } = await r();
105
+ let { pub: e } = await a();
84
106
  await e.publish(t, n);
85
107
  },
86
108
  catch: (e) => d("redis", `publish failed: ${String(e?.message ?? e)}`, e)
87
109
  }),
88
110
  subscribe: (t, n) => e.tryPromise({
89
111
  try: async () => {
90
- let { sub: e } = await r(), i = (e, r) => {
112
+ let { sub: e } = await a(), r = (e, r) => {
91
113
  e === t && n(r);
92
114
  };
93
- return e.on("message", i), await e.subscribe(t), () => {
94
- e.off("message", i), e.unsubscribe(t);
115
+ return e.on("message", r), await e.subscribe(t), () => {
116
+ e.off("message", r), e.unsubscribe(t);
95
117
  };
96
118
  },
97
119
  catch: (e) => d("redis", `subscribe failed: ${String(e?.message ?? e)}`, e)
@@ -103,25 +125,50 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
103
125
  } catch {}
104
126
  })
105
127
  };
106
- }, v = new TextEncoder(), y = new TextDecoder(), b = (t) => {
107
- let n = null, r = () => import("@nats-io/transport-node").catch((e) => {
128
+ }, y = new TextEncoder(), b = new TextDecoder(), x = (t) => {
129
+ let n = null, r = p(), i = !0, a = (e) => {
130
+ (async () => {
131
+ try {
132
+ for await (let t of e.status()) t.type === "disconnect" && i ? (i = !1, r.emit({
133
+ kind: "disconnected",
134
+ detail: "nats disconnected"
135
+ })) : t.type === "reconnect" && !i && (i = !0, r.emit({ kind: "reconnected" }));
136
+ } catch {}
137
+ })();
138
+ }, o = () => import("@nats-io/transport-node").catch((e) => {
108
139
  throw f("nats", "the 'nats' broadcast provider requires the '@nats-io/transport-node' optional dependency. Install it to enable it.", e);
109
- }), i = () => n || (n = (async () => t.connection ? t.connection : (await r()).connect({ servers: t.url }))(), n);
140
+ }), s = () => n || (n = (async () => {
141
+ if (t.connection) return t.connection;
142
+ let e = await (await o()).connect({
143
+ servers: t.url,
144
+ maxReconnectAttempts: -1,
145
+ waitOnFirstConnect: !0,
146
+ reconnectTimeWait: 2e3
147
+ });
148
+ return a(e), e;
149
+ })().catch((e) => {
150
+ throw n = null, e;
151
+ }), n);
110
152
  return {
111
153
  name: "nats",
154
+ onTransportEvent: r.subscribe,
112
155
  publish: (t, n) => e.tryPromise({
113
156
  try: async () => {
114
- (await i()).publish(t, v.encode(n));
157
+ (await s()).publish(t, y.encode(n));
115
158
  },
116
159
  catch: (e) => d("nats", `publish failed: ${String(e?.message ?? e)}`, e)
117
160
  }),
118
161
  subscribe: (t, n) => e.tryPromise({
119
162
  try: async () => {
120
- let e = (await i()).subscribe(t);
163
+ let e = (await s()).subscribe(t), a = !1;
121
164
  return (async () => {
122
- for await (let t of e) n(y.decode(t.data));
165
+ for await (let t of e) n(b.decode(t.data));
166
+ !a && i && (i = !1, r.emit({
167
+ kind: "disconnected",
168
+ detail: `the nats subscription to '${t}' ended while the connection was up`
169
+ }));
123
170
  })(), () => {
124
- e.unsubscribe();
171
+ a = !0, e.unsubscribe();
125
172
  };
126
173
  },
127
174
  catch: (e) => d("nats", `subscribe failed: ${String(e?.message ?? e)}`, e)
@@ -133,7 +180,7 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
133
180
  } catch {}
134
181
  })
135
182
  };
136
- }, x = (e, t) => t[`${e.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_REDIS_URL`], S = (e, t) => {
183
+ }, S = (e, t) => t[`${e.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_REDIS_URL`], C = (e, t) => {
137
184
  if (e.provider && typeof e.provider == "object") {
138
185
  let t = e.provider;
139
186
  return {
@@ -142,7 +189,7 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
142
189
  crossReplica: t.name !== "memory"
143
190
  };
144
191
  }
145
- let n = e.url ?? t.BROADCAST_URL ?? null, r = x(e.connection ?? "broadcast", t), i = (e.provider ?? t.BROADCAST_PROVIDER)?.toLowerCase() ?? (n?.startsWith("nats://") ? "nats" : n?.startsWith("redis://") || n?.startsWith("rediss://") || r || t.REDIS_URL ? "redis" : void 0), a = (e.provider ?? t.BROADCAST_PROVIDER)?.toLowerCase();
192
+ let n = e.url ?? t.BROADCAST_URL ?? null, r = S(e.connection ?? "broadcast", t), i = (e.provider ?? t.BROADCAST_PROVIDER)?.toLowerCase() ?? (n?.startsWith("nats://") ? "nats" : n?.startsWith("redis://") || n?.startsWith("rediss://") || r || t.REDIS_URL ? "redis" : void 0), a = (e.provider ?? t.BROADCAST_PROVIDER)?.toLowerCase();
146
193
  if (a !== void 0 && a !== "redis" && a !== "nats" && a !== "memory") throw Error(`broadcastPlugin: unknown provider "${a}".\n Valid: 'redis', 'nats', 'memory'.
147
194
  A name we do not recognise used to fall through to the in-process bus, which boots fine on one replica and drops cross-replica traffic on the second.`);
148
195
  if (n !== null && n !== "" && !/^(redis|rediss|nats):\/\//.test(n)) throw Error(`broadcastPlugin: url "${n}" names no provider we support.\n Expected a redis://, rediss:// or nats:// url.`);
@@ -152,13 +199,13 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
152
199
  if (!e) {
153
200
  if (a === "redis") throw Error("broadcastPlugin: provider \"redis\" was requested but no url resolves.\n Set BROADCAST_URL, BROADCAST_REDIS_URL or REDIS_URL, or pass `url`.\n Falling back to the in-process bus here would answer a stated requirement with a silent downgrade.");
154
201
  return {
155
- provider: g(),
202
+ provider: _(),
156
203
  url: null,
157
204
  crossReplica: !1
158
205
  };
159
206
  }
160
207
  return {
161
- provider: _({ url: e }),
208
+ provider: v({ url: e }),
162
209
  url: e,
163
210
  crossReplica: !0
164
211
  };
@@ -166,22 +213,22 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
166
213
  case "nats": {
167
214
  let e = n ?? t.NATS_URL;
168
215
  return e ? {
169
- provider: b({ url: e }),
216
+ provider: x({ url: e }),
170
217
  url: e,
171
218
  crossReplica: !0
172
219
  } : {
173
- provider: g(),
220
+ provider: _(),
174
221
  url: null,
175
222
  crossReplica: !1
176
223
  };
177
224
  }
178
225
  default: return {
179
- provider: g(),
226
+ provider: _(),
180
227
  url: null,
181
228
  crossReplica: !1
182
229
  };
183
230
  }
184
- }, C = ["network:outbound:*"], w = [
231
+ }, w = ["network:outbound:*"], T = [
185
232
  {
186
233
  name: "BROADCAST_URL",
187
234
  required: !1,
@@ -217,8 +264,8 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
217
264
  description: "Fallback broker URL used when the resolved provider is nats and no BROADCAST_URL is set (comma-separated for a cluster).",
218
265
  example: "nats://localhost:4222"
219
266
  }
220
- ], T = (t = {}) => {
221
- let r = S({
267
+ ], E = (t = {}) => {
268
+ let r = C({
222
269
  ...t.provider === void 0 ? {} : { provider: t.provider },
223
270
  ...t.url === void 0 ? {} : { url: t.url },
224
271
  ...t.connection === void 0 ? {} : { connection: t.connection }
@@ -227,8 +274,8 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
227
274
  ...n({
228
275
  name: t.name ? `@voltro/plugin-broadcast#${t.name}` : "@voltro/plugin-broadcast",
229
276
  description: "Cross-replica reactivity — fans out app-mutation ChangeEvents to every replica over a pub/sub bus (Redis / NATS). ADDITIVE to inline emit: local reactivity survives a broker outage. Closes the single-instance gap for every non-postgres dialect.",
230
- permissions: C,
231
- declaredEnv: w,
277
+ permissions: w,
278
+ declaredEnv: T,
232
279
  onActivate: (t) => e.sync(() => {
233
280
  r.crossReplica ? t.logger.info("broadcast active", {
234
281
  provider: r.provider.name,
@@ -244,57 +291,101 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
244
291
  namespace: t.namespace
245
292
  }
246
293
  };
247
- }, E = (e) => {
248
- for (let t of e) if (D(t)) return t;
294
+ }, D = (e) => {
295
+ for (let t of e) if (O(t)) return t;
249
296
  return null;
250
- }, D = (e) => typeof e.broadcast == "object" && e.broadcast !== null && typeof e.broadcast.provider == "object", O = ({ origin: e, ...t }) => t, k = {
297
+ }, O = (e) => typeof e.broadcast == "object" && e.broadcast !== null && typeof e.broadcast.provider == "object", k = ({ origin: e, ...t }) => t, A = (e) => Math.min(500 * 2 ** Math.min(Math.max(e, 1) - 1, 5), 1e4), j = {
251
298
  info: () => {},
252
299
  warn: () => {},
253
300
  debug: () => {}
254
- }, A = async (t) => {
255
- let { store: n, provider: r, replicaId: i } = t, a = t.logger ?? k, o = t.channel ?? u, s = !1, c, l = !1, d = 0, f = /* @__PURE__ */ new Map(), p = await e.runPromise(r.subscribe(o, (e) => {
256
- let r;
301
+ }, M = async (t) => {
302
+ let { store: n, provider: r, replicaId: i } = t, a = t.logger ?? j, o = t.channel ?? u, s = !1, c, l = !1, d = 0, f = /* @__PURE__ */ new Map(), p = `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}`, m = (e) => {
303
+ a.warn(`broadcast: ${e.reason} — refreshing live queries, which is safe because a query is idempotent`, {
304
+ ...e.origin === void 0 ? {} : { origin: e.origin },
305
+ ...e.missed === void 0 ? {} : { missed: e.missed }
306
+ });
307
+ try {
308
+ t.onGap?.(e);
309
+ } catch (e) {
310
+ a.warn("broadcast: gap recovery threw", { err: e });
311
+ }
312
+ }, h = (e) => {
313
+ let t;
257
314
  try {
258
- r = JSON.parse(e);
315
+ t = JSON.parse(e);
259
316
  } catch {
260
317
  a.warn("broadcast: bad payload (not JSON)", { channel: o });
261
318
  return;
262
319
  }
263
- if (r.origin === i || !r.event || typeof r.event != "object") return;
264
- if (typeof r.n == "number" && Number.isFinite(r.n)) {
265
- let e = f.get(r.origin);
266
- if (e !== void 0 && r.n > e + 1) {
267
- let n = r.n - e - 1;
268
- a.warn("broadcast: missed changes from a peer — refreshing live queries, which is safe because a query is idempotent", {
269
- origin: r.origin,
270
- missed: n,
271
- from: e,
272
- to: r.n
273
- });
274
- try {
275
- t.onGap?.(r.origin, n);
276
- } catch (e) {
277
- a.warn("broadcast: gap recovery threw", { err: e });
278
- }
279
- }
280
- (e === void 0 || r.n > e) && f.set(r.origin, r.n);
320
+ if (t.origin === i || !t.event || typeof t.event != "object") return;
321
+ if (typeof t.n == "number" && Number.isFinite(t.n)) {
322
+ let e = t.epoch ?? "", n = f.get(t.origin);
323
+ n === void 0 ? f.set(t.origin, {
324
+ epoch: e,
325
+ n: t.n
326
+ }) : n.epoch === e ? (t.n > n.n + 1 && m({
327
+ reason: `missed ${t.n - n.n - 1} change(s) from ${t.origin}`,
328
+ origin: t.origin,
329
+ missed: t.n - n.n - 1
330
+ }), t.n > n.n && f.set(t.origin, {
331
+ epoch: e,
332
+ n: t.n
333
+ })) : f.set(t.origin, {
334
+ epoch: e,
335
+ n: t.n
336
+ });
281
337
  }
282
- let l = O(r.event);
283
- s = !0, c = l;
338
+ let r = k(t.event);
339
+ s = !0, c = r;
284
340
  try {
285
- n.injectExternalChange(l);
341
+ n.injectExternalChange(r);
286
342
  } catch (e) {
287
343
  a.warn("broadcast: inject failed", { err: e });
288
344
  } finally {
289
345
  s = !1, c = void 0;
290
346
  }
291
- })), m = n.onChange((t) => {
292
- if (s && (t.origin === "injected" || t === c)) return;
347
+ }, g, _ = !1, v = 0, y = async () => {
348
+ try {
349
+ return g = await e.runPromise(r.subscribe(o, h)), !0;
350
+ } catch (e) {
351
+ return v += 1, v === 1 && a.warn("broadcast: could not subscribe — this replica will not receive other replicas' changes yet. Retrying in the background; local reactivity is unaffected.", {
352
+ provider: r.name,
353
+ channel: o,
354
+ err: e
355
+ }), !1;
356
+ }
357
+ };
358
+ await y() || (async () => {
359
+ let e = Date.now();
360
+ for (; !_ && g === void 0;) {
361
+ if (await new Promise((e) => setTimeout(e, A(v))), _) return;
362
+ if (await y()) {
363
+ a.info("broadcast: subscribed", {
364
+ provider: r.name,
365
+ channel: o,
366
+ attempts: v
367
+ }), m({ reason: `this replica was not subscribed to '${o}' for ${Math.round((Date.now() - e) / 1e3)}s` });
368
+ return;
369
+ }
370
+ }
371
+ })();
372
+ let b = r.onTransportEvent?.((e) => {
373
+ if (e.kind === "disconnected") {
374
+ a.warn("broadcast: transport disconnected — cross-replica fan-out is down until it re-dials", {
375
+ provider: r.name,
376
+ ...e.detail === void 0 ? {} : { detail: e.detail }
377
+ });
378
+ return;
379
+ }
380
+ m({ reason: `the ${r.name} transport reconnected after an outage` });
381
+ }), x = n.onChange((t) => {
382
+ if (t.origin === "injected" || s && t === c) return;
293
383
  d += 1;
294
384
  let n = {
295
385
  origin: i,
296
386
  event: t,
297
- n: d
387
+ n: d,
388
+ epoch: p
298
389
  };
299
390
  e.runPromise(r.publish(o, JSON.stringify(n)).pipe(e.tap(() => e.sync(() => {
300
391
  l = !1;
@@ -306,21 +397,19 @@ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"
306
397
  }));
307
398
  }))));
308
399
  });
309
- a.info("broadcast bus attached", {
400
+ return a.info("broadcast bus attached", {
310
401
  provider: r.name,
311
402
  channel: o,
312
403
  replicaId: i
313
- });
314
- let h = !1;
315
- return { close: async () => {
316
- if (!h) {
317
- h = !0, m();
404
+ }), { close: async () => {
405
+ if (!_) {
406
+ _ = !0, x(), b?.();
318
407
  try {
319
- p();
408
+ g?.();
320
409
  } catch {}
321
410
  await e.runPromise(r.close());
322
411
  }
323
412
  } };
324
413
  };
325
414
  //#endregion
326
- export { u as BROADCAST_CHANNEL, l as BroadcastError, r as DEFAULT_BROADCAST_NAMESPACE, A as attachBroadcastBus, T as broadcastPlugin, s as channelFor, a as describeNamespaceResolution, c as eventChannelFor, E as getBroadcastPlugin, g as memoryProvider, b as natsProvider, _ as redisProvider, h as resetMemoryBus, o as resolveBroadcastNamespace, S as resolveBroadcastProvider };
415
+ export { u as BROADCAST_CHANNEL, l as BroadcastError, r as DEFAULT_BROADCAST_NAMESPACE, M as attachBroadcastBus, E as broadcastPlugin, s as channelFor, a as describeNamespaceResolution, c as eventChannelFor, D as getBroadcastPlugin, _ as memoryProvider, x as natsProvider, v as redisProvider, g as resetMemoryBus, o as resolveBroadcastNamespace, C as resolveBroadcastProvider, A as subscribeRetryDelayMs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-broadcast",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Cross-replica reactivity — a pub/sub message bus (Redis / NATS / memory) that fans out app-mutation ChangeEvents to every replica behind a load balancer. ADDITIVE to inline emit: local reactivity survives a broker outage; cross-replica degrades gracefully. Closes the single-instance gap for every non-postgres dialect (postgres keeps native LISTEN/NOTIFY).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -33,8 +33,8 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "dependencies": {
36
- "@voltro/database": "0.55.0",
37
- "@voltro/protocol": "0.55.0"
36
+ "@voltro/database": "0.57.0",
37
+ "@voltro/protocol": "0.57.0"
38
38
  },
39
39
  "optionalDependencies": {
40
40
  "@nats-io/transport-node": "^3.4.0",