@voltro/plugin-presence 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
@@ -1,9 +1,33 @@
1
1
  import { ColumnBuilder } from '@voltro/database';
2
+ import { Effect } from 'effect';
2
3
  import { FieldDefinitions } from '@voltro/database';
3
4
  import { Schema } from 'effect';
4
5
  import { Table } from '@voltro/database';
5
6
  import { VoltroPlugin } from '@voltro/protocol';
6
7
 
8
+ /**
9
+ * Wire a tracker to the transport and the membership registry.
10
+ *
11
+ * Returns a working tracker EVEN WITH NO PROVIDER — a single-instance
12
+ * deployment is just a cluster of one, and its roster is complete. Making the
13
+ * provider optional here rather than branching at the call site is what keeps
14
+ * "works in dev, silently different in production" off the table.
15
+ */
16
+ export declare const attachPresenceBus: (options: AttachPresenceBusOptions) => PresenceBusHandle;
17
+
18
+ export declare interface AttachPresenceBusOptions {
19
+ readonly instanceId: string;
20
+ readonly membership: PresenceMembership;
21
+ /** Absent ⇒ single instance. The tracker still works; nothing is broadcast. */
22
+ readonly provider?: PresenceTransport | undefined;
23
+ readonly logger?: {
24
+ readonly warn: (m: string, f?: Record<string, unknown>) => void;
25
+ };
26
+ /** The namespaced presence channel, from the framework's one resolver. */
27
+ readonly channel?: string | undefined;
28
+ readonly now?: () => number;
29
+ }
30
+
7
31
  /** The currently-online members of a channel, newest heartbeat first. */
8
32
  export declare const filterOnline: (entries: ReadonlyArray<PresenceEntry>, now: number, timeoutMs: number) => ReadonlyArray<PresenceEntry>;
9
33
 
@@ -13,6 +37,47 @@ export declare const isOnline: (lastSeen: number, now: number, timeoutMs: number
13
37
  /** In-memory presence store (tests + single-process dev). */
14
38
  export declare const memoryPresenceStore: () => PresenceStore;
15
39
 
40
+ /**
41
+ * Its own channel, for the same reason events are not on the change channel —
42
+ * and namespaced by the framework, which passes the resolved value in.
43
+ *
44
+ * This constant is the fallback for a single-app broker and for tests. It is NOT
45
+ * the normal path: a flat name is what let two apps on one Redis apply each
46
+ * other's presence deltas, and adding a member nobody is connected to is a
47
+ * member that can never leave — there is no owner to time out.
48
+ */
49
+ export declare const PRESENCE_BROADCAST_CHANNEL = "voltro:presence";
50
+
51
+ export declare interface PresenceBusHandle {
52
+ readonly tracker: PresenceTracker;
53
+ /**
54
+ * Whether a cross-instance transport is attached.
55
+ *
56
+ * Exposed so the plugin can WARN at boot. Without one every replica keeps a
57
+ * correct roster of its own clients and nothing errors — which is why the
58
+ * multi-replica failure looks exactly like success on a single box.
59
+ */
60
+ readonly hasTransport: boolean;
61
+ /** Broadcast one local change. Returns immediately; delivery is best-effort. */
62
+ readonly announce: (delta: PresenceDelta) => void;
63
+ readonly detach: () => void;
64
+ }
65
+
66
+ /** The wire shape of one change, broadcast by the owning instance. */
67
+ export declare type PresenceDelta = {
68
+ readonly op: 'set';
69
+ readonly channel: string;
70
+ readonly tenantId: string | null;
71
+ readonly key: string;
72
+ readonly meta: Record<string, unknown> | null;
73
+ readonly lastSeen: number;
74
+ } | {
75
+ readonly op: 'remove';
76
+ readonly channel: string;
77
+ readonly tenantId: string | null;
78
+ readonly key: string;
79
+ };
80
+
16
81
  export declare interface PresenceEntry {
17
82
  readonly channel: string;
18
83
  /** Stable presence key — the subject id, or a client-supplied id for anon. */
@@ -36,12 +101,36 @@ declare const PresenceKeyMissing_base: Schema.TaggedErrorClass<PresenceKeyMissin
36
101
  channel: typeof Schema.String;
37
102
  }>;
38
103
 
104
+ export declare interface PresenceMember {
105
+ /** Stable per-client key — the subject id, or a client-supplied one. */
106
+ readonly key: string;
107
+ readonly meta: Record<string, unknown> | null;
108
+ /** The owning instance's clock when it last saw a heartbeat. Compared only
109
+ * against other timestamps from THAT owner. */
110
+ readonly lastSeen: number;
111
+ }
112
+
113
+ /** The one method of the membership registry this needs. */
114
+ export declare interface PresenceMembership {
115
+ readonly onChange: (listener: (event: {
116
+ readonly kind: 'joined' | 'left' | 'restarted';
117
+ readonly instanceId: string;
118
+ }) => void) => () => void;
119
+ }
120
+
39
121
  export declare const presencePlugin: (options?: PresencePluginOptions) => VoltroPlugin;
40
122
 
41
123
  export declare interface PresencePluginOptions {
42
124
  /** A member counts as online for this long after its last heartbeat. Default 30s. */
43
125
  readonly timeoutMs?: number;
44
- /** Sweep interval for stale rows. Default 60s. */
126
+ /**
127
+ * How often to sweep members whose heartbeat has gone stale.
128
+ *
129
+ * Defaults to a THIRD of `timeoutMs`, so a vanished member is gone within
130
+ * roughly 1.3x the online window. Set it explicitly only if you need that
131
+ * detection delay tightened or relaxed independently of the window itself —
132
+ * the default is the right relationship for almost every room.
133
+ */
45
134
  readonly sweepIntervalMs?: number;
46
135
  readonly name?: string;
47
136
  }
@@ -68,6 +157,28 @@ declare const PresenceStoreUnavailable_base: Schema.TaggedErrorClass<PresenceSto
68
157
  readonly _tag: Schema.tag<"PresenceStoreUnavailable">;
69
158
  }>;
70
159
 
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
+ */
71
182
  export declare const presenceTable: Table<"_voltro_presence", FieldDefinitions<{
72
183
  readonly id: ColumnBuilder<string, "id", boolean>;
73
184
  readonly channel: ColumnBuilder<string, "text", boolean>;
@@ -77,7 +188,134 @@ export declare const presenceTable: Table<"_voltro_presence", FieldDefinitions<{
77
188
  readonly lastSeen: ColumnBuilder<Date, "timestamp", true>;
78
189
  }>, true, "byPresenceChannel">;
79
190
 
191
+ /**
192
+ * The merged presence view this instance can see.
193
+ *
194
+ * Owner-partitioned: `owner → route → key → member`. Every write names its
195
+ * owner, so dropping a departed instance is deleting one branch rather than
196
+ * scanning for its entries — which matters when a pod dies holding a thousand
197
+ * connections and the roster must settle in one operation, not a thousand.
198
+ */
199
+ export declare class PresenceTracker {
200
+ private readonly partitions;
201
+ private readonly selfId;
202
+ private readonly now;
203
+ constructor(options: {
204
+ readonly instanceId: string;
205
+ readonly now?: () => number;
206
+ });
207
+ /** This instance's id — the owner stamped on everything it tracks. */
208
+ get owner(): string;
209
+ private branch;
210
+ /**
211
+ * Record a heartbeat from a client THIS instance holds.
212
+ *
213
+ * Returns the delta to broadcast, so the caller cannot forget to — a local
214
+ * write that is not announced is a member the other replicas never see, and
215
+ * nothing would report it.
216
+ */
217
+ track(input: {
218
+ readonly channel: string;
219
+ readonly tenantId: string | null;
220
+ readonly key: string;
221
+ readonly meta?: Record<string, unknown> | null;
222
+ }): PresenceDelta;
223
+ /** A client of THIS instance left. */
224
+ untrack(input: {
225
+ readonly channel: string;
226
+ readonly tenantId: string | null;
227
+ readonly key: string;
228
+ }): PresenceDelta;
229
+ /**
230
+ * Apply a delta from ANOTHER instance.
231
+ *
232
+ * Our own owner id is ignored: the local write already happened synchronously
233
+ * in `track`, and re-applying the echo would be harmless today but would make
234
+ * the local map depend on the broker — so a broker outage would silently stop
235
+ * this instance from seeing its OWN clients.
236
+ */
237
+ apply(owner: string, delta: PresenceDelta): void;
238
+ /**
239
+ * An instance is gone — drop everything it owned, in one operation.
240
+ *
241
+ * This is the whole reason membership had to be built. Its clients are not
242
+ * connected to anything any more, and no message will ever say so: a crashing
243
+ * process does not send a goodbye, and on the event channel it is simply
244
+ * quiet. Without this the roster keeps showing them forever.
245
+ */
246
+ dropOwner(owner: string): number;
247
+ /**
248
+ * Drop OUR OWN members that have stopped heartbeating, and return the deltas
249
+ * to broadcast.
250
+ *
251
+ * The comment on `roster` used to argue that no staleness filter was needed
252
+ * because "an entry here leaves when its client does". That premise was
253
+ * false: an entry left only when the client explicitly CALLED leave. A closed
254
+ * laptop, a dropped network or a crashed tab call nothing, and the owning
255
+ * replica is still alive so `dropOwner` never fires either — so those members
256
+ * stayed in the roster forever and every screen showed people who had gone.
257
+ *
258
+ * ONLY our own partition. Another owner's entries carry timestamps from THEIR
259
+ * clock, and judging them against ours is precisely the mistake instance
260
+ * membership exists to avoid: a peer that is gone is dropped whole, on a
261
+ * signal, not on a guess about clock skew. That asymmetry is the design —
262
+ * every entry is owned by exactly one instance, and only its owner can say
263
+ * whether it is still there.
264
+ *
265
+ * Returns the removals rather than being void for the same reason `track`
266
+ * does: a local removal nobody broadcasts is a member every OTHER replica
267
+ * keeps showing.
268
+ */
269
+ sweep(timeoutMs: number): ReadonlyArray<PresenceDelta>;
270
+ /** Everything this instance owns — what a newly-seen peer must be told. */
271
+ ownSnapshot(): ReadonlyArray<PresenceDelta>;
272
+ /**
273
+ * The roster for one channel, in one tenant.
274
+ *
275
+ * TENANT-SCOPED AND FAIL-CLOSED, exactly as the table version was: a caller
276
+ * only ever sees members whose tenant equals theirs, and an anonymous caller
277
+ * (`null`) sees only anonymous members. A same-named channel in another
278
+ * tenant is invisible, not merged.
279
+ *
280
+ * No staleness filter IN THE READ — the sweep does it on the write side
281
+ * instead, and the distinction matters.
282
+ *
283
+ * This comment used to argue that no staleness handling was needed at all,
284
+ * because "an entry is removed when the client leaves". That was false: an
285
+ * entry left only when a client explicitly CALLED leave. A closed laptop, a
286
+ * dropped network or a crashed tab call nothing, and the owning replica is
287
+ * still alive so `dropOwner` never fires either — so those members stayed in
288
+ * the roster forever and every screen showed people who had gone home.
289
+ *
290
+ * `sweep()` removes them on the OWNER's side, on a timer, and announces the
291
+ * removals. Reading stays filter-free because by the time a read happens the
292
+ * stale entries are already gone — which is the property the original comment
293
+ * claimed and did not have.
294
+ */
295
+ roster(channel: string, tenantId: string | null): ReadonlyArray<TrackedMember>;
296
+ /** Owners currently holding at least one member — for inspect. */
297
+ owners(): ReadonlyArray<{
298
+ readonly owner: string;
299
+ readonly members: number;
300
+ }>;
301
+ /** Total tracked members across every owner and channel. */
302
+ size(): number;
303
+ }
304
+
305
+ /** The two methods of a broadcast provider this needs. */
306
+ export declare interface PresenceTransport {
307
+ readonly publish: (channel: string, payload: string) => Effect.Effect<unknown, unknown>;
308
+ readonly subscribe: (channel: string, handler: (payload: string) => void) => Effect.Effect<() => void, unknown>;
309
+ }
310
+
80
311
  /** Keys that have gone stale (eligible for the sweep). */
81
312
  export declare const staleKeys: (entries: ReadonlyArray<PresenceEntry>, now: number, timeoutMs: number) => ReadonlyArray<PresenceEntry>;
82
313
 
314
+ /** One member as the roster reports it, with the owner it belongs to. */
315
+ export declare interface TrackedMember extends PresenceMember {
316
+ readonly owner: string;
317
+ readonly channel: string;
318
+ readonly tenantId: string | null;
319
+ }
320
+
83
321
  export { }
package/dist/index.js CHANGED
@@ -1,10 +1,178 @@
1
1
  import { PresenceKeyMissing as e, PresenceStoreUnavailable as t } from "./errors.js";
2
2
  import { heartbeatDescriptor as n, leaveDescriptor as r, listDescriptor as i, presenceRpcClientImports as a } from "./rpc.js";
3
3
  import { Effect as o } from "effect";
4
- import { and as s, eq as c, id as l, json as u, lt as d, queryFor as f, table as p, text as m, timestamp as h } from "@voltro/database";
5
- import { definePlugin as g, tsMs as _ } from "@voltro/protocol";
6
- //#region src/presence.ts
7
- var v = (e, t, n) => t - e < n, y = (e, t, n) => e.filter((e) => v(e.lastSeen, t, n)).sort((e, t) => t.lastSeen - e.lastSeen), b = (e, t, n) => e.filter((e) => !v(e.lastSeen, t, n)), x = () => {
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";
6
+ //#region src/tracker.ts
7
+ var p = (e, t) => `${e ?? "~"}::${t}`, m = class {
8
+ partitions = /* @__PURE__ */ new Map();
9
+ selfId;
10
+ now;
11
+ constructor(e) {
12
+ this.selfId = e.instanceId, this.now = e.now ?? Date.now;
13
+ }
14
+ get owner() {
15
+ return this.selfId;
16
+ }
17
+ branch(e, t, n) {
18
+ let r = this.partitions.get(e);
19
+ r === void 0 && (r = /* @__PURE__ */ new Map(), this.partitions.set(e, r));
20
+ let i = p(t, n), a = r.get(i);
21
+ return a === void 0 && (a = {
22
+ tenantId: t,
23
+ channel: n,
24
+ members: /* @__PURE__ */ new Map()
25
+ }, r.set(i, a)), a;
26
+ }
27
+ 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, {
30
+ key: e.key,
31
+ meta: n,
32
+ lastSeen: t
33
+ }), {
34
+ op: "set",
35
+ channel: e.channel,
36
+ tenantId: e.tenantId,
37
+ key: e.key,
38
+ meta: n,
39
+ lastSeen: t
40
+ };
41
+ }
42
+ 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
48
+ };
49
+ }
50
+ apply(e, t) {
51
+ if (e === this.selfId) return;
52
+ 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, {
58
+ key: t.key,
59
+ meta: t.meta,
60
+ lastSeen: t.lastSeen
61
+ });
62
+ }
63
+ dropOwner(e) {
64
+ if (e === this.selfId) return 0;
65
+ let t = this.partitions.get(e);
66
+ if (t === void 0) return 0;
67
+ let n = 0;
68
+ for (let e of t.values()) n += e.members.size;
69
+ return this.partitions.delete(e), n;
70
+ }
71
+ sweep(e) {
72
+ let t = this.partitions.get(this.selfId);
73
+ if (t === void 0) return [];
74
+ let n = this.now() - e, r = [];
75
+ for (let e of t.values()) for (let [t, i] of e.members) i.lastSeen >= n || (e.members.delete(t), r.push({
76
+ op: "remove",
77
+ channel: e.channel,
78
+ tenantId: e.tenantId,
79
+ key: t
80
+ }));
81
+ return r;
82
+ }
83
+ ownSnapshot() {
84
+ let e = [], t = this.partitions.get(this.selfId);
85
+ if (t === void 0) return e;
86
+ for (let n of t.values()) for (let t of n.members.values()) e.push({
87
+ op: "set",
88
+ channel: n.channel,
89
+ tenantId: n.tenantId,
90
+ key: t.key,
91
+ meta: t.meta,
92
+ lastSeen: t.lastSeen
93
+ });
94
+ return e;
95
+ }
96
+ roster(e, t) {
97
+ let n = p(t, e), r = [];
98
+ for (let [i, a] of this.partitions) {
99
+ let o = a.get(n);
100
+ if (o !== void 0) for (let n of o.members.values()) r.push({
101
+ ...n,
102
+ owner: i,
103
+ channel: e,
104
+ tenantId: t
105
+ });
106
+ }
107
+ return r.sort((e, t) => t.lastSeen - e.lastSeen);
108
+ }
109
+ owners() {
110
+ return [...this.partitions.entries()].map(([e, t]) => {
111
+ let n = 0;
112
+ for (let e of t.values()) n += e.members.size;
113
+ return {
114
+ owner: e,
115
+ members: n
116
+ };
117
+ });
118
+ }
119
+ size() {
120
+ return this.owners().reduce((e, t) => e + t.members, 0);
121
+ }
122
+ }, h = "voltro:presence", g = (e) => typeof e == "object" && !!e && typeof e.owner == "string" && typeof e.delta == "object" && e.delta !== null, _ = (e) => typeof e == "object" && !!e && typeof e.owner == "string" && Array.isArray(e.sync), v = (e) => {
123
+ let t = new m({
124
+ instanceId: e.instanceId,
125
+ ...e.now === void 0 ? {} : { now: e.now }
126
+ }), n = e.logger, r = e.membership.onChange((e) => {
127
+ if (e.kind === "joined") {
128
+ c();
129
+ return;
130
+ }
131
+ let r = t.dropOwner(e.instanceId);
132
+ r > 0 && n?.warn("presence: dropped members of a departed instance", {
133
+ instanceId: e.instanceId,
134
+ members: r,
135
+ 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 = () => {
140
+ let n = t.ownSnapshot();
141
+ n.length !== 0 && s({
142
+ owner: e.instanceId,
143
+ sync: n
144
+ });
145
+ }, l;
146
+ return i !== void 0 && o.runPromise(i.subscribe(a, (e) => {
147
+ let n;
148
+ try {
149
+ n = JSON.parse(e);
150
+ } catch {
151
+ return;
152
+ }
153
+ if (_(n)) {
154
+ for (let e of n.sync) t.apply(n.owner, e);
155
+ return;
156
+ }
157
+ g(n) && t.apply(n.owner, n.delta);
158
+ })).then((e) => {
159
+ l = e;
160
+ }, (e) => {
161
+ n?.warn("presence: could not subscribe — this instance will only see its own members", { cause: e });
162
+ }), {
163
+ tracker: t,
164
+ hasTransport: i !== void 0,
165
+ announce: (t) => {
166
+ s({
167
+ owner: e.instanceId,
168
+ delta: t
169
+ });
170
+ },
171
+ detach: () => {
172
+ r(), l?.();
173
+ }
174
+ };
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 = () => {
8
176
  let e = /* @__PURE__ */ new Map(), t = (e, t) => `${e}|${t}`;
9
177
  return {
10
178
  upsert: async (n) => {
@@ -20,70 +188,53 @@ var v = (e, t, n) => t - e < n, y = (e, t, n) => e.filter((e) => v(e.lastSeen, t
20
188
  return n;
21
189
  }
22
190
  };
23
- }, S = "_voltro_presence", C = p(S, {
24
- id: l({ prefix: "pres" }),
25
- channel: m(),
26
- key: m(),
27
- tenantId: m().nullable(),
28
- meta: u().nullable(),
29
- lastSeen: h().default("now")
30
- }).unique(["channel", "key"]).index("byPresenceChannel", ["channel", "lastSeen"]), w = (e) => e instanceof Date ? e : new Date(e), T = (e) => {
31
- let t = (t) => e.query(f(C).where(t).descriptor), n = (e) => ({
32
- channel: String(e.channel),
33
- key: String(e.key),
34
- tenantId: e.tenantId ?? null,
35
- meta: e.meta ?? null,
36
- lastSeen: _(e.lastSeen)
37
- });
38
- return {
39
- upsert: async (t) => {
40
- let n = {
41
- channel: t.channel,
42
- key: t.key,
43
- tenantId: t.tenantId,
44
- meta: t.meta,
45
- lastSeen: w(t.lastSeen)
46
- };
47
- await e.upsert(S, n, {
48
- conflictColumns: ["channel", "key"],
49
- update: [
50
- "tenantId",
51
- "meta",
52
- "lastSeen"
53
- ]
54
- });
55
- },
56
- list: async (e, r) => (await t(c("channel", e))).map(n).filter((e) => e.tenantId === r),
57
- remove: async (n, r) => {
58
- let i = await t(s(c("channel", n), c("key", r)));
59
- for (let t of i) await e.delete(S, String(t.id));
60
- },
61
- sweep: async (t) => e.deleteMany(S, { where: d("lastSeen", w(t)) })
62
- };
63
- }, E = (s = {}) => {
64
- let c = s.timeoutMs ?? 3e4, l = s.name ? `@voltro/plugin-presence#${s.name}` : "@voltro/plugin-presence", u, d, f = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, p = (e) => e.request.subject?.tenantId ?? null, m = [
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
+ let c = s.timeoutMs ?? 3e4, l = Math.max(1e3, Math.floor(s.sweepIntervalMs ?? c / 3)), u = s.name ? `@voltro/plugin-presence#${s.name}` : "@voltro/plugin-presence", d, p, m, h = () => {
200
+ m !== void 0 || d === void 0 || (m = setInterval(() => {
201
+ let e = d;
202
+ if (e === void 0) return;
203
+ let t = e.tracker.sweep(c);
204
+ if (t.length !== 0) {
205
+ for (let n of t) e.announce(n);
206
+ y();
207
+ }
208
+ }, l), m.unref?.());
209
+ }, g = (e, t) => typeof t == "string" && t.length > 0 ? t : e.request.subject?.id ?? null, _ = (e) => e.request.subject?.tenantId ?? null, y = () => {
210
+ p?.injectExternalChange?.({
211
+ table: C,
212
+ op: "update",
213
+ new: {},
214
+ old: {}
215
+ });
216
+ }, b = [
65
217
  {
66
218
  ...n,
67
219
  description: "Register/refresh the caller's presence in a channel.",
68
220
  execute: (n, r) => o.gen(function* () {
69
221
  let i = n;
70
- if (!u) return yield* o.fail(new t({}));
71
- let a = f(r, i.key);
72
- return a === null ? yield* o.fail(new e({ channel: i.channel })) : (yield* o.promise(() => u.upsert({
222
+ if (!d) return yield* o.fail(new t({}));
223
+ let a = g(r, i.key);
224
+ return a === null ? yield* o.fail(new e({ channel: i.channel })) : (d.announce(d.tracker.track({
73
225
  channel: i.channel,
226
+ tenantId: _(r),
74
227
  key: a,
75
- tenantId: p(r),
76
- meta: i.meta ?? null,
77
- lastSeen: Date.now()
78
- })), { ok: !0 });
228
+ ...i.meta === void 0 ? {} : { meta: i.meta }
229
+ })), y(), { ok: !0 });
79
230
  })
80
231
  },
81
232
  {
82
233
  ...i,
83
234
  description: "The online roster of a channel (heartbeat-fresh members). Push-driven — updates live.",
84
- source: S,
235
+ source: C,
85
236
  execute: (e, n) => o.gen(function* () {
86
- return u ? y(yield* o.promise(() => u.list(e.channel, p(n))), Date.now(), c).map((e) => ({
237
+ return d ? d.tracker.roster(e.channel, _(n)).map((e) => ({
87
238
  key: e.key,
88
239
  meta: e.meta,
89
240
  lastSeen: e.lastSeen
@@ -95,33 +246,46 @@ var v = (e, t, n) => t - e < n, y = (e, t, n) => e.filter((e) => v(e.lastSeen, t
95
246
  description: "Remove the caller's presence from a channel.",
96
247
  execute: (n, r) => o.gen(function* () {
97
248
  let i = n;
98
- if (!u) return yield* o.fail(new t({}));
99
- let a = f(r, i.key);
100
- return a === null ? yield* o.fail(new e({ channel: i.channel })) : (yield* o.promise(() => u.remove(i.channel, a)), { ok: !0 });
249
+ if (!d) return yield* o.fail(new t({}));
250
+ let a = g(r, i.key);
251
+ return a === null ? yield* o.fail(new e({ channel: i.channel })) : (d.announce(d.tracker.untrack({
252
+ channel: i.channel,
253
+ tenantId: _(r),
254
+ key: a
255
+ })), y(), { ok: !0 });
101
256
  })
102
257
  }
103
258
  ];
104
- return g({
105
- name: l,
259
+ return f({
260
+ name: u,
106
261
  description: "Ephemeral realtime presence — heartbeat roster per channel.",
107
262
  permissions: ["store:write"],
108
- extendSchema: { tables: [C] },
109
- routes: m,
263
+ extendSchema: { tables: [w] },
264
+ routes: b,
110
265
  rpcClientDescriptors: a,
111
266
  bindDataStore: (e, t) => {
112
- u = T(e);
113
- let n = s.sweepIntervalMs ?? 6e4;
114
- d = t?.scheduleCoordinated("presence.sweep", n, async () => {
115
- await u?.sweep(Date.now() - c * 2);
267
+ p = e;
268
+ let n = t;
269
+ d = v({
270
+ instanceId: n?.instanceId ?? `presence-${process.pid}`,
271
+ membership: n?.membership ?? { onChange: () => () => {} },
272
+ ...n?.broadcast === void 0 ? {} : { provider: n.broadcast },
273
+ ...n?.broadcastChannels?.presence === void 0 ? {} : { channel: n.broadcastChannels.presence }
116
274
  });
117
275
  },
118
276
  onActivate: (e) => o.sync(() => {
119
- e.logger.info("presence active", { timeoutMs: c });
277
+ h();
278
+ let t = d?.hasTransport === !0;
279
+ e.logger.info("presence active", {
280
+ timeoutMs: c,
281
+ sweepEveryMs: l,
282
+ crossInstance: t
283
+ }), 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.");
120
284
  }),
121
285
  onDeactivate: () => o.sync(() => {
122
- d?.stop(), d = void 0;
286
+ m !== void 0 && (clearInterval(m), m = void 0), d?.detach(), d = void 0, p = void 0;
123
287
  })
124
288
  });
125
289
  };
126
290
  //#endregion
127
- export { e as PresenceKeyMissing, t as PresenceStoreUnavailable, y as filterOnline, v as isOnline, x as memoryPresenceStore, E as presencePlugin, C as presenceTable, b as staleKeys };
291
+ 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-presence",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "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
5
  "keywords": [
6
6
  "voltro",
@@ -47,9 +47,9 @@
47
47
  "node": ">=24.0.0"
48
48
  },
49
49
  "dependencies": {
50
- "@voltro/client": "0.24.0",
51
- "@voltro/database": "0.24.0",
52
- "@voltro/protocol": "0.24.0"
50
+ "@voltro/client": "0.26.0",
51
+ "@voltro/database": "0.26.0",
52
+ "@voltro/protocol": "0.26.0"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "effect": "^3.22.0",