@voltro/plugin-broadcast 0.24.0 → 0.26.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
@@ -23,10 +23,29 @@ export declare interface AttachBroadcastBusOptions {
23
23
  readonly logger?: BroadcastBusLogger;
24
24
  /** Channel override (tests). Default `voltro:changes`. */
25
25
  readonly channel?: string;
26
+ /**
27
+ * Called when this replica proves it missed changes from a peer.
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.
35
+ *
36
+ * Absent ⇒ the gap is still DETECTED and logged; only the recovery is
37
+ * missing. That is deliberate: a bus used without a dispatcher (a test, an
38
+ * embedder) should still say what it lost.
39
+ */
40
+ readonly onGap?: (origin: string, missed: number) => void;
26
41
  }
27
42
 
28
- /** The channel the bus fans ChangeEvents out on. */
29
- export declare const BROADCAST_CHANNEL = "voltro:changes";
43
+ /**
44
+ * The changes channel when no namespace is known — a last resort, not the
45
+ * normal path. A real boot resolves a namespace from the app's name and passes
46
+ * the channel in, so two apps on one broker do not share it.
47
+ */
48
+ export declare const BROADCAST_CHANNEL: string;
30
49
 
31
50
  export declare interface BroadcastBusHandle {
32
51
  /** Detach the onChange listener, the bus subscription, and close the
@@ -40,6 +59,9 @@ export declare interface BroadcastBusLogger {
40
59
  debug: (message: string, fields?: Record<string, unknown>) => void;
41
60
  }
42
61
 
62
+ /** The channels the framework runs over one broker. */
63
+ export declare type BroadcastChannelKind = 'changes' | 'events' | 'members' | 'presence';
64
+
43
65
  /**
44
66
  * What rides the wire on the `voltro:changes` channel. `origin` is the
45
67
  * publishing replica's id; subscribers skip their OWN origin (the writer
@@ -50,6 +72,21 @@ export declare interface BroadcastBusLogger {
50
72
  export declare interface BroadcastEnvelope {
51
73
  readonly origin: string;
52
74
  readonly event: ChangeEvent;
75
+ /**
76
+ * This origin's monotonic serial, from 1.
77
+ *
78
+ * The one thing a receiver cannot infer, and without it a dropped message is
79
+ * undetectable: pub/sub has no retention, so a replica whose broker
80
+ * connection blips simply never learns that a change happened. Its clients
81
+ * keep their sockets — the client-side reconnect never fires — and their live
82
+ * queries stay stale until something else touches the same table, which for a
83
+ * quiet table can be never.
84
+ *
85
+ * Optional on the type because a message from an older replica during a
86
+ * rolling deploy has none; the receiver treats that as "cannot tell" rather
87
+ * than as a gap.
88
+ */
89
+ readonly n?: number;
53
90
  }
54
91
 
55
92
  /**
@@ -87,6 +124,12 @@ export declare interface BroadcastPlugin extends VoltroPlugin {
87
124
  readonly provider: BroadcastProvider;
88
125
  readonly url: string | null;
89
126
  readonly crossReplica: boolean;
127
+ /**
128
+ * The namespace as DECLARED here, before the app name or env is consulted.
129
+ * `undefined` means "derive it" — the framework resolves the effective value
130
+ * at boot, because only it knows the app's name.
131
+ */
132
+ readonly namespace: string | undefined;
90
133
  };
91
134
  }
92
135
 
@@ -106,12 +149,18 @@ export declare interface BroadcastPluginOptions {
106
149
  * `REDIS_URL`). Point broadcast at its own server or the shared one purely
107
150
  * by which env var you set. */
108
151
  readonly connection?: string;
109
- /** Channel the bus publishes/subscribes on. Namespace it per app/env
110
- * (e.g. `myapp:prod:changes`) when several deployments share one
111
- * broker otherwise they inject each other's ChangeEvents (spurious
112
- * wake-ups; matcher-scoped, so no data leak, but wasted work).
113
- * Default `BROADCAST_CHANNEL` env, else `voltro:changes`. */
114
- readonly channel?: string;
152
+ /**
153
+ * Namespace for EVERY framework channel on this broker — changes, events,
154
+ * membership and presence alike.
155
+ *
156
+ * Defaults to your app's name, so two different apps sharing one Redis or
157
+ * NATS separate without anyone remembering to do anything. Set it explicitly
158
+ * for the case that default cannot see: **several deployments of the SAME app
159
+ * on one broker** (staging and production, say) have the same name and the
160
+ * same code, so nothing derivable tells them apart. There, this — or
161
+ * `VOLTRO_BROADCAST_NAMESPACE` — is the only thing that works.
162
+ */
163
+ readonly namespace?: string;
115
164
  /** Disambiguates multiple instances of this plugin in one app. */
116
165
  readonly name?: string;
117
166
  }
@@ -150,6 +199,33 @@ export declare interface BroadcastStore {
150
199
  injectExternalChange: (event: ChangeEvent) => void;
151
200
  }
152
201
 
202
+ /**
203
+ * One framework channel inside a namespace.
204
+ *
205
+ * Every channel goes through here so a fifth one cannot be added as a flat
206
+ * constant that quietly skips the namespace — which is how the first four came
207
+ * to be flat in the first place.
208
+ */
209
+ export declare const channelFor: (namespace: string, kind: BroadcastChannelKind) => string;
210
+
211
+ /**
212
+ * The namespace used when nothing else is known.
213
+ *
214
+ * Only reachable when there is no app name to derive from — a bare library use.
215
+ * A real boot always has one.
216
+ */
217
+ export declare const DEFAULT_BROADCAST_NAMESPACE = "voltro";
218
+
219
+ /**
220
+ * The channel for ONE declared event.
221
+ *
222
+ * Per event rather than per app, so a replica can decline the traffic it has no
223
+ * subscribers for. The event name comes from a declaration and is unique by boot
224
+ * audit, but it is sanitised anyway: a name containing a NATS wildcard would
225
+ * turn one event's channel into a pattern matching others.
226
+ */
227
+ export declare const eventChannelFor: (namespace: string, event: string) => string;
228
+
153
229
  /**
154
230
  * Pull the resolved broadcast carrier off a plugin list (the framework's
155
231
  * serve pipeline calls this after building the store). Returns the FIRST
@@ -210,6 +286,32 @@ export declare interface RedisProviderOptions {
210
286
 
211
287
  /* Excluded from this release type: resetMemoryBus */
212
288
 
289
+ /**
290
+ * Resolve the namespace every framework channel hangs off.
291
+ *
292
+ * Deriving from the app name by DEFAULT rather than requiring configuration, and
293
+ * that ordering is the whole point: a namespace you must remember to set is one
294
+ * two apps forget to set, and the failure is silent in the worst direction —
295
+ * one deployment's events arriving at another's clients. The default has to be
296
+ * safe; configuration is for where the default cannot see.
297
+ *
298
+ * And it genuinely cannot see one case, which is worth stating plainly rather
299
+ * than papering over: STAGING AND PRODUCTION OF THE SAME APP have the same name,
300
+ * the same code and the same fingerprint. Nothing derivable separates them. If
301
+ * one broker serves several deployments of one app, the env var is not optional
302
+ * — it is the only thing that can work, and the docs say so.
303
+ */
304
+ export declare const resolveBroadcastNamespace: (input: ResolveBroadcastNamespaceInput) => string;
305
+
306
+ export declare interface ResolveBroadcastNamespaceInput {
307
+ /** `broadcast({ namespace })` — explicit, and wins over everything. */
308
+ readonly option?: string | undefined;
309
+ /** `VOLTRO_BROADCAST_NAMESPACE` — for the case the code cannot see. */
310
+ readonly env?: string | undefined;
311
+ /** The app's name, the default. Different apps therefore separate on their own. */
312
+ readonly appName?: string | undefined;
313
+ }
314
+
213
315
  export declare interface ResolveBroadcastOptions {
214
316
  /** Explicit provider name or a pre-built `BroadcastProvider`. */
215
317
  readonly provider?: BroadcastProviderName | BroadcastProvider;
package/dist/index.js CHANGED
@@ -1,28 +1,39 @@
1
1
  import { Effect as e, Schema as t } from "effect";
2
2
  import { definePlugin as n } from "@voltro/protocol";
3
- //#region src/types.ts
4
- var r = class extends t.TaggedError()("BroadcastError", {
3
+ //#region src/namespace.ts
4
+ var r = "voltro", i = (e) => e.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""), a = (e) => {
5
+ for (let t of [
6
+ e.option,
7
+ e.env,
8
+ e.appName
9
+ ]) {
10
+ if (t === void 0) continue;
11
+ let e = i(t);
12
+ if (e.length > 0) return e;
13
+ }
14
+ return r;
15
+ }, o = (e, t) => `${e}:${t}`, s = (e, t) => `${o(e, "events")}:${i(t)}`, c = class extends t.TaggedError()("BroadcastError", {
5
16
  provider: t.String,
6
17
  message: t.String,
7
18
  transient: t.Boolean,
8
19
  cause: t.optional(t.Unknown)
9
- }) {}, i = "voltro:changes", a = (e, t, n) => new r({
20
+ }) {}, l = o(r, "changes"), u = (e, t, n) => new c({
10
21
  provider: e,
11
22
  message: t,
12
23
  transient: !0,
13
24
  ...n === void 0 ? {} : { cause: n }
14
- }), o = (e, t, n) => new r({
25
+ }), d = (e, t, n) => new c({
15
26
  provider: e,
16
27
  message: t,
17
28
  transient: !1,
18
29
  ...n === void 0 ? {} : { cause: n }
19
- }), s = /* @__PURE__ */ new Map(), c = (e) => {
20
- let t = s.get(e);
21
- return t || (t = { handlers: /* @__PURE__ */ new Map() }, s.set(e, t)), t;
22
- }, l = (e) => {
23
- s.get(e)?.handlers.clear();
24
- }, u = (t = `default-${Math.random().toString(36).slice(2)}`) => {
25
- let n = c(t);
30
+ }), f = /* @__PURE__ */ new Map(), p = (e) => {
31
+ let t = f.get(e);
32
+ return t || (t = { handlers: /* @__PURE__ */ new Map() }, f.set(e, t)), t;
33
+ }, m = (e) => {
34
+ f.get(e)?.handlers.clear();
35
+ }, h = (t = `default-${Math.random().toString(36).slice(2)}`) => {
36
+ let n = p(t);
26
37
  return {
27
38
  name: "memory",
28
39
  publish: (t, r) => e.sync(() => {
@@ -39,7 +50,7 @@ var r = class extends t.TaggedError()("BroadcastError", {
39
50
  }),
40
51
  close: () => e.void
41
52
  };
42
- }, d = (t) => {
53
+ }, g = (t) => {
43
54
  let n = null, r = () => n || (n = (async () => {
44
55
  let e;
45
56
  if (t.client) e = t.client;
@@ -48,7 +59,7 @@ var r = class extends t.TaggedError()("BroadcastError", {
48
59
  try {
49
60
  n = (await import("ioredis")).default;
50
61
  } catch (e) {
51
- throw o("redis", "the 'redis' broadcast provider requires the 'ioredis' optional dependency. Install it to enable it.", e);
62
+ throw d("redis", "the 'redis' broadcast provider requires the 'ioredis' optional dependency. Install it to enable it.", e);
52
63
  }
53
64
  e = new n(t.url);
54
65
  }
@@ -65,7 +76,7 @@ var r = class extends t.TaggedError()("BroadcastError", {
65
76
  let { pub: e } = await r();
66
77
  await e.publish(t, n);
67
78
  },
68
- catch: (e) => a("redis", `publish failed: ${String(e?.message ?? e)}`, e)
79
+ catch: (e) => u("redis", `publish failed: ${String(e?.message ?? e)}`, e)
69
80
  }),
70
81
  subscribe: (t, n) => e.tryPromise({
71
82
  try: async () => {
@@ -76,7 +87,7 @@ var r = class extends t.TaggedError()("BroadcastError", {
76
87
  e.off("message", i), e.unsubscribe(t);
77
88
  };
78
89
  },
79
- catch: (e) => a("redis", `subscribe failed: ${String(e?.message ?? e)}`, e)
90
+ catch: (e) => u("redis", `subscribe failed: ${String(e?.message ?? e)}`, e)
80
91
  }),
81
92
  close: () => e.promise(async () => {
82
93
  if (n) try {
@@ -85,28 +96,28 @@ var r = class extends t.TaggedError()("BroadcastError", {
85
96
  } catch {}
86
97
  })
87
98
  };
88
- }, f = new TextEncoder(), p = new TextDecoder(), m = (t) => {
99
+ }, _ = new TextEncoder(), v = new TextDecoder(), y = (t) => {
89
100
  let n = null, r = () => import("@nats-io/transport-node").catch((e) => {
90
- throw o("nats", "the 'nats' broadcast provider requires the '@nats-io/transport-node' optional dependency. Install it to enable it.", e);
101
+ throw d("nats", "the 'nats' broadcast provider requires the '@nats-io/transport-node' optional dependency. Install it to enable it.", e);
91
102
  }), i = () => n || (n = (async () => t.connection ? t.connection : (await r()).connect({ servers: t.url }))(), n);
92
103
  return {
93
104
  name: "nats",
94
105
  publish: (t, n) => e.tryPromise({
95
106
  try: async () => {
96
- (await i()).publish(t, f.encode(n));
107
+ (await i()).publish(t, _.encode(n));
97
108
  },
98
- catch: (e) => a("nats", `publish failed: ${String(e?.message ?? e)}`, e)
109
+ catch: (e) => u("nats", `publish failed: ${String(e?.message ?? e)}`, e)
99
110
  }),
100
111
  subscribe: (t, n) => e.tryPromise({
101
112
  try: async () => {
102
113
  let e = (await i()).subscribe(t);
103
114
  return (async () => {
104
- for await (let t of e) n(p.decode(t.data));
115
+ for await (let t of e) n(v.decode(t.data));
105
116
  })(), () => {
106
117
  e.unsubscribe();
107
118
  };
108
119
  },
109
- catch: (e) => a("nats", `subscribe failed: ${String(e?.message ?? e)}`, e)
120
+ catch: (e) => u("nats", `subscribe failed: ${String(e?.message ?? e)}`, e)
110
121
  }),
111
122
  close: () => e.promise(async () => {
112
123
  if (n) try {
@@ -115,7 +126,7 @@ var r = class extends t.TaggedError()("BroadcastError", {
115
126
  } catch {}
116
127
  })
117
128
  };
118
- }, h = (e, t) => t[`${e.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_REDIS_URL`], g = (e, t) => {
129
+ }, b = (e, t) => t[`${e.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_REDIS_URL`], x = (e, t) => {
119
130
  if (e.provider && typeof e.provider == "object") {
120
131
  let t = e.provider;
121
132
  return {
@@ -124,16 +135,16 @@ var r = class extends t.TaggedError()("BroadcastError", {
124
135
  crossReplica: t.name !== "memory"
125
136
  };
126
137
  }
127
- let n = e.url ?? t.BROADCAST_URL ?? null, r = h(e.connection ?? "broadcast", t), i = e.connection !== void 0;
138
+ let n = e.url ?? t.BROADCAST_URL ?? null, r = b(e.connection ?? "broadcast", t), i = e.connection !== void 0;
128
139
  switch ((e.provider ?? t.BROADCAST_PROVIDER)?.toLowerCase() ?? (n?.startsWith("nats://") ? "nats" : n?.startsWith("redis://") || n?.startsWith("rediss://") || r || i && t.REDIS_URL ? "redis" : void 0)) {
129
140
  case "redis": {
130
141
  let e = n ?? r ?? t.REDIS_URL;
131
142
  return e ? {
132
- provider: d({ url: e }),
143
+ provider: g({ url: e }),
133
144
  url: e,
134
145
  crossReplica: !0
135
146
  } : {
136
- provider: u(),
147
+ provider: h(),
137
148
  url: null,
138
149
  crossReplica: !1
139
150
  };
@@ -141,22 +152,22 @@ var r = class extends t.TaggedError()("BroadcastError", {
141
152
  case "nats": {
142
153
  let e = n ?? t.NATS_URL;
143
154
  return e ? {
144
- provider: m({ url: e }),
155
+ provider: y({ url: e }),
145
156
  url: e,
146
157
  crossReplica: !0
147
158
  } : {
148
- provider: u(),
159
+ provider: h(),
149
160
  url: null,
150
161
  crossReplica: !1
151
162
  };
152
163
  }
153
164
  default: return {
154
- provider: u(),
165
+ provider: h(),
155
166
  url: null,
156
167
  crossReplica: !1
157
168
  };
158
169
  }
159
- }, _ = ["network:outbound:*"], v = [
170
+ }, S = ["network:outbound:*"], C = [
160
171
  {
161
172
  name: "BROADCAST_URL",
162
173
  required: !1,
@@ -192,8 +203,8 @@ var r = class extends t.TaggedError()("BroadcastError", {
192
203
  description: "Fallback broker URL used when the resolved provider is nats and no BROADCAST_URL is set (comma-separated for a cluster).",
193
204
  example: "nats://localhost:4222"
194
205
  }
195
- ], y = (t = {}) => {
196
- let r = g({
206
+ ], w = (t = {}) => {
207
+ let r = x({
197
208
  ...t.provider === void 0 ? {} : { provider: t.provider },
198
209
  ...t.url === void 0 ? {} : { url: t.url },
199
210
  ...t.connection === void 0 ? {} : { connection: t.connection }
@@ -202,8 +213,8 @@ var r = class extends t.TaggedError()("BroadcastError", {
202
213
  ...n({
203
214
  name: t.name ? `@voltro/plugin-broadcast#${t.name}` : "@voltro/plugin-broadcast",
204
215
  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.",
205
- permissions: _,
206
- declaredEnv: v,
216
+ permissions: S,
217
+ declaredEnv: C,
207
218
  onActivate: (t) => e.sync(() => {
208
219
  r.crossReplica ? t.logger.info("broadcast active", {
209
220
  provider: r.provider.name,
@@ -215,40 +226,61 @@ var r = class extends t.TaggedError()("BroadcastError", {
215
226
  broadcast: {
216
227
  provider: r.provider,
217
228
  url: r.url,
218
- crossReplica: r.crossReplica
229
+ crossReplica: r.crossReplica,
230
+ namespace: t.namespace
219
231
  }
220
232
  };
221
- }, b = (e) => {
222
- for (let t of e) if (x(t)) return t;
233
+ }, T = (e) => {
234
+ for (let t of e) if (E(t)) return t;
223
235
  return null;
224
- }, x = (e) => typeof e.broadcast == "object" && e.broadcast !== null && typeof e.broadcast.provider == "object", S = {
236
+ }, E = (e) => typeof e.broadcast == "object" && e.broadcast !== null && typeof e.broadcast.provider == "object", D = {
225
237
  info: () => {},
226
238
  warn: () => {},
227
239
  debug: () => {}
228
- }, C = async (t) => {
229
- let { store: n, provider: r, replicaId: i } = t, a = t.logger ?? S, o = t.channel ?? "voltro:changes", s = !1, c = !1, l = await e.runPromise(r.subscribe(o, (e) => {
230
- let t;
240
+ }, O = async (t) => {
241
+ let { store: n, provider: r, replicaId: i } = t, a = t.logger ?? D, o = t.channel ?? l, s = !1, c = !1, u = 0, d = /* @__PURE__ */ new Map(), f = await e.runPromise(r.subscribe(o, (e) => {
242
+ let r;
231
243
  try {
232
- t = JSON.parse(e);
244
+ r = JSON.parse(e);
233
245
  } catch {
234
246
  a.warn("broadcast: bad payload (not JSON)", { channel: o });
235
247
  return;
236
248
  }
237
- if (t.origin !== i && !(!t.event || typeof t.event != "object")) {
249
+ if (r.origin !== i && !(!r.event || typeof r.event != "object")) {
250
+ if (typeof r.n == "number" && Number.isFinite(r.n)) {
251
+ let e = d.get(r.origin);
252
+ if (e !== void 0 && r.n > e + 1) {
253
+ let n = r.n - e - 1;
254
+ a.warn("broadcast: missed changes from a peer — refreshing live queries, which is safe because a query is idempotent", {
255
+ origin: r.origin,
256
+ missed: n,
257
+ from: e,
258
+ to: r.n
259
+ });
260
+ try {
261
+ t.onGap?.(r.origin, n);
262
+ } catch (e) {
263
+ a.warn("broadcast: gap recovery threw", { err: e });
264
+ }
265
+ }
266
+ (e === void 0 || r.n > e) && d.set(r.origin, r.n);
267
+ }
238
268
  s = !0;
239
269
  try {
240
- n.injectExternalChange(t.event);
270
+ n.injectExternalChange(r.event);
241
271
  } catch (e) {
242
272
  a.warn("broadcast: inject failed", { err: e });
243
273
  } finally {
244
274
  s = !1;
245
275
  }
246
276
  }
247
- })), u = n.onChange((t) => {
277
+ })), p = n.onChange((t) => {
248
278
  if (s) return;
279
+ u += 1;
249
280
  let n = {
250
281
  origin: i,
251
- event: t
282
+ event: t,
283
+ n: u
252
284
  };
253
285
  e.runPromise(r.publish(o, JSON.stringify(n)).pipe(e.tap(() => e.sync(() => {
254
286
  c = !1;
@@ -265,16 +297,16 @@ var r = class extends t.TaggedError()("BroadcastError", {
265
297
  channel: o,
266
298
  replicaId: i
267
299
  });
268
- let d = !1;
300
+ let m = !1;
269
301
  return { close: async () => {
270
- if (!d) {
271
- d = !0, u();
302
+ if (!m) {
303
+ m = !0, p();
272
304
  try {
273
- l();
305
+ f();
274
306
  } catch {}
275
307
  await e.runPromise(r.close());
276
308
  }
277
309
  } };
278
310
  };
279
311
  //#endregion
280
- export { i as BROADCAST_CHANNEL, r as BroadcastError, C as attachBroadcastBus, y as broadcastPlugin, b as getBroadcastPlugin, u as memoryProvider, m as natsProvider, d as redisProvider, l as resetMemoryBus, g as resolveBroadcastProvider };
312
+ export { l as BROADCAST_CHANNEL, c as BroadcastError, r as DEFAULT_BROADCAST_NAMESPACE, O as attachBroadcastBus, w as broadcastPlugin, o as channelFor, s as eventChannelFor, T as getBroadcastPlugin, h as memoryProvider, y as natsProvider, g as redisProvider, m as resetMemoryBus, a as resolveBroadcastNamespace, x as resolveBroadcastProvider };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-broadcast",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",
@@ -32,8 +32,8 @@
32
32
  "node": ">=24.0.0"
33
33
  },
34
34
  "dependencies": {
35
- "@voltro/database": "0.24.0",
36
- "@voltro/protocol": "0.24.0"
35
+ "@voltro/database": "0.26.0",
36
+ "@voltro/protocol": "0.26.0"
37
37
  },
38
38
  "optionalDependencies": {
39
39
  "@nats-io/transport-node": "^3.4.0",