@voltro/plugin-presence 0.32.0 → 0.34.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @voltro/plugin-presence
4
4
 
5
- **Ephemeral realtime presence — who is online in a channel, with heartbeat + live roster + per-member metadata (cursor, status). Backed by a swept presence table; works cross-instance.**
5
+ **Ephemeral realtime presence — who is online in a channel, with heartbeat + live roster + per-member metadata (cursor, status). Held in memory, owner-partitioned; cross-instance with @voltro/plugin-broadcast.**
6
6
 
7
7
  [📖 Documentation](https://docs.voltro.dev/docs/plugins/presence) · [voltro.dev](https://voltro.dev) · [Voltro Cloud](https://voltro.cloud)
8
8
 
package/dist/index.d.ts CHANGED
@@ -1,8 +1,6 @@
1
- import { ColumnBuilder } from '@voltro/database';
2
1
  import { Effect } from 'effect';
3
- import { FieldDefinitions } from '@voltro/database';
2
+ import { ReactivityChannel } from '@voltro/protocol';
4
3
  import { Schema } from 'effect';
5
- import { Table } from '@voltro/database';
6
4
  import { VoltroPlugin } from '@voltro/protocol';
7
5
 
8
6
  /**
@@ -25,18 +23,18 @@ export declare interface AttachPresenceBusOptions {
25
23
  };
26
24
  /** The namespaced presence channel, from the framework's one resolver. */
27
25
  readonly channel?: string | undefined;
26
+ /**
27
+ * Called when a REMOTE event moved this replica's roster — a peer's delta, or
28
+ * a peer's departure taking its partition with it.
29
+ *
30
+ * Only for the remote paths. A local heartbeat's push is the caller's, because
31
+ * only the caller knows whether the write it just made changed anything;
32
+ * routing it through here as well would give one fact two owners.
33
+ */
34
+ readonly onRosterChanged?: (() => void) | undefined;
28
35
  readonly now?: () => number;
29
36
  }
30
37
 
31
- /** The currently-online members of a channel, newest heartbeat first. */
32
- export declare const filterOnline: (entries: ReadonlyArray<PresenceEntry>, now: number, timeoutMs: number) => ReadonlyArray<PresenceEntry>;
33
-
34
- /** A member is online if its last heartbeat is within the timeout window. */
35
- export declare const isOnline: (lastSeen: number, now: number, timeoutMs: number) => boolean;
36
-
37
- /** In-memory presence store (tests + single-process dev). */
38
- export declare const memoryPresenceStore: () => PresenceStore;
39
-
40
38
  /**
41
39
  * Its own channel, for the same reason events are not on the change channel —
42
40
  * and namespaced by the framework, which passes the resolved value in.
@@ -78,16 +76,6 @@ export declare type PresenceDelta = {
78
76
  readonly key: string;
79
77
  };
80
78
 
81
- export declare interface PresenceEntry {
82
- readonly channel: string;
83
- /** Stable presence key — the subject id, or a client-supplied id for anon. */
84
- readonly key: string;
85
- readonly tenantId: string | null;
86
- readonly meta: Record<string, unknown> | null;
87
- /** epoch ms of the last heartbeat. */
88
- readonly lastSeen: number;
89
- }
90
-
91
79
  /** The caller has no presence identity — an anonymous subject (no subject id)
92
80
  * that also passed no explicit `key`. There's nothing stable to register a
93
81
  * heartbeat under, so the route fails typed instead of silently no-opping.
@@ -132,20 +120,37 @@ export declare interface PresencePluginOptions {
132
120
  * the default is the right relationship for almost every room.
133
121
  */
134
122
  readonly sweepIntervalMs?: number;
123
+ /**
124
+ * Namespace for this plugin's rpc tags. Default `presence`.
125
+ *
126
+ * Set it when your app already publishes under that name — an exact tag
127
+ * collision is fatal at codegen, and this is the way out. Orthogonal to
128
+ * `name` below: `alias` REPLACES the namespace, `name` distinguishes two
129
+ * installations within it.
130
+ */
131
+ readonly alias?: string;
132
+ /**
133
+ * Discriminator for a SECOND installation of this plugin, when one app runs
134
+ * two (`@voltro/plugin-presence#analytics`). Not a rename — for that use
135
+ * `alias`.
136
+ */
135
137
  readonly name?: string;
136
138
  }
137
139
 
138
- export declare interface PresenceStore {
139
- readonly upsert: (entry: PresenceEntry) => Promise<void>;
140
- /** The channel's members FOR THE CALLER'S TENANT. Tenant-scoped + fail-closed:
141
- * a caller only ever sees members whose `tenantId` equals theirs (anon
142
- * `null` sees only anon members); a same-named channel in another tenant is
143
- * invisible. Pass `subject.tenantId`. */
144
- readonly list: (channel: string, tenantId: string | null) => Promise<ReadonlyArray<PresenceEntry>>;
145
- readonly remove: (channel: string, key: string) => Promise<void>;
146
- /** Drop every entry whose heartbeat is older than `olderThan` (epoch ms). */
147
- readonly sweep: (olderThan: number) => Promise<number>;
148
- }
140
+ /**
141
+ * The roster's reactivity channel — presence's push target.
142
+ *
143
+ * Declared HERE, in the browser-safe descriptor module, for two reasons. It is
144
+ * the `source:` of `listDescriptor`, so the client descriptor and the server
145
+ * route now carry the SAME value instead of the server adding one the client
146
+ * never saw. And the server's `publishReactivity` imports this exact object, so
147
+ * the thing that publishes and the thing that subscribes cannot name two
148
+ * different keys the failure a pair of strings makes possible and silent.
149
+ *
150
+ * It replaces `_voltro_presence`, a table that was declared and deliberately
151
+ * never written purely to own this name.
152
+ */
153
+ export declare const presenceRoster: ReactivityChannel;
149
154
 
150
155
  /** The presence store isn't bound — `bindDataStore` never ran. This is a boot/
151
156
  * wiring bug (the plugin wasn't installed on an app with a DataStore), not a
@@ -157,37 +162,6 @@ declare const PresenceStoreUnavailable_base: Schema.TaggedErrorClass<PresenceSto
157
162
  readonly _tag: Schema.tag<"PresenceStoreUnavailable">;
158
163
  }>;
159
164
 
160
- /**
161
- * The presence table — DECLARED, and deliberately NEVER WRITTEN.
162
- *
163
- * Presence state lives in the owner-partitioned `PresenceTracker`, in memory,
164
- * announced between replicas over the broadcast channel. No row is inserted or
165
- * updated any more: a heartbeat used to rewrite one row per client every 15
166
- * seconds, which is a lot of write amplification for a datum that is meaningless
167
- * 30 seconds later.
168
- *
169
- * The declaration survives because the name is the REACTIVITY KEY. `presence.list`
170
- * declares `source: '_voltro_presence'`, and the framework routes change events
171
- * by table name — so the plugin injects a synthetic change on that name whenever
172
- * the tracker moves, and every subscribed client is pushed a fresh roster
173
- * through the path it already used. Removing the declaration would make the
174
- * `source` name resolve to nothing, which the boot audit reports (correctly) and
175
- * which would silently stop every roster from updating.
176
- *
177
- * It therefore stays as an empty table. That is a real cost — one unused table
178
- * in every user's database — accepted over the alternatives: a `source` that
179
- * names nothing (invisible breakage, and an audit exemption that would rot), or
180
- * a second client-side subscription concept just for presence.
181
- */
182
- export declare const presenceTable: Table<"_voltro_presence", FieldDefinitions<{
183
- readonly id: ColumnBuilder<string, "id", boolean>;
184
- readonly channel: ColumnBuilder<string, "text", boolean>;
185
- readonly key: ColumnBuilder<string, "text", boolean>;
186
- readonly tenantId: ColumnBuilder<string | null, "text", boolean>;
187
- readonly meta: ColumnBuilder<unknown, "json", boolean>;
188
- readonly lastSeen: ColumnBuilder<Date, "timestamp", true>;
189
- }>, true, "byPresenceChannel">;
190
-
191
165
  /**
192
166
  * The merged presence view this instance can see.
193
167
  *
@@ -212,29 +186,36 @@ export declare class PresenceTracker {
212
186
  *
213
187
  * Returns the delta to broadcast, so the caller cannot forget to — a local
214
188
  * write that is not announced is a member the other replicas never see, and
215
- * nothing would report it.
189
+ * nothing would report it. It returns `changed` beside it for the same kind of
190
+ * reason: a caller that has to derive "did this move the roster" would derive
191
+ * it from the delta, which cannot answer the question.
216
192
  */
217
193
  track(input: {
218
194
  readonly channel: string;
219
195
  readonly tenantId: string | null;
220
196
  readonly key: string;
221
197
  readonly meta?: Record<string, unknown> | null;
222
- }): PresenceDelta;
198
+ }): PresenceWrite;
223
199
  /** A client of THIS instance left. */
224
200
  untrack(input: {
225
201
  readonly channel: string;
226
202
  readonly tenantId: string | null;
227
203
  readonly key: string;
228
- }): PresenceDelta;
204
+ }): PresenceWrite;
229
205
  /**
230
- * Apply a delta from ANOTHER instance.
206
+ * Apply a delta from ANOTHER instance. Returns whether the roster moved.
231
207
  *
232
208
  * Our own owner id is ignored: the local write already happened synchronously
233
209
  * in `track`, and re-applying the echo would be harmless today but would make
234
210
  * the local map depend on the broker — so a broker outage would silently stop
235
211
  * this instance from seeing its OWN clients.
212
+ *
213
+ * The return value is what lets the bus push a remote join to LOCAL
214
+ * subscribers. Until it existed, nothing did: a member who joined on replica A
215
+ * appeared on replica B's screens only when one of B's own clients happened to
216
+ * heartbeat, and the unconditional push on that heartbeat is what hid it.
236
217
  */
237
- apply(owner: string, delta: PresenceDelta): void;
218
+ apply(owner: string, delta: PresenceDelta): boolean;
238
219
  /**
239
220
  * An instance is gone — drop everything it owned, in one operation.
240
221
  *
@@ -308,8 +289,28 @@ export declare interface PresenceTransport {
308
289
  readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, unknown>;
309
290
  }
310
291
 
311
- /** Keys that have gone stale (eligible for the sweep). */
312
- export declare const staleKeys: (entries: ReadonlyArray<PresenceEntry>, now: number, timeoutMs: number) => ReadonlyArray<PresenceEntry>;
292
+ /**
293
+ * A local write, and whether it MOVED the roster.
294
+ *
295
+ * The second field is the whole reason this is not just a `PresenceDelta`. A
296
+ * heartbeat arrives every 15 seconds per client and almost never changes
297
+ * anything a subscriber can observe — the member was already there with the same
298
+ * metadata. Pushing on it anyway made the cost quadratic: N clients heartbeating
299
+ * into a room woke all N subscribers each time, N² wake-ups per interval, for a
300
+ * roster that had not moved.
301
+ *
302
+ * `changed` is exact rather than approximate because `lastSeen` is no longer on
303
+ * the wire (see `rpc.ts`). It is a heartbeat's ONLY effect in the steady state,
304
+ * so while it was observable, "did anything change" could only ever be answered
305
+ * "yes".
306
+ */
307
+ export declare interface PresenceWrite {
308
+ /** The delta to broadcast. Always present — a peer must learn of a heartbeat
309
+ * even when the roster did not move, or its own sweep will drop the member. */
310
+ readonly delta: PresenceDelta;
311
+ /** Whether a local subscriber would see a different roster. */
312
+ readonly changed: boolean;
313
+ }
313
314
 
314
315
  /** One member as the roster reports it, with the owner it belongs to. */
315
316
  export declare interface TrackedMember extends PresenceMember {
package/dist/index.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { PresenceKeyMissing as e, PresenceStoreUnavailable as t } from "./errors.js";
2
- import { heartbeatDescriptor as n, leaveDescriptor as r, listDescriptor as i, presenceRpcClientImports as a } from "./rpc.js";
3
- import { Effect as o } from "effect";
4
- import { id as s, json as c, table as l, text as u, timestamp as d } from "@voltro/database";
5
- import { definePlugin as f } from "@voltro/protocol";
2
+ import { heartbeatDescriptor as n, leaveDescriptor as r, listDescriptor as i, presenceRoster as a, presenceRpcClientImports as o } from "./rpc.js";
3
+ import { Effect as s } from "effect";
4
+ import { definePlugin as c, pluginInstanceName as l, publishReactivity as u } from "@voltro/protocol";
6
5
  //#region src/tracker.ts
7
- var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
6
+ var d = (e, t) => `${e ?? "~"}::${t}`, f = (e) => e === void 0 ? "undefined" : typeof e != "object" || !e ? JSON.stringify(e) ?? "undefined" : Array.isArray(e) ? `[${e.map(f).join(",")}]` : `{${Object.entries(e).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${f(t)}`).join(",")}}`, p = (e, t) => e === t || e !== null && t !== null && f(e) === f(t), m = class {
8
7
  partitions = /* @__PURE__ */ new Map();
9
8
  selfId;
10
9
  now;
@@ -17,7 +16,7 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
17
16
  branch(e, t, n) {
18
17
  let r = this.partitions.get(e);
19
18
  r === void 0 && (r = /* @__PURE__ */ new Map(), this.partitions.set(e, r));
20
- let i = p(t, n), a = r.get(i);
19
+ let i = d(t, n), a = r.get(i);
21
20
  return a === void 0 && (a = {
22
21
  tenantId: t,
23
22
  channel: n,
@@ -25,40 +24,45 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
25
24
  }, r.set(i, a)), a;
26
25
  }
27
26
  track(e) {
28
- let t = this.now(), n = e.meta ?? null;
29
- return this.branch(this.selfId, e.tenantId, e.channel).members.set(e.key, {
27
+ let t = this.now(), n = e.meta ?? null, { members: r } = this.branch(this.selfId, e.tenantId, e.channel), i = r.get(e.key);
28
+ return r.set(e.key, {
30
29
  key: e.key,
31
30
  meta: n,
32
31
  lastSeen: t
33
32
  }), {
34
- op: "set",
35
- channel: e.channel,
36
- tenantId: e.tenantId,
37
- key: e.key,
38
- meta: n,
39
- lastSeen: t
33
+ delta: {
34
+ op: "set",
35
+ channel: e.channel,
36
+ tenantId: e.tenantId,
37
+ key: e.key,
38
+ meta: n,
39
+ lastSeen: t
40
+ },
41
+ changed: i === void 0 || !p(i.meta, n)
40
42
  };
41
43
  }
42
44
  untrack(e) {
43
- return this.branch(this.selfId, e.tenantId, e.channel).members.delete(e.key), {
44
- op: "remove",
45
- channel: e.channel,
46
- tenantId: e.tenantId,
47
- key: e.key
45
+ let t = this.branch(this.selfId, e.tenantId, e.channel).members.delete(e.key);
46
+ return {
47
+ delta: {
48
+ op: "remove",
49
+ channel: e.channel,
50
+ tenantId: e.tenantId,
51
+ key: e.key
52
+ },
53
+ changed: t
48
54
  };
49
55
  }
50
56
  apply(e, t) {
51
- if (e === this.selfId) return;
57
+ if (e === this.selfId) return !1;
52
58
  let { members: n } = this.branch(e, t.tenantId, t.channel);
53
- if (t.op === "remove") {
54
- n.delete(t.key);
55
- return;
56
- }
57
- n.set(t.key, {
59
+ if (t.op === "remove") return n.delete(t.key);
60
+ let r = n.get(t.key);
61
+ return n.set(t.key, {
58
62
  key: t.key,
59
63
  meta: t.meta,
60
64
  lastSeen: t.lastSeen
61
- });
65
+ }), r === void 0 || !p(r.meta, t.meta);
62
66
  }
63
67
  dropOwner(e) {
64
68
  if (e === this.selfId) return 0;
@@ -94,7 +98,7 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
94
98
  return e;
95
99
  }
96
100
  roster(e, t) {
97
- let n = p(t, e), r = [];
101
+ let n = d(t, e), r = [];
98
102
  for (let [i, a] of this.partitions) {
99
103
  let o = a.get(n);
100
104
  if (o !== void 0) for (let n of o.members.values()) r.push({
@@ -123,27 +127,31 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
123
127
  let t = new m({
124
128
  instanceId: e.instanceId,
125
129
  ...e.now === void 0 ? {} : { now: e.now }
126
- }), n = e.logger, r = e.membership.onChange((e) => {
130
+ }), n = e.logger, r = () => {
131
+ try {
132
+ e.onRosterChanged?.();
133
+ } catch {}
134
+ }, i = e.membership.onChange((e) => {
127
135
  if (e.kind === "joined") {
128
- c();
136
+ l();
129
137
  return;
130
138
  }
131
- let r = t.dropOwner(e.instanceId);
132
- r > 0 && n?.warn("presence: dropped members of a departed instance", {
139
+ let i = t.dropOwner(e.instanceId);
140
+ i > 0 && (n?.warn("presence: dropped members of a departed instance", {
133
141
  instanceId: e.instanceId,
134
- members: r,
142
+ members: i,
135
143
  reason: e.kind
136
- }), e.kind === "restarted" && c();
137
- }), i = e.provider, a = e.channel ?? "voltro:presence", s = (e) => {
138
- i !== void 0 && o.runPromise(i.publish(a, JSON.stringify(e))).catch(() => {});
139
- }, c = () => {
144
+ }), r()), e.kind === "restarted" && l();
145
+ }), a = e.provider, o = e.channel ?? "voltro:presence", c = (e) => {
146
+ a !== void 0 && s.runPromise(a.publish(o, JSON.stringify(e))).catch(() => {});
147
+ }, l = () => {
140
148
  let n = t.ownSnapshot();
141
- n.length !== 0 && s({
149
+ n.length !== 0 && c({
142
150
  owner: e.instanceId,
143
151
  sync: n
144
152
  });
145
- }, l;
146
- return i !== void 0 && o.runPromise(i.subscribe(a, (e) => {
153
+ }, u;
154
+ return a !== void 0 && s.runPromise(a.subscribe(o, (e) => {
147
155
  let n;
148
156
  try {
149
157
  n = JSON.parse(e);
@@ -151,147 +159,127 @@ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
151
159
  return;
152
160
  }
153
161
  if (_(n)) {
154
- for (let e of n.sync) t.apply(n.owner, e);
162
+ let e = !1;
163
+ for (let r of n.sync) e = t.apply(n.owner, r) || e;
164
+ e && r();
155
165
  return;
156
166
  }
157
- g(n) && t.apply(n.owner, n.delta);
167
+ g(n) && t.apply(n.owner, n.delta) && r();
158
168
  })).then((e) => {
159
- l = e;
169
+ u = e;
160
170
  }, (e) => {
161
171
  n?.warn("presence: could not subscribe — this instance will only see its own members", { cause: e });
162
172
  }), {
163
173
  tracker: t,
164
- hasTransport: i !== void 0,
174
+ hasTransport: a !== void 0,
165
175
  announce: (t) => {
166
- s({
176
+ c({
167
177
  owner: e.instanceId,
168
178
  delta: t
169
179
  });
170
180
  },
171
181
  detach: () => {
172
- r(), l?.();
182
+ i(), u?.();
173
183
  }
174
184
  };
175
- }, y = (e, t, n) => t - e < n, b = (e, t, n) => e.filter((e) => y(e.lastSeen, t, n)).sort((e, t) => t.lastSeen - e.lastSeen), x = (e, t, n) => e.filter((e) => !y(e.lastSeen, t, n)), S = () => {
176
- let e = /* @__PURE__ */ new Map(), t = (e, t) => `${e}|${t}`;
177
- return {
178
- upsert: async (n) => {
179
- e.set(t(n.channel, n.key), n);
180
- },
181
- list: async (t, n) => [...e.values()].filter((e) => e.channel === t && e.tenantId === n),
182
- remove: async (n, r) => {
183
- e.delete(t(n, r));
184
- },
185
- sweep: async (t) => {
186
- let n = 0;
187
- for (let [r, i] of e) i.lastSeen < t && (e.delete(r), n++);
188
- return n;
189
- }
190
- };
191
- }, C = "_voltro_presence", w = l(C, {
192
- id: s({ prefix: "pres" }),
193
- channel: u(),
194
- key: u(),
195
- tenantId: u().nullable(),
196
- meta: c().nullable(),
197
- lastSeen: d().default("now")
198
- }).unique(["channel", "key"]).index("byPresenceChannel", ["channel", "lastSeen"]), T = (s = {}) => {
199
- if (s.timeoutMs !== void 0 && s.timeoutMs < 1e3) throw Error(`presencePlugin: \`timeoutMs\` is ${s.timeoutMs}, which expires every member before it can heartbeat again.
185
+ }, y = "@voltro/plugin-presence", b = (d = {}) => {
186
+ if (d.timeoutMs !== void 0 && d.timeoutMs < 1e3) throw Error(`presencePlugin: \`timeoutMs\` is ${d.timeoutMs}, which expires every member before it can heartbeat again.
200
187
  A member is online for \`timeoutMs\` after its last heartbeat, and \`usePresence\` beats every 15s by default — so this must be LARGER than your client's
201
188
  \`heartbeatMs\`, comfortably. The default pair is 30s server / 15s client.
202
189
  There is no "never expire" spelling: omit \`timeoutMs\` for the default.`);
203
- let c, l = s.timeoutMs ?? 3e4, u = Math.max(1e3, Math.floor(s.sweepIntervalMs ?? l / 3)), d = s.name ? `@voltro/plugin-presence#${s.name}` : "@voltro/plugin-presence", p, m, h, g = () => {
204
- h !== void 0 || p === void 0 || (h = setInterval(() => {
205
- let e = p;
190
+ let f, p = d.timeoutMs ?? 3e4, m = Math.max(1e3, Math.floor(d.sweepIntervalMs ?? p / 3)), h = l({
191
+ base: y,
192
+ alias: d.alias,
193
+ instance: d.name
194
+ }), g, _, b, x = () => {
195
+ b !== void 0 || g === void 0 || (b = setInterval(() => {
196
+ let e = g;
206
197
  if (e === void 0) return;
207
- let t = e.tracker.sweep(l);
198
+ let t = e.tracker.sweep(p);
208
199
  if (t.length !== 0) {
209
200
  for (let n of t) e.announce(n);
210
- b();
201
+ w();
211
202
  }
212
- }, u), h.unref?.());
213
- }, _ = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, y = (e) => e.request.subject?.tenantId ?? null, b = () => {
214
- m?.injectExternalChange?.({
215
- table: C,
216
- op: "update",
217
- new: {},
218
- old: {}
219
- });
220
- }, x = [
221
- {
222
- ...n,
223
- description: "Register/refresh the caller's presence in a channel.",
224
- execute: (n, r) => o.gen(function* () {
225
- let i = n;
226
- if (!p) return yield* o.fail(new t({}));
227
- let a = _(r, i.key);
228
- return a === null ? yield* o.fail(new e({ channel: i.channel })) : (p.announce(p.tracker.track({
229
- channel: i.channel,
230
- tenantId: y(r),
231
- key: a,
232
- ...i.meta === void 0 ? {} : { meta: i.meta }
233
- })), b(), { ok: !0 });
234
- })
235
- },
236
- {
237
- ...i,
238
- description: "The online roster of a channel (heartbeat-fresh members). Push-driven — updates live.",
239
- source: C,
240
- execute: (e, n) => o.gen(function* () {
241
- return p ? p.tracker.roster(e.channel, y(n)).map((e) => ({
242
- key: e.key,
243
- meta: e.meta,
244
- lastSeen: e.lastSeen
245
- })) : yield* o.fail(new t({}));
246
- })
247
- },
248
- {
249
- ...r,
250
- description: "Remove the caller's presence from a channel.",
251
- execute: (n, r) => o.gen(function* () {
252
- let i = n;
253
- if (!p) return yield* o.fail(new t({}));
254
- let a = _(r, i.key);
255
- return a === null ? yield* o.fail(new e({ channel: i.channel })) : (p.announce(p.tracker.untrack({
256
- channel: i.channel,
257
- tenantId: y(r),
258
- key: a
259
- })), b(), { ok: !0 });
260
- })
261
- }
262
- ];
263
- return f({
264
- name: d,
203
+ }, m), b.unref?.());
204
+ }, S = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, C = (e) => e.request.subject?.tenantId ?? null, w = () => {
205
+ u(_, a);
206
+ };
207
+ return c({
208
+ name: h,
209
+ baseName: y,
265
210
  description: "Ephemeral realtime presence — heartbeat roster per channel.",
266
- permissions: ["store:write"],
267
- extendSchema: { tables: [w] },
268
- routes: x,
269
- rpcClientDescriptors: a,
211
+ routes: [
212
+ {
213
+ ...n,
214
+ description: "Register/refresh the caller's presence in a channel.",
215
+ execute: (n, r) => s.gen(function* () {
216
+ let i = n;
217
+ if (!g) return yield* s.fail(new t({}));
218
+ let a = S(r, i.key);
219
+ if (a === null) return yield* s.fail(new e({ channel: i.channel }));
220
+ let o = g.tracker.track({
221
+ channel: i.channel,
222
+ tenantId: C(r),
223
+ key: a,
224
+ ...i.meta === void 0 ? {} : { meta: i.meta }
225
+ });
226
+ return g.announce(o.delta), o.changed && w(), { ok: !0 };
227
+ })
228
+ },
229
+ {
230
+ ...i,
231
+ description: "The online roster of a channel (heartbeat-fresh members). Push-driven — updates live.",
232
+ execute: (e, n) => s.gen(function* () {
233
+ return g ? g.tracker.roster(e.channel, C(n)).map((e) => ({
234
+ key: e.key,
235
+ meta: e.meta
236
+ })) : yield* s.fail(new t({}));
237
+ })
238
+ },
239
+ {
240
+ ...r,
241
+ description: "Remove the caller's presence from a channel.",
242
+ execute: (n, r) => s.gen(function* () {
243
+ let i = n;
244
+ if (!g) return yield* s.fail(new t({}));
245
+ let a = S(r, i.key);
246
+ if (a === null) return yield* s.fail(new e({ channel: i.channel }));
247
+ let o = g.tracker.untrack({
248
+ channel: i.channel,
249
+ tenantId: C(r),
250
+ key: a
251
+ });
252
+ return g.announce(o.delta), o.changed && w(), { ok: !0 };
253
+ })
254
+ }
255
+ ],
256
+ rpcClientDescriptors: o,
270
257
  bindDataStore: (e, t) => {
271
- m = e;
258
+ _ = e;
272
259
  let n = t;
273
- p = v({
260
+ g = v({
274
261
  instanceId: n?.instanceId ?? `presence-${process.pid}`,
275
262
  membership: n?.membership ?? { onChange: () => () => {} },
276
263
  ...n?.broadcast === void 0 ? {} : { provider: n.broadcast },
277
- ...n?.broadcastChannels?.presence === void 0 ? {} : { channel: n.broadcastChannels.presence }
264
+ ...n?.broadcastChannels?.presence === void 0 ? {} : { channel: n.broadcastChannels.presence },
265
+ onRosterChanged: w
278
266
  });
279
267
  },
280
- onActivate: (e) => o.sync(() => {
281
- g(), e.logger.info("presence active", {
282
- timeoutMs: l,
283
- sweepEveryMs: u
268
+ onActivate: (e) => s.sync(() => {
269
+ x(), e.logger.info("presence active", {
270
+ timeoutMs: p,
271
+ sweepEveryMs: m
284
272
  });
285
273
  let t = Number(process.env.VOLTRO_PRESENCE_BROKER_GRACE_MS ?? 5e3), n = setTimeout(() => {
286
- let t = p?.hasTransport === !0;
274
+ let t = g?.hasTransport === !0;
287
275
  e.logger.info("presence transport", { crossInstance: t }), !t && e.logger.warn("presence: no broadcast provider — each replica sees only the clients CONNECTED TO IT. Correct for a single instance; on more than one, every screen shows a fraction of the room and nothing reports it. Add @voltro/plugin-broadcast (redis:// or nats://) to share the roster.");
288
276
  }, t);
289
- n.unref?.(), c = n;
277
+ n.unref?.(), f = n;
290
278
  }),
291
- onDeactivate: () => o.sync(() => {
292
- c !== void 0 && (clearTimeout(c), c = void 0), h !== void 0 && (clearInterval(h), h = void 0), p?.detach(), p = void 0, m = void 0;
279
+ onDeactivate: () => s.sync(() => {
280
+ f !== void 0 && (clearTimeout(f), f = void 0), b !== void 0 && (clearInterval(b), b = void 0), g?.detach(), g = void 0, _ = void 0;
293
281
  })
294
282
  });
295
283
  };
296
284
  //#endregion
297
- export { h as PRESENCE_BROADCAST_CHANNEL, e as PresenceKeyMissing, t as PresenceStoreUnavailable, m as PresenceTracker, v as attachPresenceBus, b as filterOnline, y as isOnline, S as memoryPresenceStore, T as presencePlugin, w as presenceTable, x as staleKeys };
285
+ export { h as PRESENCE_BROADCAST_CHANNEL, e as PresenceKeyMissing, t as PresenceStoreUnavailable, m as PresenceTracker, v as attachPresenceBus, b as presencePlugin, a as presenceRoster };
package/dist/rpc.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { MutationProcedureDescriptor } from '@voltro/protocol';
2
2
  import { PluginRpcClientDescriptor } from '@voltro/protocol';
3
3
  import { QueryProcedureDescriptor } from '@voltro/protocol';
4
+ import { ReactivityChannel } from '@voltro/protocol';
4
5
  import { Schema } from 'effect';
5
6
 
6
7
  export declare const heartbeatDescriptor: MutationProcedureDescriptor<"presence.heartbeat", Schema.Struct<{
@@ -23,7 +24,6 @@ export declare const listDescriptor: QueryProcedureDescriptor<"presence.list", S
23
24
  }>, Schema.Array$<Schema.Struct<{
24
25
  key: typeof Schema.String;
25
26
  meta: Schema.NullOr<Schema.Record$<typeof Schema.String, typeof Schema.Unknown>>;
26
- lastSeen: typeof Schema.Number;
27
27
  }>>, typeof PresenceStoreUnavailable>;
28
28
 
29
29
  /** The caller has no presence identity — an anonymous subject (no subject id)
@@ -39,6 +39,21 @@ declare const PresenceKeyMissing_base: Schema.TaggedErrorClass<PresenceKeyMissin
39
39
  channel: typeof Schema.String;
40
40
  }>;
41
41
 
42
+ /**
43
+ * The roster's reactivity channel — presence's push target.
44
+ *
45
+ * Declared HERE, in the browser-safe descriptor module, for two reasons. It is
46
+ * the `source:` of `listDescriptor`, so the client descriptor and the server
47
+ * route now carry the SAME value instead of the server adding one the client
48
+ * never saw. And the server's `publishReactivity` imports this exact object, so
49
+ * the thing that publishes and the thing that subscribes cannot name two
50
+ * different keys — the failure a pair of strings makes possible and silent.
51
+ *
52
+ * It replaces `_voltro_presence`, a table that was declared and deliberately
53
+ * never written purely to own this name.
54
+ */
55
+ export declare const presenceRoster: ReactivityChannel;
56
+
42
57
  /** Declared for the codegen (`VoltroPlugin.rpcClientDescriptors`) so each tag is
43
58
  * emitted into the generated client group. Kept in lockstep with the exports. */
44
59
  export declare const presenceRpcClientImports: ReadonlyArray<PluginRpcClientDescriptor>;