@ultimat3/realtime 1.0.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/package.json +36 -0
  4. package/src/change-buffer.ts +69 -0
  5. package/src/changefeed-env.ts +146 -0
  6. package/src/changefeed.ts +191 -0
  7. package/src/channel.ts +181 -0
  8. package/src/client.ts +439 -0
  9. package/src/cursor.ts +188 -0
  10. package/src/errors.ts +253 -0
  11. package/src/fanout.ts +159 -0
  12. package/src/hooks.ts +230 -0
  13. package/src/index.ts +328 -0
  14. package/src/json.ts +76 -0
  15. package/src/live-definition.ts +144 -0
  16. package/src/live-query.ts +449 -0
  17. package/src/local-store.ts +188 -0
  18. package/src/matcher-bridge.ts +169 -0
  19. package/src/nats-commands.ts +97 -0
  20. package/src/nats-connection-fixture.ts +105 -0
  21. package/src/nats-connection.ts +464 -0
  22. package/src/nats-fake.ts +431 -0
  23. package/src/nats-jetstream.ts +226 -0
  24. package/src/nats-kv.ts +157 -0
  25. package/src/nats-protocol.ts +222 -0
  26. package/src/nats-socket.ts +236 -0
  27. package/src/nats-transport.ts +257 -0
  28. package/src/offline-queue.ts +206 -0
  29. package/src/pg-advisory-lock.ts +98 -0
  30. package/src/pg-auth.ts +300 -0
  31. package/src/pg-bytes.ts +185 -0
  32. package/src/pg-connection-fixture.ts +215 -0
  33. package/src/pg-connection.ts +337 -0
  34. package/src/pg-entity-row.ts +130 -0
  35. package/src/pg-replication-fixture.ts +261 -0
  36. package/src/pg-replication.ts +396 -0
  37. package/src/pg-socket.ts +265 -0
  38. package/src/pg-wire.ts +192 -0
  39. package/src/pgoutput.ts +297 -0
  40. package/src/policy-gate.ts +56 -0
  41. package/src/presence.ts +219 -0
  42. package/src/rebase.ts +198 -0
  43. package/src/replicator.ts +185 -0
  44. package/src/socket.ts +208 -0
  45. package/src/sync-node.ts +400 -0
  46. package/src/sync-protocol.ts +376 -0
  47. package/src/thundering-herd.ts +141 -0
  48. package/src/transport-env.ts +104 -0
@@ -0,0 +1,297 @@
1
+ // Decodes pgoutput logical-replication messages (protocol version 1, Postgres >= 12) into typed
2
+ // PgOutputMessage values, and the postgres text-format values inside each tuple into JsonValue.
3
+ // Pure byte decoding: no sockets, no I/O. A decoder instance owns the per-connection relation
4
+ // cache that later Insert/Update/Delete/Truncate messages reference by oid.
5
+
6
+ import { ReplicationProtocolError } from './errors';
7
+ import type { JsonObject, JsonValue } from './json';
8
+ import { ByteReader, pgTimestampToEpochMs } from './pg-bytes';
9
+
10
+ export interface PgColumn {
11
+ /** part of the replica identity key — set by the `flags & 1` bit. */
12
+ readonly key: boolean;
13
+ /** the physical, snake_case column name as postgres reports it. */
14
+ readonly name: string;
15
+ readonly typeOid: number;
16
+ readonly typeMod: number;
17
+ }
18
+
19
+ export interface PgRelation {
20
+ readonly oid: number;
21
+ readonly schema: string;
22
+ readonly name: string;
23
+ /** 'd' default | 'n' nothing | 'f' full | 'i' index */
24
+ readonly replicaIdentity: string;
25
+ readonly columns: readonly PgColumn[];
26
+ }
27
+
28
+ export type PgOutputMessage =
29
+ | {
30
+ readonly kind: 'begin';
31
+ readonly commitLsn: bigint;
32
+ readonly commitAt: number;
33
+ readonly xid: number;
34
+ }
35
+ | {
36
+ readonly kind: 'commit';
37
+ readonly commitLsn: bigint;
38
+ readonly endLsn: bigint;
39
+ readonly commitAt: number;
40
+ }
41
+ | { readonly kind: 'relation'; readonly relation: PgRelation }
42
+ | { readonly kind: 'insert'; readonly relation: PgRelation; readonly after: JsonObject }
43
+ | {
44
+ readonly kind: 'update';
45
+ readonly relation: PgRelation;
46
+ readonly before: JsonObject | null;
47
+ readonly after: JsonObject;
48
+ }
49
+ | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: JsonObject }
50
+ | { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
51
+ /** origin / type / logical message — decoded far enough to be skipped safely. */
52
+ | { readonly kind: 'other'; readonly tag: string };
53
+
54
+ /**
55
+ * Postgres sends every value as text (we never negotiate binary). Decoding depends on the
56
+ * column's type oid — the wire gives us nothing else to go on, so this switch is the one place
57
+ * that type catalogue is encoded.
58
+ */
59
+ function decodeValue(typeOid: number, text: string): JsonValue {
60
+ switch (typeOid) {
61
+ case 16: // bool
62
+ return text === 't';
63
+
64
+ case 20: {
65
+ // int8: only safe as a number if it round-trips exactly; otherwise keep the digits —
66
+ // a rounded bigint is a worse lie than a string that still parses correctly downstream.
67
+ const asNumber = Number(text);
68
+ return Number.isSafeInteger(asNumber) ? asNumber : text;
69
+ }
70
+
71
+ case 21: // int2
72
+ case 23: // int4
73
+ case 26: // oid
74
+ return Number(text);
75
+
76
+ case 700: // float4
77
+ case 701: // float8
78
+ // JSON has no literal for these three, so the text form survives the round trip instead of
79
+ // silently becoming a number `JSON.stringify` would otherwise turn into `null`.
80
+ if (text === 'NaN' || text === 'Infinity' || text === '-Infinity') return text;
81
+ return Number(text);
82
+
83
+ case 1700: // numeric — exactness beats convenience; money is never a float here.
84
+ return text;
85
+
86
+ case 114: // json
87
+ case 3802: {
88
+ // jsonb
89
+ let parsed: unknown;
90
+ try {
91
+ parsed = JSON.parse(text);
92
+ } catch (cause) {
93
+ throw new ReplicationProtocolError({
94
+ stage: 'value',
95
+ detail: `type oid ${typeOid} carried invalid json: ${String(cause)}`,
96
+ });
97
+ }
98
+ return parsed as JsonValue;
99
+ }
100
+
101
+ case 1082: // date
102
+ case 1114: // timestamp
103
+ case 1184: // timestamptz
104
+ return text; // an ISO-ish string; never a `Date` — the row must stay JSON.
105
+
106
+ case 17: // bytea — the `\x...` text form, as-is.
107
+ return text;
108
+
109
+ default: // text, varchar, uuid, enum, and everything else not called out above.
110
+ return text;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Int16 ncolumns + that many columns. Every tuple kind (insert's new row, update/delete's old
116
+ * row, a `'K'` key-only row) shares this decoder: postgres always sends one byte per column,
117
+ * `'u'` standing in for the columns a key-only tuple leaves out — so the column count always
118
+ * matches the relation, and only the per-column byte tells us whether a value is actually there.
119
+ */
120
+ function decodeTupleData(reader: ByteReader, relation: PgRelation): JsonObject {
121
+ const count = reader.int16();
122
+ if (count !== relation.columns.length) {
123
+ throw new ReplicationProtocolError({
124
+ stage: 'tuple',
125
+ detail:
126
+ `relation "${relation.schema}.${relation.name}" has ${relation.columns.length} columns ` +
127
+ `but a tuple for it carried ${count}`,
128
+ });
129
+ }
130
+
131
+ const row: JsonObject = {};
132
+ for (const column of relation.columns) {
133
+ const kind = reader.tag();
134
+ if (kind === 'n') {
135
+ row[column.name] = null;
136
+ continue;
137
+ }
138
+ if (kind === 'u') {
139
+ // Omitted, not nulled: "unchanged" and "set to null" are different facts about the row,
140
+ // and a missing key is the only encoding that keeps those two facts distinguishable.
141
+ continue;
142
+ }
143
+ if (kind === 't') {
144
+ const length = reader.int32();
145
+ row[column.name] = decodeValue(column.typeOid, reader.utf8(length));
146
+ continue;
147
+ }
148
+ if (kind === 'b') {
149
+ throw new ReplicationProtocolError({
150
+ stage: 'tuple',
151
+ detail: `column "${column.name}" arrived as binary; this decoder only requests text-format values`,
152
+ });
153
+ }
154
+ throw new ReplicationProtocolError({
155
+ stage: 'tuple',
156
+ detail: `column "${column.name}" has an unrecognised tuple kind "${kind}"`,
157
+ });
158
+ }
159
+ return row;
160
+ }
161
+
162
+ /**
163
+ * Holds the relation cache: postgres sends a `Relation` message once per table per connection and
164
+ * every later tuple references it by oid, so a decoder instance is per-connection and is thrown
165
+ * away with it.
166
+ */
167
+ export class PgOutputDecoder {
168
+ readonly #relations = new Map<number, PgRelation>();
169
+
170
+ decode(payload: Uint8Array): PgOutputMessage {
171
+ const reader = new ByteReader(payload, 'pgoutput');
172
+ const tag = reader.tag();
173
+ switch (tag) {
174
+ case 'B':
175
+ return this.#decodeBegin(reader);
176
+ case 'C':
177
+ return this.#decodeCommit(reader);
178
+ case 'R':
179
+ return this.#decodeRelation(reader);
180
+ case 'I':
181
+ return this.#decodeInsert(reader);
182
+ case 'U':
183
+ return this.#decodeUpdate(reader);
184
+ case 'D':
185
+ return this.#decodeDelete(reader);
186
+ case 'T':
187
+ return this.#decodeTruncate(reader);
188
+ // 'O' (origin), 'Y' (type), 'M' (logical message), and any tag a newer server invents:
189
+ // nothing downstream needs them decoded, and guessing at an unknown tag's shape is how a
190
+ // truncated read turns into a silent misread instead of a clean skip.
191
+ default:
192
+ return { kind: 'other', tag };
193
+ }
194
+ }
195
+
196
+ /** The relation behind an oid, for a caller that needs it after the fact. */
197
+ relation(oid: number): PgRelation | undefined {
198
+ return this.#relations.get(oid);
199
+ }
200
+
201
+ #relationOrThrow(oid: number): PgRelation {
202
+ const relation = this.#relations.get(oid);
203
+ if (relation === undefined) {
204
+ throw new ReplicationProtocolError({
205
+ stage: 'tuple',
206
+ detail: `no Relation message has been seen yet for oid ${oid}`,
207
+ });
208
+ }
209
+ return relation;
210
+ }
211
+
212
+ #decodeBegin(reader: ByteReader): PgOutputMessage {
213
+ const commitLsn = reader.int64();
214
+ const commitAt = pgTimestampToEpochMs(reader.int64());
215
+ const xid = reader.int32();
216
+ return { kind: 'begin', commitLsn, commitAt, xid };
217
+ }
218
+
219
+ #decodeCommit(reader: ByteReader): PgOutputMessage {
220
+ reader.uint8(); // flags: reserved by the protocol, unused today.
221
+ const commitLsn = reader.int64();
222
+ const endLsn = reader.int64();
223
+ const commitAt = pgTimestampToEpochMs(reader.int64());
224
+ return { kind: 'commit', commitLsn, endLsn, commitAt };
225
+ }
226
+
227
+ #decodeRelation(reader: ByteReader): PgOutputMessage {
228
+ const oid = reader.int32();
229
+ const schema = reader.cstring();
230
+ const name = reader.cstring();
231
+ const replicaIdentity = reader.tag();
232
+ const columnCount = reader.int16();
233
+ const columns: PgColumn[] = [];
234
+ for (let i = 0; i < columnCount; i += 1) {
235
+ const flags = reader.uint8();
236
+ const columnName = reader.cstring();
237
+ const typeOid = reader.int32();
238
+ const typeMod = reader.int32();
239
+ columns.push({ key: (flags & 1) === 1, name: columnName, typeOid, typeMod });
240
+ }
241
+ // Always overwrites: postgres re-sends a Relation after a DDL change, and a stale column
242
+ // list would silently mis-name every later value decoded against this oid.
243
+ const relation: PgRelation = { oid, schema, name, replicaIdentity, columns };
244
+ this.#relations.set(oid, relation);
245
+ return { kind: 'relation', relation };
246
+ }
247
+
248
+ #decodeInsert(reader: ByteReader): PgOutputMessage {
249
+ const relation = this.#relationOrThrow(reader.int32());
250
+ reader.tag(); // always 'N' — a new row has no other kind.
251
+ const after = decodeTupleData(reader, relation);
252
+ return { kind: 'insert', relation, after };
253
+ }
254
+
255
+ #decodeUpdate(reader: ByteReader): PgOutputMessage {
256
+ const relation = this.#relationOrThrow(reader.int32());
257
+ let marker = reader.tag();
258
+ let before: JsonObject | null = null;
259
+ if (marker === 'K' || marker === 'O') {
260
+ before = decodeTupleData(reader, relation);
261
+ marker = reader.tag();
262
+ }
263
+ if (marker !== 'N') {
264
+ throw new ReplicationProtocolError({
265
+ stage: 'update',
266
+ detail: `expected the new-tuple marker "N" but got "${marker}"`,
267
+ });
268
+ }
269
+ const after = decodeTupleData(reader, relation);
270
+ return { kind: 'update', relation, before, after };
271
+ }
272
+
273
+ #decodeDelete(reader: ByteReader): PgOutputMessage {
274
+ const relation = this.#relationOrThrow(reader.int32());
275
+ const marker = reader.tag();
276
+ if (marker !== 'K' && marker !== 'O') {
277
+ throw new ReplicationProtocolError({
278
+ stage: 'delete',
279
+ detail: `expected the old-tuple marker "K" or "O" but got "${marker}"`,
280
+ });
281
+ }
282
+ const before = decodeTupleData(reader, relation);
283
+ return { kind: 'delete', relation, before };
284
+ }
285
+
286
+ #decodeTruncate(reader: ByteReader): PgOutputMessage {
287
+ const count = reader.int32();
288
+ reader.uint8(); // flags: CASCADE / RESTART IDENTITY bits — advisory, not modelled downstream.
289
+ const relations: PgRelation[] = [];
290
+ for (let i = 0; i < count; i += 1) {
291
+ const relation = this.#relations.get(reader.int32());
292
+ // An oid with no cached Relation is skipped, not fatal: truncate is advisory for us.
293
+ if (relation !== undefined) relations.push(relation);
294
+ }
295
+ return { kind: 'truncate', relations };
296
+ }
297
+ }
@@ -0,0 +1,56 @@
1
+ // The single seam between realtime and authz. It goes through `@ultimat3/query`'s `guard`, which
2
+ // is itself the only point of contact with `@ultimat3/policy` — one authz system, never two, and
3
+ // realtime does not get its own opinion about what a decision means.
4
+ //
5
+ // The row gate turns a denial into "not visible" instead of an error: a row that fails an actor's
6
+ // policy is dropped, never sent. That is the rule from the live-query pipeline, implemented once.
7
+
8
+ import type { Actor, Ctx } from '@ultimat3/core';
9
+ import { guard, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
10
+ import type { JsonValue, Row } from './json';
11
+
12
+ export interface GateOptions {
13
+ /** Query name, for the denial reason and the policy trace. */
14
+ readonly query: string;
15
+ readonly ctx: Ctx;
16
+ }
17
+
18
+ /** Subscribe-time gate for `LiveQueryDefinition.authorize`. Throws the policy's denial error. */
19
+ export function authorizeWithPolicy(
20
+ policy: QueryPolicy,
21
+ options: GateOptions,
22
+ ): (args: { actor: Actor | null; input: JsonValue }) => Promise<void> {
23
+ return async (args) => {
24
+ // No row exists yet at subscribe time; `null` says so rather than leaving the predicate
25
+ // to infer it from an absent field.
26
+ await guard(policy, subjectOf(options, args.actor, args.input, null), 'live');
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Row gate for `LiveQueryDefinition.visible`. Called once per subscriber per row — never once per
32
+ * query. The row travels as `row`, the same field an HTTP or job row rule reads: a predicate is
33
+ * written once as `({ actor, row }) => …` and works on every surface.
34
+ */
35
+ export function visibleWithPolicy<R extends Row = Row>(
36
+ policy: QueryPolicy,
37
+ options: GateOptions,
38
+ ): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
39
+ return async (args) => {
40
+ try {
41
+ await guard(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ };
47
+ }
48
+
49
+ function subjectOf(
50
+ options: GateOptions,
51
+ actor: Actor | null,
52
+ input: unknown,
53
+ row: unknown,
54
+ ): QuerySubject {
55
+ return { actor, input, row, ctx: options.ctx, query: options.query };
56
+ }
@@ -0,0 +1,219 @@
1
+ // Tier 1: presence. Who is here, where their cursor is, what they are typing.
2
+ //
3
+ // Presence lives in `transport.shared`, never in a node's heap: when a `sync` node dies its members
4
+ // simply stop heartbeating and expire, and every other node already sees the same set. Ephemeral
5
+ // state is never modelled as rows — that rule is what keeps presence off the write path entirely.
6
+
7
+ import { type Clock, systemClock } from '@ultimat3/core';
8
+ import type { ChannelHub, Topic } from './channel';
9
+ import type { Transport } from './fanout';
10
+ import type { JsonObject } from './json';
11
+ import { type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
12
+
13
+ export const PRESENCE_KEY_PREFIX = 'presence';
14
+
15
+ export interface PresenceOptions {
16
+ readonly transport: Transport;
17
+ /** Optional: without a hub, presence is queryable but silent (no join/leave frames). */
18
+ readonly hub?: ChannelHub;
19
+ readonly clock?: Clock;
20
+ /** Member TTL. Clients should heartbeat at ttl/3 so one lost beat is not a false leave. */
21
+ readonly ttlMs?: number;
22
+ }
23
+
24
+ export interface PresenceInput {
25
+ readonly id: string;
26
+ readonly actorId: string | null;
27
+ readonly meta?: JsonObject;
28
+ /** Logical time from the member. Ties and older writes are dropped — last write wins. */
29
+ readonly updatedAt?: number;
30
+ }
31
+
32
+ export class PresenceRegistry {
33
+ readonly #transport: Transport;
34
+ readonly #hub: ChannelHub | undefined;
35
+ readonly #clock: Clock;
36
+ readonly #ttlMs: number;
37
+ /** Diffing cache only — the truth is always `transport.shared`. Safe to lose. */
38
+ readonly #seen = new Map<string, Set<string>>();
39
+
40
+ constructor(options: PresenceOptions) {
41
+ this.#transport = options.transport;
42
+ this.#hub = options.hub;
43
+ this.#clock = options.clock ?? systemClock;
44
+ this.#ttlMs = options.ttlMs ?? 30_000;
45
+ }
46
+
47
+ get ttlMs(): number {
48
+ return this.#ttlMs;
49
+ }
50
+
51
+ /** Recommended client heartbeat interval: one lost beat must not read as a leave. */
52
+ get heartbeatMs(): number {
53
+ return Math.max(1_000, Math.floor(this.#ttlMs / 3));
54
+ }
55
+
56
+ async join(name: Topic, input: PresenceInput): Promise<readonly PresenceMember[]> {
57
+ const member: PresenceMember = {
58
+ id: input.id,
59
+ actorId: input.actorId,
60
+ meta: input.meta ?? {},
61
+ updatedAt: input.updatedAt ?? this.#clock.now().getTime(),
62
+ };
63
+ await this.#write(name, member);
64
+ this.#track(name).add(member.id);
65
+ await this.#emit(name, 'join', [member]);
66
+ return await this.list(name);
67
+ }
68
+
69
+ /** `false` means the member had already expired: the caller must `join` again, not `heartbeat`. */
70
+ async heartbeat(name: Topic, id: string): Promise<boolean> {
71
+ const alive = await this.#transport.shared.touch(this.#key(name), id, this.#ttlMs);
72
+ if (!alive) this.#track(name).delete(id);
73
+ return alive;
74
+ }
75
+
76
+ /** Last-write-wins per member: an out-of-order cursor update is dropped, never merged. */
77
+ async update(name: Topic, input: PresenceInput): Promise<PresenceMember | null> {
78
+ const current = await this.#find(name, input.id);
79
+ const updatedAt = input.updatedAt ?? this.#clock.now().getTime();
80
+ if (current && updatedAt <= current.updatedAt) return current;
81
+ const member: PresenceMember = {
82
+ id: input.id,
83
+ actorId: input.actorId,
84
+ meta: input.meta ?? {},
85
+ updatedAt,
86
+ };
87
+ await this.#write(name, member);
88
+ this.#track(name).add(member.id);
89
+ await this.#emit(name, current ? 'update' : 'join', [member]);
90
+ return member;
91
+ }
92
+
93
+ async leave(name: Topic, id: string): Promise<void> {
94
+ const current = await this.#find(name, id);
95
+ await this.#transport.shared.drop(this.#key(name), id);
96
+ this.#track(name).delete(id);
97
+ await this.#emit(
98
+ name,
99
+ 'leave',
100
+ current
101
+ ? [current]
102
+ : [{ id, actorId: null, meta: {}, updatedAt: this.#clock.now().getTime() }],
103
+ );
104
+ }
105
+
106
+ async list(name: Topic): Promise<readonly PresenceMember[]> {
107
+ const entries = await this.#transport.shared.entries(this.#key(name));
108
+ const members: PresenceMember[] = [];
109
+ for (const entry of entries) {
110
+ const member = parseMember(entry.member, entry.value);
111
+ if (member) members.push(member);
112
+ }
113
+ members.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
114
+ return members;
115
+ }
116
+
117
+ /** Turns TTL expiry into explicit `leave` frames. Called on an interval by the `sync` node. */
118
+ async sweep(name: Topic): Promise<readonly PresenceMember[]> {
119
+ const live = await this.list(name);
120
+ const liveIds = new Set(live.map((member) => member.id));
121
+ const tracked = this.#track(name);
122
+ const gone: PresenceMember[] = [];
123
+ for (const id of tracked) {
124
+ if (!liveIds.has(id)) {
125
+ tracked.delete(id);
126
+ gone.push({ id, actorId: null, meta: {}, updatedAt: this.#clock.now().getTime() });
127
+ }
128
+ }
129
+ for (const id of liveIds) tracked.add(id);
130
+ if (gone.length > 0) await this.#emit(name, 'leave', gone);
131
+ return gone;
132
+ }
133
+
134
+ /**
135
+ * Every topic this node has seen, in one pass. Expiry is silent by design — a member whose node
136
+ * died simply stops heartbeating — so with nothing sweeping, the survivors keep rendering a
137
+ * cursor that stopped moving until they reconnect. The `sync` node calls this on an interval;
138
+ * nothing else is in a position to.
139
+ *
140
+ * Why an interval and not a call on demand: `sweep` can only report a member it has already
141
+ * seen, and a member that joined on *another* node is first seen by a sweep. One pass while it
142
+ * is alive is what makes the next pass able to say it left.
143
+ */
144
+ async sweepAll(): Promise<readonly PresenceMember[]> {
145
+ const gone: PresenceMember[] = [];
146
+ for (const name of [...this.#seen.keys()] as Topic[]) {
147
+ gone.push(...(await this.sweep(name)));
148
+ // A room nobody is in is not a room. Without this the cache keeps one entry per topic ever
149
+ // subscribed to, for the life of the process, and the sweep walks all of them forever.
150
+ if ((this.#seen.get(name)?.size ?? 0) === 0) this.#seen.delete(name);
151
+ }
152
+ return gone;
153
+ }
154
+
155
+ /** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
156
+ async syncFrame(name: Topic): Promise<Frame> {
157
+ return presenceFrame(name, 'sync', await this.list(name));
158
+ }
159
+
160
+ #key(name: Topic): string {
161
+ return `${PRESENCE_KEY_PREFIX}.${name}`;
162
+ }
163
+
164
+ #track(name: Topic): Set<string> {
165
+ const existing = this.#seen.get(name);
166
+ if (existing) return existing;
167
+ const created = new Set<string>();
168
+ this.#seen.set(name, created);
169
+ return created;
170
+ }
171
+
172
+ async #write(name: Topic, member: PresenceMember): Promise<void> {
173
+ const value = JSON.stringify({
174
+ actorId: member.actorId,
175
+ meta: member.meta,
176
+ updatedAt: member.updatedAt,
177
+ });
178
+ await this.#transport.shared.put(this.#key(name), member.id, value, this.#ttlMs);
179
+ }
180
+
181
+ async #find(name: Topic, id: string): Promise<PresenceMember | null> {
182
+ const entries = await this.#transport.shared.entries(this.#key(name));
183
+ const entry = entries.find((candidate) => candidate.member === id);
184
+ return entry ? parseMember(entry.member, entry.value) : null;
185
+ }
186
+
187
+ async #emit(
188
+ name: Topic,
189
+ op: 'join' | 'leave' | 'update',
190
+ members: readonly PresenceMember[],
191
+ ): Promise<void> {
192
+ if (!this.#hub) return;
193
+ await this.#hub.publishFrame(name, presenceFrame(name, op, members));
194
+ }
195
+ }
196
+
197
+ export function presenceFrame(
198
+ name: Topic,
199
+ op: 'join' | 'leave' | 'update' | 'sync',
200
+ members: readonly PresenceMember[],
201
+ ): Frame {
202
+ return { type: 'presence', v: PROTOCOL_VERSION, topic: name, op, members };
203
+ }
204
+
205
+ function parseMember(id: string, value: string): PresenceMember | null {
206
+ try {
207
+ const parsed: unknown = JSON.parse(value);
208
+ if (typeof parsed !== 'object' || parsed === null) return null;
209
+ const shape = parsed as { actorId?: unknown; meta?: unknown; updatedAt?: unknown };
210
+ return {
211
+ id,
212
+ actorId: typeof shape.actorId === 'string' ? shape.actorId : null,
213
+ meta: typeof shape.meta === 'object' && shape.meta !== null ? (shape.meta as JsonObject) : {},
214
+ updatedAt: typeof shape.updatedAt === 'number' ? shape.updatedAt : 0,
215
+ };
216
+ } catch {
217
+ return null;
218
+ }
219
+ }