@ultimat3/realtime 1.2.0 → 3.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 (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +202 -20
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +99 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +324 -248
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
@@ -1,8 +1,11 @@
1
1
  // Physical Postgres row -> entity-row shaping: snake_case columns become camelCase properties,
2
- // and a `<p>_minor` / `<p>_currency` column pair folds into one `<p>: Money`-shaped property.
3
- // The inverse of the camelCasing here is `@ultimat3/entity`'s `column.ts#snake` — not imported
4
- // (a tier-3 package may not reach across to tier-2), so the round trip is pinned by a test instead.
2
+ // and the `<p>_minor` / `<p>_currency` / `<p>_scale` columns fold into one `<p>: Money`-shaped
3
+ // property. The inverse of the camelCasing here is `@ultimat3/entity`'s `column.ts#snake`, and
4
+ // the fold is its `pg-row.ts#moneyOf` neither is imported (this package declares no dependency
5
+ // on tier 2), so both are pinned by a test instead: `pg-entity-row-parity.test.ts` reads one
6
+ // physical row through both surfaces and asserts one object.
5
7
 
8
+ import { describeValue } from '@ultimat3/core';
6
9
  import { ReplicationProtocolError } from './errors';
7
10
  import type { JsonObject, JsonValue } from './json';
8
11
 
@@ -17,18 +20,24 @@ function capitalize(part: string): string {
17
20
  return part.charAt(0).toUpperCase() + part.slice(1);
18
21
  }
19
22
 
20
- interface MoneyPair {
23
+ interface FoldedMoney {
21
24
  readonly property: string;
22
- readonly minorKey: string;
23
- readonly currencyKey: string;
24
- readonly minor: number;
25
- readonly currency: string;
25
+ /** The physical columns the fold consumed, in declaration order — named together on a collision. */
26
+ readonly columns: readonly string[];
27
+ readonly value: JsonObject;
26
28
  }
27
29
 
28
- /** `price_minor` / `price_currency` -> `price`; any other column name has no money prefix. */
30
+ /**
31
+ * `price_minor` / `price_currency` / `price_scale` -> `price`; any other column name has no money
32
+ * prefix. The scale column is matched here for the same reason the other two are: unmatched, it
33
+ * survived the fold as a physical `priceScale` property beside `price`, so one row read live and
34
+ * the same row read through a repository reported two different shapes — and the sub-cent amount
35
+ * the scale names was delivered to every subscriber unscaled.
36
+ */
29
37
  function moneyPrefix(column: string): string | null {
30
38
  if (column.endsWith('_minor')) return column.slice(0, -'_minor'.length);
31
39
  if (column.endsWith('_currency')) return column.slice(0, -'_currency'.length);
40
+ if (column.endsWith('_scale')) return column.slice(0, -'_scale'.length);
32
41
  return null;
33
42
  }
34
43
 
@@ -45,20 +54,74 @@ function moneyMinor(column: string, value: number | string): number {
45
54
  if (Number.isSafeInteger(minor)) return minor;
46
55
  throw new ReplicationProtocolError({
47
56
  stage: 'value',
48
- detail: `column "${column}" carries "${value}", which is not a whole number of minor units`,
57
+ detail: `column "${column}" carries ${shownNumber(value)}, which is not a whole number of minor units`,
49
58
  fix: `store ${column} as a bigint inside ±2^53 — Money.minor is a number, never a float or a bigint`,
50
59
  });
51
60
  }
52
61
 
53
62
  /**
54
- * `name` is one half of a `<p>_minor` / `<p>_currency` pair, or null if it is not part of one.
55
- * Both halves must be present *and* typed like money a null currency (an unset money value) is
56
- * not "half a pair", it simply is not a pair, so both columns fall through as ordinary values.
63
+ * `MoneyValue.scale` is a whole, non-negative count of decimal places, so that and only that
64
+ * is what this decoder refuses: a fractional or negative scale is not a value the shape can carry
65
+ * at all, exactly as an out-of-range `minor` is not.
66
+ *
67
+ * The `0…MAX_MONEY_SCALE` CEILING is `@ultimat3/schema`'s and is deliberately not restated here:
68
+ * it is enforced at both ends of this column already — the CHECK `@ultimat3/entity`'s
69
+ * `describeColumn` emits on `<p>_scale`, and `parseScale` on the repository read — and this
70
+ * package declares no `@ultimat3/schema` dependency, so a copy of the bound here would be a
71
+ * second declaration that can drift from the one that decides.
72
+ *
73
+ * `/^\d+$/` and not `Number(value)`: `Number('')` is 0, and 0 means whole units — the one value
74
+ * an empty column must never decode to. The same guard `parseScale` uses.
75
+ */
76
+ function moneyScale(column: string, value: JsonValue): number {
77
+ const digits = typeof value === 'string' && /^\d+$/.test(value);
78
+ const scale = typeof value === 'number' ? value : digits ? Number(value) : Number.NaN;
79
+ if (Number.isSafeInteger(scale) && scale >= 0) return scale;
80
+ throw new ReplicationProtocolError({
81
+ stage: 'value',
82
+ detail: `column "${column}" carries ${shownNumber(value)}, which is not a whole number of decimal places`,
83
+ fix: `store ${column} as a non-negative integer, or null for the currency's own minor unit`,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * **Paired with `parseMinor` in `@ultimat3/entity`'s `columns.ts`, and the pairing is the rule
89
+ * rather than the spelling: an amount may be echoed when it is *provably numeric*, and this file
90
+ * has to prove it at run time because entity proves it from the branch.**
91
+ *
92
+ * Entity reaches its echo only on a value already narrowed to a finite non-integer `number`, a
93
+ * `bigint`, or a `/^-?\d+$/` string. Here the value came off the WAL, and a `<p>_minor` pair is
94
+ * matched by column *name*: any `text` column called `note_minor` beside `note_currency` routes
95
+ * arbitrary user content through this throw. `"${value}"` on that path is the leak
96
+ * `describeValue` exists for — the message is built before any field-level redaction can see it,
97
+ * and it reaches the log store and the operator alike.
98
+ *
99
+ * So the amount survives when its content is a number (a float, an out-of-range integer, or a
100
+ * string that *is* one), and everything else is reported as shape. The scale column is matched by
101
+ * name the same way and carries the same risk, so it renders through here too.
57
102
  */
58
- function moneyPairAt(
103
+ function shownNumber(value: JsonValue): string {
104
+ if (typeof value === 'number') return `"${value}"`;
105
+ if (typeof value !== 'string') return describeValue(value);
106
+ const numeric = value.trim() !== '' && Number.isFinite(Number(value));
107
+ return numeric ? `"${value}"` : describeValue(value);
108
+ }
109
+
110
+ /**
111
+ * `name` is part of a `<p>_minor` / `<p>_currency` (/ `<p>_scale`) group, or null if it is not.
112
+ * The first two must be present *and* typed like money — a null currency (an unset money value) is
113
+ * not "half a pair", it simply is not a pair, so every column falls through as an ordinary value.
114
+ *
115
+ * The scale column is the one member that may be absent or NULL, and both mean the same thing:
116
+ * "the currency's own minor unit". Neither produces a `scale` key, because `undefined` and `0` are
117
+ * different values — `0` claims whole units, a 100x reinterpretation of an ordinary price — which
118
+ * is exactly the rule `moneyOf` follows on the repository side. What it may NOT do is survive as a
119
+ * column of its own: the fold consumes it whenever the group folds.
120
+ */
121
+ function foldMoney(
59
122
  physical: Readonly<Record<string, JsonValue>>,
60
123
  name: string,
61
- ): MoneyPair | null {
124
+ ): FoldedMoney | null {
62
125
  const prefix = moneyPrefix(name);
63
126
  if (prefix === null) return null;
64
127
 
@@ -68,16 +131,21 @@ function moneyPairAt(
68
131
 
69
132
  const minor = physical[minorKey];
70
133
  const currency = physical[currencyKey];
71
- if ((typeof minor === 'number' || typeof minor === 'string') && typeof currency === 'string') {
72
- return {
73
- property: camel(prefix),
74
- minorKey,
75
- currencyKey,
134
+ if (!(typeof minor === 'number' || typeof minor === 'string') || typeof currency !== 'string') {
135
+ return null;
136
+ }
137
+
138
+ const scaleKey = `${prefix}_scale`;
139
+ const scale = Object.hasOwn(physical, scaleKey) ? physical[scaleKey] : undefined;
140
+ return {
141
+ property: camel(prefix),
142
+ columns: scale === undefined ? [minorKey, currencyKey] : [minorKey, currencyKey, scaleKey],
143
+ value: {
76
144
  minor: moneyMinor(minorKey, minor),
77
145
  currency,
78
- };
79
- }
80
- return null;
146
+ ...(scale === undefined || scale === null ? {} : { scale: moneyScale(scaleKey, scale) }),
147
+ },
148
+ };
81
149
  }
82
150
 
83
151
  /**
@@ -100,12 +168,13 @@ function claim(taken: Map<string, string>, property: string, column: string): vo
100
168
  /**
101
169
  * A physical postgres row -> the row shape the rest of the pipeline is written against.
102
170
  * Two things are not one-to-one and both live here: the column is snake_case while the entity
103
- * property is camelCase, and money is one property over the two columns `<p>_minor`/`<p>_currency`.
171
+ * property is camelCase, and money is one property over the columns `<p>_minor`/`<p>_currency`
172
+ * and the nullable `<p>_scale`.
104
173
  */
105
174
  export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonObject {
106
175
  const row: JsonObject = {};
107
- // Column order in is key order out; a folded money property lands wherever its earlier half
108
- // (whichever of _minor/_currency the source happened to emit first) would otherwise have sat.
176
+ // Column order in is key order out; a folded money property lands wherever its earliest member
177
+ // (whichever of the three the source happened to emit first) would otherwise have sat.
109
178
  const consumed = new Set<string>();
110
179
  // Which column produced each property, so a collision names both sides rather than losing one.
111
180
  const taken = new Map<string, string>();
@@ -113,12 +182,11 @@ export function entityRow(physical: Readonly<Record<string, JsonValue>>): JsonOb
113
182
  for (const name of Object.keys(physical)) {
114
183
  if (consumed.has(name)) continue;
115
184
 
116
- const money = moneyPairAt(physical, name);
185
+ const money = foldMoney(physical, name);
117
186
  if (money !== null) {
118
- claim(taken, money.property, `${money.minorKey}/${money.currencyKey}`);
119
- row[money.property] = { minor: money.minor, currency: money.currency };
120
- consumed.add(money.minorKey);
121
- consumed.add(money.currencyKey);
187
+ claim(taken, money.property, money.columns.join('/'));
188
+ row[money.property] = money.value;
189
+ for (const column of money.columns) consumed.add(column);
122
190
  continue;
123
191
  }
124
192
 
@@ -123,6 +123,15 @@ export class PgReplicationStream {
123
123
  /** Resolves once the stream is live. Delivery continues on the pump until `stop()`. */
124
124
  async start(handlers: ReplicationStreamHandlers): Promise<void> {
125
125
  if (this.#running) return;
126
+ // `#pump` is the previous run's *terminal cleanup*, not just its read loop: `#drain` awaits
127
+ // `#die`, and `#die` awaits `connection.close()`. `#die` clears `#running` and nulls
128
+ // `#connection` before that close settles, so a restart that dialled here would replace
129
+ // `#pump` with its own and leave the old walsender holding the slot — the next `stop()` would
130
+ // await only the new pump and report a released slot to a supervisor whose next process then
131
+ // collides with one that is still `active`. Waiting for it is waiting for the prior teardown.
132
+ const previous = this.#pump;
133
+ this.#pump = null;
134
+ if (previous !== null) await previous;
126
135
  const slot = this.#slot;
127
136
  const publication = this.#publication;
128
137
  const target = parsePgUrl(this.#options.url);
@@ -146,10 +155,15 @@ export class PgReplicationStream {
146
155
  `(proto_version '1', publication_names '${publication}')`,
147
156
  );
148
157
  } catch (failure) {
149
- await this.stop();
158
+ // The dial failure is the one that explains the boot, so a teardown that also failed must
159
+ // not replace it — `stop()` has released everything either way by the time it rethrows.
160
+ await this.stop().catch(() => undefined);
150
161
  throw failure;
151
162
  }
152
163
  this.#running = true;
164
+ // A restart that kept the last death in `stats()` reports a live stream as failed, and the
165
+ // supervisor that reads it never sees the replicator come back.
166
+ this.#failure = null;
153
167
  this.#timer = setInterval(() => {
154
168
  void this.#confirm();
155
169
  }, this.#options.statusIntervalMs ?? DEFAULT_STATUS_INTERVAL_MS);
@@ -158,24 +172,75 @@ export class PgReplicationStream {
158
172
  this.#pump = this.#drain(connection, handlers);
159
173
  }
160
174
 
175
+ /**
176
+ * Every step here runs whatever the step before it did. A `#confirm` or an `endCopy` that threw
177
+ * used to skip the close and the pump await entirely: the socket leaked, the slot stayed
178
+ * `active`, and `stop()` reported the failure to a supervisor that was already starting the next
179
+ * process — teardown announced as finished before it had begun. The first failure is the one
180
+ * that explains the shutdown, so it is the one rethrown, and only once everything is let go.
181
+ */
161
182
  async stop(): Promise<void> {
162
183
  this.#running = false;
163
- if (this.#timer !== null) {
164
- clearInterval(this.#timer);
165
- this.#timer = null;
166
- }
184
+ this.#clearTimer();
167
185
  const connection = this.#connection;
168
186
  this.#connection = null;
169
- if (connection === null) return;
170
- // Confirming before the goodbye is what stops a restart from replaying the whole window.
171
- if (connection.inCopyBoth) {
172
- await this.#confirm(connection);
173
- await connection.endCopy();
187
+ const failures: unknown[] = [];
188
+ try {
189
+ // Confirming before the goodbye is what stops a restart from replaying the whole window.
190
+ if (connection?.inCopyBoth === true) {
191
+ await this.#confirm(connection);
192
+ await connection.endCopy();
193
+ }
194
+ } catch (failure) {
195
+ failures.push(failure);
174
196
  }
175
- await connection.close();
197
+ try {
198
+ await connection?.close();
199
+ } catch (failure) {
200
+ failures.push(failure);
201
+ }
202
+ // Awaited even when the stream died on its own: `#die` nulls the connection *before* closing
203
+ // it, so returning here would report the socket as released while it is still going down.
176
204
  const pump = this.#pump;
177
205
  this.#pump = null;
178
- if (pump !== null) await pump;
206
+ try {
207
+ if (pump !== null) await pump;
208
+ } catch (failure) {
209
+ failures.push(failure);
210
+ }
211
+ if (failures.length > 0) throw failures[0];
212
+ }
213
+
214
+ /**
215
+ * The pump's only way out, however it ended — a decode error, or a walsender that said goodbye.
216
+ * The four things it owns go together or not at all, because each one left behind is a dead
217
+ * replicator claiming to be a live one: a `null` failure for a loop that stopped reading, a
218
+ * confirm timer still telling the walsender the stream is keeping up, a socket nobody closed,
219
+ * and a `#connection` the next `start()` overwrites instead of releasing. A `stop()` that got
220
+ * here first owns the connection, and its exit is an orderly one, not a failure.
221
+ */
222
+ async #die(reason: string): Promise<void> {
223
+ if (!this.#running) return;
224
+ this.#running = false;
225
+ this.#failure = reason;
226
+ this.#clearTimer();
227
+ const connection = this.#connection;
228
+ this.#connection = null;
229
+ // The supervisor reads /readyz, so the loop records, reports and ends rather than throwing
230
+ // into a promise nothing awaits.
231
+ logger.error('replication stream ended', { slot: this.#options.slot, error: reason });
232
+ try {
233
+ await connection?.close();
234
+ } catch {
235
+ // The socket is already unusable and `failure` above is the report that matters — a
236
+ // goodbye that throws must not become the rejection `#drain` promised never to produce.
237
+ }
238
+ }
239
+
240
+ #clearTimer(): void {
241
+ if (this.#timer === null) return;
242
+ clearInterval(this.#timer);
243
+ this.#timer = null;
179
244
  }
180
245
 
181
246
  /** The read loop. It owns no timers and no state beyond the current transaction. */
@@ -183,7 +248,12 @@ export class PgReplicationStream {
183
248
  try {
184
249
  for (;;) {
185
250
  const payload = await connection.nextCopyData();
186
- if (payload === undefined) return;
251
+ if (payload === undefined) {
252
+ // The walsender ended the copy. However politely it said so, nothing reads the slot
253
+ // again until something restarts this — which is a failure, not a shutdown.
254
+ await this.#die('the walsender ended the copy stream');
255
+ return;
256
+ }
187
257
  const reader = new ByteReader(payload, 'copy-data');
188
258
  const tag = reader.tag();
189
259
  if (tag === 'w') {
@@ -207,20 +277,7 @@ export class PgReplicationStream {
207
277
  }
208
278
  }
209
279
  } catch (failure) {
210
- if (!this.#running) return;
211
- this.#running = false;
212
- // The supervisor reads /readyz, so the loop records, reports and ends rather than throwing
213
- // into a timer callback nothing awaits — and the confirm timer stops with the loop it
214
- // confirms for, or it keeps telling the walsender a dead stream is still keeping up.
215
- this.#failure = failure instanceof Error ? failure.message : String(failure);
216
- if (this.#timer !== null) {
217
- clearInterval(this.#timer);
218
- this.#timer = null;
219
- }
220
- logger.error('replication stream ended', {
221
- slot: this.#options.slot,
222
- error: this.#failure,
223
- });
280
+ await this.#die(failure instanceof Error ? failure.message : String(failure));
224
281
  }
225
282
  }
226
283
 
package/src/pg-socket.ts CHANGED
@@ -51,9 +51,12 @@ export function parsePgUrl(url: string): PgTarget {
51
51
  try {
52
52
  parsed = new URL(url);
53
53
  } catch {
54
+ // The variable, never its value: a connection URL carries the database password, and an
55
+ // error is the one thing that reaches a log, `--json`, an agent transcript and a ticket.
56
+ // Same rule as `packages/mail/src/driver-smtp.ts:68`.
54
57
  throw new ReplicationFailedError({
55
58
  stage: 'connect',
56
- detail: `"${url}" is not a connection URL`,
59
+ detail: 'DATABASE_URL is not a connection URL',
57
60
  fix: 'set DATABASE_URL to postgres://user:password@host:5432/database',
58
61
  });
59
62
  }
@@ -4,9 +4,11 @@
4
4
  //
5
5
  // The row gate turns a denial into "not visible" instead of an error: a row that fails an actor's
6
6
  // policy is dropped, never sent. That is the rule from the live-query pipeline, implemented once.
7
+ // A *denial* only — a gate that could not reach a decision raises, because "denied" and "the
8
+ // database is down" are different facts and one of them has to page someone.
7
9
 
8
10
  import type { Actor, Ctx } from '@ultimat3/core';
9
- import { guard, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
11
+ import { guard, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
10
12
  import type { JsonValue, Row } from './json';
11
13
 
12
14
  export interface GateOptions {
@@ -23,7 +25,7 @@ export function authorizeWithPolicy(
23
25
  return async (args) => {
24
26
  // No row exists yet at subscribe time; `null` says so rather than leaving the predicate
25
27
  // to infer it from an absent field.
26
- await guard(policy, subjectOf(options, args.actor, args.input, null), 'live');
28
+ guard(policy, subjectOf(options, args.actor, args.input, null), 'live');
27
29
  };
28
30
  }
29
31
 
@@ -38,10 +40,16 @@ export function visibleWithPolicy<R extends Row = Row>(
38
40
  ): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
39
41
  return async (args) => {
40
42
  try {
41
- await guard(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
43
+ guard(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
42
44
  return true;
43
- } catch {
44
- return false;
45
+ } catch (error) {
46
+ // `guard` throws `QueryDeniedError` for a decision and for nothing else, so that class is
47
+ // the whole of "not visible". A rule that reached for a row and timed out throws something
48
+ // else, and answering `false` to it would report an outage as a permission change: the rows
49
+ // leave the subscriber's screen, `live.rows_denied` counts the drop, and no error reaches
50
+ // the node. The registry is the one that decides what a failure costs — see `deliver`.
51
+ if (error instanceof QueryDeniedError) return false;
52
+ throw error;
45
53
  }
46
54
  };
47
55
  }
package/src/presence.ts CHANGED
@@ -4,13 +4,22 @@
4
4
  // simply stop heartbeating and expire, and every other node already sees the same set. Ephemeral
5
5
  // state is never modelled as rows — that rule is what keeps presence off the write path entirely.
6
6
 
7
- import { type Clock, systemClock } from '@ultimat3/core';
7
+ import { type Clock, systemClock, uuid } from '@ultimat3/core';
8
8
  import type { ChannelHub, Topic } from './channel';
9
9
  import type { Transport } from './fanout';
10
10
  import type { JsonObject } from './json';
11
11
  import { type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
12
12
 
13
13
  export const PRESENCE_KEY_PREFIX = 'presence';
14
+ /** Separate namespace: the sweep lease is one member per *node*, never one per participant. */
15
+ export const PRESENCE_SWEEP_PREFIX = 'presence.sweep';
16
+
17
+ /**
18
+ * Members carried on one full-set frame. A 5,000-avatar row is not a UI anyone renders, and the
19
+ * full set is 25M member deserializations across one all-hands join storm; the count rides along
20
+ * on the frame so a client can say "and 4,744 others" without ever holding them.
21
+ */
22
+ export const DEFAULT_MAX_PRESENCE_MEMBERS = 256;
14
23
 
15
24
  export interface PresenceOptions {
16
25
  readonly transport: Transport;
@@ -19,6 +28,20 @@ export interface PresenceOptions {
19
28
  readonly clock?: Clock;
20
29
  /** Member TTL. Clients should heartbeat at ttl/3 so one lost beat is not a false leave. */
21
30
  readonly ttlMs?: number;
31
+ /** Members on a full-set frame. The set itself is never capped — only what is shipped. */
32
+ readonly maxMembers?: number;
33
+ /**
34
+ * This node's identity in the per-topic sweep election. Defaults to a fresh id per registry,
35
+ * which is what a `sync` node wants: an election between processes, never between rooms.
36
+ */
37
+ readonly nodeId?: string;
38
+ }
39
+
40
+ /** One full-set frame's worth of a room, and how big the room actually is. */
41
+ export interface PresenceRoster {
42
+ readonly members: readonly PresenceMember[];
43
+ /** Members in the set, whatever was shipped. `total > members.length` means truncated. */
44
+ readonly total: number;
22
45
  }
23
46
 
24
47
  export interface PresenceInput {
@@ -34,6 +57,8 @@ export class PresenceRegistry {
34
57
  readonly #hub: ChannelHub | undefined;
35
58
  readonly #clock: Clock;
36
59
  readonly #ttlMs: number;
60
+ readonly #maxMembers: number;
61
+ readonly #nodeId: string;
37
62
  /** Diffing cache only — the truth is always `transport.shared`. Safe to lose. */
38
63
  readonly #seen = new Map<string, Set<string>>();
39
64
 
@@ -42,6 +67,8 @@ export class PresenceRegistry {
42
67
  this.#hub = options.hub;
43
68
  this.#clock = options.clock ?? systemClock;
44
69
  this.#ttlMs = options.ttlMs ?? 30_000;
70
+ this.#maxMembers = Math.max(1, options.maxMembers ?? DEFAULT_MAX_PRESENCE_MEMBERS);
71
+ this.#nodeId = options.nodeId ?? uuid();
45
72
  }
46
73
 
47
74
  get ttlMs(): number {
@@ -53,7 +80,7 @@ export class PresenceRegistry {
53
80
  return Math.max(1_000, Math.floor(this.#ttlMs / 3));
54
81
  }
55
82
 
56
- async join(name: Topic, input: PresenceInput): Promise<readonly PresenceMember[]> {
83
+ async join(name: Topic, input: PresenceInput): Promise<PresenceRoster> {
57
84
  const member: PresenceMember = {
58
85
  id: input.id,
59
86
  actorId: input.actorId,
@@ -63,7 +90,7 @@ export class PresenceRegistry {
63
90
  await this.#write(name, member);
64
91
  this.#track(name).add(member.id);
65
92
  await this.#emit(name, 'join', [member]);
66
- return await this.list(name);
93
+ return await this.roster(name);
67
94
  }
68
95
 
69
96
  /** `false` means the member had already expired: the caller must `join` again, not `heartbeat`. */
@@ -114,6 +141,15 @@ export class PresenceRegistry {
114
141
  return members;
115
142
  }
116
143
 
144
+ /**
145
+ * What a full-set frame carries. `list` stays the whole set because the sweep decides who left by
146
+ * differencing it — capping *that* would report every member past the cap as gone.
147
+ */
148
+ async roster(name: Topic): Promise<PresenceRoster> {
149
+ const members = await this.list(name);
150
+ return { members: members.slice(0, this.#maxMembers), total: members.length };
151
+ }
152
+
117
153
  /** Turns TTL expiry into explicit `leave` frames. Called on an interval by the `sync` node. */
118
154
  async sweep(name: Topic): Promise<readonly PresenceMember[]> {
119
155
  const live = await this.list(name);
@@ -144,17 +180,44 @@ export class PresenceRegistry {
144
180
  async sweepAll(): Promise<readonly PresenceMember[]> {
145
181
  const gone: PresenceMember[] = [];
146
182
  for (const name of [...this.#seen.keys()] as Topic[]) {
147
- gone.push(...(await this.sweep(name)));
148
183
  // A room nobody is in is not a room. Without this the cache keeps one entry per topic ever
149
184
  // subscribed to, for the life of the process, and the sweep walks all of them forever.
185
+ if ((this.#seen.get(name)?.size ?? 0) === 0) {
186
+ this.#seen.delete(name);
187
+ continue;
188
+ }
189
+ if (!(await this.#claimSweep(name))) continue;
190
+ gone.push(...(await this.sweep(name)));
150
191
  if ((this.#seen.get(name)?.size ?? 0) === 0) this.#seen.delete(name);
151
192
  }
152
193
  return gone;
153
194
  }
154
195
 
196
+ /**
197
+ * One node per topic per pass reads the full member set. Every node sweeping every room it has
198
+ * ever seen is the same full-set read multiplied by the fleet — twenty nodes reading a
199
+ * 5,000-member set every ten seconds, forever, to produce twenty copies of one `leave` frame.
200
+ *
201
+ * The election needs no compare-and-set the shared store does not have: the lease key is a
202
+ * *keyed set*, so every node's claim is its own member and the winner is simply the lowest id
203
+ * every claimant can see. It is eventually consistent, and the worst case of two nodes reading
204
+ * different views is a duplicate `leave` for a member who has already gone — which is what a
205
+ * `leave` frame means anyway. What it never produces is nobody sweeping: a claim is re-put every
206
+ * pass, and a dead leader's expires within one TTL.
207
+ */
208
+ async #claimSweep(name: Topic): Promise<boolean> {
209
+ const key = `${PRESENCE_SWEEP_PREFIX}.${name}`;
210
+ await this.#transport.shared.put(key, this.#nodeId, '', this.#ttlMs);
211
+ const claimants = await this.#transport.shared.entries(key);
212
+ let leader = this.#nodeId;
213
+ for (const claimant of claimants) if (claimant.member < leader) leader = claimant.member;
214
+ return leader === this.#nodeId;
215
+ }
216
+
155
217
  /** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
156
218
  async syncFrame(name: Topic): Promise<Frame> {
157
- return presenceFrame(name, 'sync', await this.list(name));
219
+ const roster = await this.roster(name);
220
+ return presenceFrame(name, 'sync', roster.members, roster.total);
158
221
  }
159
222
 
160
223
  #key(name: Topic): string {
@@ -194,12 +257,19 @@ export class PresenceRegistry {
194
257
  }
195
258
  }
196
259
 
260
+ /**
261
+ * `total` belongs to a **full set** and to nothing else: a `join`/`leave`/`update` frame carries the
262
+ * members that changed, so a count beside them would read as "and the rest were truncated". Absent
263
+ * is a defined answer — the client renders what it was sent.
264
+ */
197
265
  export function presenceFrame(
198
266
  name: Topic,
199
267
  op: 'join' | 'leave' | 'update' | 'sync',
200
268
  members: readonly PresenceMember[],
269
+ total?: number,
201
270
  ): Frame {
202
- return { type: 'presence', v: PROTOCOL_VERSION, topic: name, op, members };
271
+ const base = { type: 'presence', v: PROTOCOL_VERSION, topic: name, op, members } as const;
272
+ return total === undefined ? base : { ...base, total };
203
273
  }
204
274
 
205
275
  function parseMember(id: string, value: string): PresenceMember | null {
@@ -0,0 +1,56 @@
1
+ // The typed projection of a query into the hook a component calls: `liveHookFor(liveFeed)` is
2
+ // `useLiveFeed`, and `useLiveFeed({ orgId })` carries that query's own input and row types. It
3
+ // binds `useLive` rather than re-implementing it — one subscribe path, given the query's name.
4
+
5
+ import { QueryNotSubscribableError } from './errors';
6
+ import { type LiveInput, type LiveRows, useLive } from './hooks';
7
+
8
+ /**
9
+ * What the hook needs from a `@ultimat3/query` `Query`: the name it subscribes under, the declared
10
+ * `live:` flag, and the call signature both types are read off. Named structurally rather than
11
+ * imported, the way `hooks.ts` names a mutator — a hook is browser code, and a value import of
12
+ * `@ultimat3/query` would carry the server's read path into the bundle.
13
+ *
14
+ * `options` is `never` because this side never passes one; a `Query`, whose second parameter is
15
+ * optional and wider, still assigns.
16
+ */
17
+ export interface LiveQuerySource<TInput, TRow extends object> {
18
+ (input: TInput, options?: never): Promise<readonly TRow[]>;
19
+ readonly name: string;
20
+ readonly isLive: boolean;
21
+ }
22
+
23
+ /**
24
+ * The bound hook. Input is the query's own — a wrong key is a compile error in the component, not
25
+ * a subscription that returns nothing. A thunk is read **once**, at subscribe time, exactly as
26
+ * `useLive`'s is: there is no reactive runtime here to re-run it, so new input means a new
27
+ * subscription.
28
+ */
29
+ export type LiveQueryHook<TInput, TRow extends object> = (
30
+ input: TInput | (() => TInput),
31
+ ) => LiveRows<TRow>;
32
+
33
+ /**
34
+ * Bind one live query to one hook: `export const useLiveFeed = liveHookFor(liveFeed)`, then
35
+ * `useLiveFeed({ orgId })` in a component. Nothing is generated and nothing is fetched by hand —
36
+ * the types come off the query declaration and the rows off its subscription.
37
+ *
38
+ * Binding a query that is not `live: true` throws here, at module load, rather than handing back a
39
+ * hook that could only ever return an empty set.
40
+ */
41
+ export function liveHookFor<TInput, TRow extends object>(
42
+ query: LiveQuerySource<TInput, TRow>,
43
+ ): LiveQueryHook<TInput, TRow> {
44
+ if (!query.isLive) throw new QueryNotSubscribableError({ name: query.name });
45
+ // The query object is handed through, never its `name` read now: `registerQueries()` stamps that
46
+ // at boot, and this binding runs at import — earlier. `useLive` reads it per subscription.
47
+ return (input) => {
48
+ // Both assertions are the one wire seam: the input is about to be serialised into a subscribe
49
+ // frame, and the rows come back off that subscription. `unknown` in between rather than a
50
+ // direct cast, exactly as `query.client()` hops through it at the same seam.
51
+ const rows: unknown = useLive(query, input as LiveInput);
52
+ // Erased at the wire seam, the way `query.client()` erases its own: the rows arriving on this
53
+ // subscription are this query's by construction, because the server built them from its `sql`.
54
+ return rows as LiveRows<TRow>;
55
+ };
56
+ }