@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/CHANGELOG.md +670 -0
- package/dist/index.d.ts +239 -1
- package/dist/index.js +233 -69
- package/package.json +4 -4
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
|
-
/**
|
|
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 {
|
|
5
|
-
import { definePlugin as
|
|
6
|
-
//#region src/
|
|
7
|
-
var
|
|
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
|
-
},
|
|
24
|
-
id:
|
|
25
|
-
channel:
|
|
26
|
-
key:
|
|
27
|
-
tenantId:
|
|
28
|
-
meta:
|
|
29
|
-
lastSeen:
|
|
30
|
-
}).unique(["channel", "key"]).index("byPresenceChannel", ["channel", "lastSeen"]),
|
|
31
|
-
let
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
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 (!
|
|
71
|
-
let a =
|
|
72
|
-
return a === null ? yield* o.fail(new e({ channel: i.channel })) : (
|
|
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
|
-
|
|
76
|
-
|
|
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:
|
|
235
|
+
source: C,
|
|
85
236
|
execute: (e, n) => o.gen(function* () {
|
|
86
|
-
return
|
|
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 (!
|
|
99
|
-
let a =
|
|
100
|
-
return a === null ? yield* o.fail(new e({ channel: i.channel })) : (
|
|
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
|
|
105
|
-
name:
|
|
259
|
+
return f({
|
|
260
|
+
name: u,
|
|
106
261
|
description: "Ephemeral realtime presence — heartbeat roster per channel.",
|
|
107
262
|
permissions: ["store:write"],
|
|
108
|
-
extendSchema: { tables: [
|
|
109
|
-
routes:
|
|
263
|
+
extendSchema: { tables: [w] },
|
|
264
|
+
routes: b,
|
|
110
265
|
rpcClientDescriptors: a,
|
|
111
266
|
bindDataStore: (e, t) => {
|
|
112
|
-
|
|
113
|
-
let n =
|
|
114
|
-
d =
|
|
115
|
-
|
|
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
|
-
|
|
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?.
|
|
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,
|
|
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.
|
|
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.
|
|
51
|
-
"@voltro/database": "0.
|
|
52
|
-
"@voltro/protocol": "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",
|