@ultimat3/realtime 23.0.0 → 25.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 (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -0,0 +1,151 @@
1
+ // One cap that spans sockets: live subscriptions counted per KEY (a tenant, an actor), held plus
2
+ // reserved, with the key each socket was counted under remembered. Split from
3
+ // `subscription-book.ts` so the tenant cap and the actor cap are one mechanism, not two copies.
4
+
5
+ import { SubscriptionLimitError } from './errors';
6
+ import type { SyncSocket } from './socket';
7
+
8
+ export interface CappedCountInit {
9
+ /** Which key a socket's subscriptions count against, or `null` for none (uncapped here). */
10
+ readonly principalOf: (socket: SyncSocket) => string | null;
11
+ /** `undefined` counts without refusing — the tenant cap's opt-out. Screened by the caller. */
12
+ readonly max: number | undefined;
13
+ readonly scope: 'tenant' | 'actor';
14
+ readonly knob: string;
15
+ }
16
+
17
+ /**
18
+ * Every question answered from a `Map`, never a scan: the cap is asked on every subscribe frame,
19
+ * and walking the node's subscriptions for it was one socket consuming the node.
20
+ */
21
+ export class CappedCount {
22
+ readonly #init: CappedCountInit;
23
+ readonly #held = new Map<string, number>();
24
+ readonly #claimed = new Map<string, number>();
25
+ /**
26
+ * The key each socket's subscriptions were counted under. Remembered rather than re-derived,
27
+ * because `socket.actor` is replaced by a re-auth: deriving it again at `delete` time would
28
+ * decrement a key that was never incremented and leave the old one counting forever.
29
+ */
30
+ readonly #principalOfSocket = new Map<string, string>();
31
+
32
+ constructor(init: CappedCountInit) {
33
+ this.#init = init;
34
+ }
35
+
36
+ count(key: string): number {
37
+ return this.#held.get(key) ?? 0;
38
+ }
39
+
40
+ /** The key a socket counts under: the remembered one, or what its actor says now. */
41
+ principalFor(socket: SyncSocket): string | null {
42
+ return this.#principalOfSocket.get(socket.id) ?? this.#init.principalOf(socket);
43
+ }
44
+
45
+ /** Claims count: a subscribe past this check has already committed the node to its work. */
46
+ assert(socket: SyncSocket): void {
47
+ const max = this.#init.max;
48
+ const key = this.principalFor(socket);
49
+ if (max === undefined || key === null) return;
50
+ if (this.count(key) + (this.#claimed.get(key) ?? 0) >= max) {
51
+ throw new SubscriptionLimitError({
52
+ scope: this.#init.scope,
53
+ id: key,
54
+ limit: max,
55
+ knob: this.#init.knob,
56
+ });
57
+ }
58
+ }
59
+
60
+ /** A reservation, given back to the key that took it even if the socket re-keys meanwhile. */
61
+ claim(socket: SyncSocket): { readonly principal: string | null; readonly release: () => void } {
62
+ const key = this.principalFor(socket);
63
+ if (key === null) return { principal: key, release: () => undefined };
64
+ bump(this.#claimed, key, 1);
65
+ return { principal: key, release: () => bump(this.#claimed, key, -1) };
66
+ }
67
+
68
+ /**
69
+ * A subscribe reserved under `claimedPrincipal` is attaching, and its socket now counts under another
70
+ * key (a re-auth landed while it was in flight): it joins the new key only if that key has room —
71
+ * the check the new key never got at reserve time.
72
+ */
73
+ assertIfMoved(socket: SyncSocket, claimedPrincipal: string | null): void {
74
+ if (this.principalFor(socket) !== claimedPrincipal) this.assert(socket);
75
+ }
76
+
77
+ /**
78
+ * How many of a socket's `held` subscriptions the key it is about to move to cannot take — `0`
79
+ * when it is not moving or there is room. A re-auth refuses that many rather than carrying a key
80
+ * past its cap: moving 128 onto a principal at 1,000 was 1,128, and filling an address then
81
+ * signing in was a way to repeat it.
82
+ */
83
+ overflowOnRekey(socket: SyncSocket, held: number): number {
84
+ const max = this.#init.max;
85
+ const after = this.#init.principalOf(socket);
86
+ if (max === undefined || after === null || held === 0) return 0;
87
+ if (after === (this.#principalOfSocket.get(socket.id) ?? null)) return 0;
88
+ const room = max - this.count(after) - (this.#claimed.get(after) ?? 0);
89
+ return Math.max(0, held - Math.max(0, room));
90
+ }
91
+
92
+ /** The refusal this cap answers for the key `socket` would count under. */
93
+ refusalFor(socket: SyncSocket): SubscriptionLimitError {
94
+ return new SubscriptionLimitError({
95
+ scope: this.#init.scope,
96
+ id: this.#init.principalOf(socket) ?? 'unknown',
97
+ limit: this.#init.max ?? 0,
98
+ knob: this.#init.knob,
99
+ });
100
+ }
101
+
102
+ /** The socket closed: nothing it remembers may outlive it. */
103
+ forget(socketId: string): void {
104
+ this.#principalOfSocket.delete(socketId);
105
+ }
106
+
107
+ /** Sockets this cap still remembers a key for — bounded by sockets that hold a subscription. */
108
+ get remembered(): number {
109
+ return this.#principalOfSocket.size;
110
+ }
111
+
112
+ added(socket: SyncSocket): void {
113
+ const key = this.principalFor(socket);
114
+ if (key === null) return;
115
+ this.#principalOfSocket.set(socket.id, key);
116
+ bump(this.#held, key, 1);
117
+ }
118
+
119
+ /** `emptied`: the socket now holds nothing, so its remembered key goes too. */
120
+ deleted(socketId: string, emptied: boolean): void {
121
+ const key = this.#principalOfSocket.get(socketId);
122
+ if (key === undefined) return;
123
+ bump(this.#held, key, -1);
124
+ if (emptied) this.#principalOfSocket.delete(socketId);
125
+ }
126
+
127
+ /** A re-auth moved the socket to another key: its `held` subscriptions move with it. */
128
+ reassign(socket: SyncSocket, held: number): void {
129
+ // Holding nothing, there is nothing to move and nothing to remember: `keyFor` falls back to
130
+ // the actor's key. Stored anyway, a re-auth on an idle socket was an entry nothing deleted.
131
+ if (held === 0) {
132
+ this.#principalOfSocket.delete(socket.id);
133
+ return;
134
+ }
135
+ const before = this.#principalOfSocket.get(socket.id) ?? null;
136
+ const after = this.#init.principalOf(socket);
137
+ if (before === after) return;
138
+ if (before !== null) bump(this.#held, before, -held);
139
+ if (after === null) this.#principalOfSocket.delete(socket.id);
140
+ else {
141
+ this.#principalOfSocket.set(socket.id, after);
142
+ if (held > 0) bump(this.#held, after, held);
143
+ }
144
+ }
145
+ }
146
+
147
+ function bump(map: Map<string, number>, key: string, by: number): void {
148
+ const next = (map.get(key) ?? 0) + by;
149
+ if (next > 0) map.set(key, next);
150
+ else map.delete(key);
151
+ }
@@ -0,0 +1,56 @@
1
+ // What a socket's re-decided grant does to it: the hub re-asks every channel seat under the new
2
+ // actor (each one it drops is refused under its sid, and left in presence), and the registry
3
+ // re-decides every live subscription. Split from `sync-node.ts` at its size ceiling.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import type { ChannelHub, Topic } from './channel';
7
+ import type { ChannelSids } from './channel-sids';
8
+ import { detach } from './detach';
9
+ import { TopicForbiddenError } from './errors';
10
+ import type { LiveQueryRegistry } from './live-query';
11
+ import { refuseSubscription } from './live-refusal';
12
+ import type { PresenceRegistry } from './presence';
13
+ import type { SocketRegistry } from './socket';
14
+
15
+ export interface ActorChangeDeps {
16
+ readonly sockets: SocketRegistry;
17
+ readonly hub: ChannelHub;
18
+ readonly registry: LiveQueryRegistry;
19
+ readonly channelSids: ChannelSids;
20
+ readonly presence: PresenceRegistry | undefined;
21
+ }
22
+
23
+ /** The `onActor` a grant sweep calls once per socket whose grant was refreshed. */
24
+ export function actorChangeHandler(
25
+ deps: ActorChangeDeps,
26
+ ): (socketId: string, actor: Actor) => Promise<void> {
27
+ const hasRoster = (name: Topic): boolean => deps.hub.channelOf(name)?.channel.events === true;
28
+ return async (socketId, actor) => {
29
+ const socket = deps.sockets.get(socketId);
30
+ if (!socket) return;
31
+ const rooms = new Set(deps.hub.topicsOf(socket).filter(hasRoster));
32
+ // The hub sets `socket.actor` and drops the topics this actor may no longer read; the
33
+ // registry re-decides every live subscription (refusing each one it drops under its sid)
34
+ // and desyncs the survivors, so the next delivery re-snapshots them under the new
35
+ // authority rather than the old window.
36
+ for (const name of await deps.hub.onActorChange(socket, actor)) {
37
+ // A dropped seat is SAID: unsaid, the client kept rendering the channel as live.
38
+ const sid = deps.channelSids.sidOf(socket, name);
39
+ deps.channelSids.delete(socket, name);
40
+ if (sid !== undefined) {
41
+ refuseSubscription(
42
+ socket,
43
+ sid,
44
+ new TopicForbiddenError({
45
+ topic: name,
46
+ actorId: socket.actorId,
47
+ reason: 'the session changed and the channel policy no longer admits it',
48
+ }),
49
+ );
50
+ }
51
+ if (deps.presence && rooms.has(name))
52
+ detach(deps.presence.leave(name, socket.id), 'presence.leave', name);
53
+ }
54
+ await deps.registry.reauthorize(socket);
55
+ };
56
+ }
package/src/sync-auth.ts CHANGED
@@ -3,6 +3,16 @@
3
3
  // holds it, and the pass that re-decides one whose window has closed.
4
4
 
5
5
  import type { Actor, Clock } from '@ultimat3/core';
6
+ import { finiteOption } from '@ultimat3/core';
7
+ import { GrantRefreshTimeoutError } from './errors';
8
+ import { type Scheduler, timeoutScheduler } from './thundering-herd';
9
+
10
+ /**
11
+ * How long one grant's `refresh()` may hold the re-auth pass. The pass is serial and one at a
12
+ * time, so a single call that never settles stopped every later grant on the node from being
13
+ * renewed OR revoked. On by default with no "off": a non-positive value takes the default.
14
+ */
15
+ export const DEFAULT_GRANT_REFRESH_DEADLINE_MS = 10_000;
6
16
 
7
17
  /**
8
18
  * One connection's identity. A grant, not an `Actor`, because a websocket outlives every credential
@@ -76,8 +86,12 @@ export interface GrantSweepDeps {
76
86
  onActor: (socketId: string, actor: Actor) => Promise<void>;
77
87
  /** Nobody may hold this socket any longer. The caller closes it. */
78
88
  onRevoked: (socketId: string) => void;
79
- /** `refresh` raised instead of deciding. The grant is kept and retried on the next pass. */
89
+ /** `refresh` raised, or did not answer in time. The grant is kept and retried on the next pass. */
80
90
  onRefreshFailed?: (socketId: string, error: unknown) => void;
91
+ /** See `DEFAULT_GRANT_REFRESH_DEADLINE_MS`. */
92
+ readonly refreshDeadlineMs?: number | undefined;
93
+ /** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
94
+ readonly schedule?: Scheduler | undefined;
81
95
  }
82
96
 
83
97
  export interface GrantSweepResult {
@@ -98,18 +112,29 @@ export interface GrantSweepResult {
98
112
  */
99
113
  export async function sweepGrants(deps: GrantSweepDeps): Promise<GrantSweepResult> {
100
114
  const now = deps.clock.now().getTime();
115
+ const declared = finiteOption(
116
+ 'sweepGrants',
117
+ 'refreshDeadlineMs',
118
+ deps.refreshDeadlineMs ?? DEFAULT_GRANT_REFRESH_DEADLINE_MS,
119
+ );
120
+ const deadlineMs = declared > 0 ? declared : DEFAULT_GRANT_REFRESH_DEADLINE_MS;
121
+ const schedule = deps.schedule ?? timeoutScheduler;
101
122
  let refreshed = 0;
102
123
  let revoked = 0;
103
124
  let failed = 0;
104
125
  for (const [socketId, grant] of deps.grants.expired(now)) {
105
126
  let next: SyncGrant | null;
106
127
  try {
107
- next = grant.refresh ? await grant.refresh() : null;
128
+ next = grant.refresh ? await within(grant.refresh(), socketId, deadlineMs, schedule) : null;
108
129
  } catch (error) {
109
130
  failed += 1;
110
131
  deps.onRefreshFailed?.(socketId, error);
111
132
  continue;
112
133
  }
134
+ // Asked again, because `refresh` is the app's and awaiting it is awaiting a token service: the
135
+ // socket may have closed meanwhile (its grant deleted) or been re-granted. Written back
136
+ // regardless, a closed socket's grant stayed in the book and was refreshed forever.
137
+ if (deps.grants.get(socketId) !== grant) continue;
113
138
  if (next === null) {
114
139
  deps.grants.delete(socketId);
115
140
  deps.onRevoked(socketId);
@@ -122,3 +147,23 @@ export async function sweepGrants(deps: GrantSweepDeps): Promise<GrantSweepResul
122
147
  }
123
148
  return { refreshed, revoked, failed };
124
149
  }
150
+
151
+ /**
152
+ * `refresh()`, or a coded timeout. The call itself cannot be cancelled and this does not pretend
153
+ * to: it runs on with nobody listening, and `Promise.race` keeps its late rejection handled.
154
+ */
155
+ function within(
156
+ refreshing: Promise<SyncGrant | null>,
157
+ socketId: string,
158
+ afterMs: number,
159
+ schedule: Scheduler,
160
+ ): Promise<SyncGrant | null> {
161
+ let disarm: () => void = () => undefined;
162
+ const deadline = new Promise<never>((_resolve, reject) => {
163
+ disarm = schedule(() => reject(new GrantRefreshTimeoutError({ socketId, afterMs })), afterMs);
164
+ });
165
+ const settled = Promise.race([refreshing, deadline]);
166
+ // An armed timer per answered refresh is a leak, and keeps a draining process alive.
167
+ void settled.then(disarm, disarm);
168
+ return settled;
169
+ }
@@ -0,0 +1,131 @@
1
+ // What a sync node does with the change bus: one change in, fanned to the live queries and the
2
+ // declared channels; a gap or a reconnect, answered by invalidating both; and one producer KIND
3
+ // per table, the other's envelopes dropped. Split out of `sync-node.ts` so the node's lifecycle
4
+ // (start, stop, drain) stays readable in one file.
5
+
6
+ import { logger } from '@ultimat3/core';
7
+ import type { ChannelHub } from './channel';
8
+ import { detach } from './detach';
9
+ import type { LiveQueryRegistry } from './live-query';
10
+ import { RealtimeError } from './realtime-error';
11
+ import {
12
+ type ChangeEnvelope,
13
+ type EnvelopeSource,
14
+ parseEnvelope,
15
+ type SeqGapDetector,
16
+ } from './replicator-envelope';
17
+
18
+ export interface SyncBus {
19
+ readonly registry: LiveQueryRegistry;
20
+ readonly hub: ChannelHub;
21
+ readonly gaps: SeqGapDetector;
22
+ readonly producers: ProducerKinds;
23
+ }
24
+
25
+ /**
26
+ * The WAL replicator and a `recordPublisher` both carrying one table is every committed row twice
27
+ * on every channel. Logged, never thrown: the bus handler must keep serving every other table.
28
+ */
29
+ export class ProducerConflictError extends RealtimeError {
30
+ constructor(args: { table: string; kept: EnvelopeSource; dropped: EnvelopeSource }) {
31
+ super({
32
+ code: 'X_REALTIME_PRODUCER_CONFLICT',
33
+ cause:
34
+ `table "${args.table}" is carried by the ${args.kept} and also by a ${args.dropped}, so ` +
35
+ `every committed row would reach its channels twice; this node keeps the ${args.kept} ` +
36
+ `(seen first) and drops the ${args.dropped}'s changes`,
37
+ // One fix for both orders: the replicator carries every entity table it can see, so the
38
+ // publisher is the half that can let go of one.
39
+ fix: `recordPublisher({ entities: [/* every entity but table ${args.table}'s */] }) # where the replicator runs: it already carries every committed row; then restart the sync node`,
40
+ });
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Which producer kind carries each table, decided by the first envelope this node reads for it and
46
+ * kept for the node's life. The other kind's envelopes for that table are dropped and the conflict
47
+ * said once per table (`X_REALTIME_PRODUCER_CONFLICT`). The `x dev` bridge is no producer here: it
48
+ * delivers in-process and stands in for the replicator, skipping a claimed table itself.
49
+ */
50
+ export class ProducerKinds {
51
+ readonly #kinds = new Map<string, EnvelopeSource>();
52
+ readonly #said = new Set<string>();
53
+
54
+ /** `true` when this envelope's producer kind is the one its table is carried by. */
55
+ admit(envelope: ChangeEnvelope): boolean {
56
+ const table = envelope.change.table;
57
+ const kind = envelope.source ?? 'replicator';
58
+ const kept = this.#kinds.get(table);
59
+ if (kept === undefined) {
60
+ this.#kinds.set(table, kind);
61
+ return true;
62
+ }
63
+ if (kept === kind) return true;
64
+ if (!this.#said.has(table)) {
65
+ this.#said.add(table);
66
+ const conflict = new ProducerConflictError({ table, kept, dropped: kind });
67
+ // FIELDS, never interpolation, and the message is the CODE alone (`pg-preflight.ts`).
68
+ logger.warn(conflict.code, {
69
+ cause: conflict.cause,
70
+ fix: conflict.fix,
71
+ table,
72
+ kept,
73
+ dropped: kind,
74
+ });
75
+ }
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /** The handler a node subscribes to `CHANGE_SUBJECT_ALL` with. */
81
+ export function changeHandler(bus: SyncBus): (payload: string) => void {
82
+ return (payload) => {
83
+ const envelope = parseEnvelope(payload);
84
+ if (!envelope) return;
85
+ // Before the gap detector: a dropped producer's sequence is no stream this node follows.
86
+ if (!bus.producers.admit(envelope)) return;
87
+ // Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
88
+ // Nothing downstream could notice: no window's lsn moved, so no cursor moved, so nothing
89
+ // ever asked for a re-snapshot. A gap invalidates every window here instead, and the
90
+ // subscribers are re-served on the next change to each query.
91
+ const replicated = envelope.source !== 'publisher';
92
+ if (bus.gaps.observe(envelope)) {
93
+ // A publisher's lost change was never a window's (below), so only the channels re-read.
94
+ const marked = replicated ? bus.registry.invalidate() : 0;
95
+ // Channels too: their seq is minted from what this node sees, so the hole is invisible
96
+ // to a ring that would otherwise replay across it as complete history.
97
+ const channelGaps = bus.hub.invalidate();
98
+ logger.warn('live.change_gap', {
99
+ table: envelope.change.table,
100
+ desynced: marked,
101
+ channelGaps,
102
+ });
103
+ }
104
+ // Not awaited: the bus handler must return before the next change, and ordering is the
105
+ // registry's — one serial lane per query id. What this call site owes is the failure. An
106
+ // unhandled rejection here is a fanout that reached nobody, reported as a dead process.
107
+ // A publisher's change has no commit position (its lsn orders nothing across processes), and
108
+ // a window refuses a change below its own lsn as stale: fed one, a live query would drop rows
109
+ // silently. Channels mint their own seq, so they take it (`record-publisher.ts`).
110
+ if (replicated) {
111
+ detach(bus.registry.deliver(envelope.change), 'live.deliver', envelope.change.table);
112
+ }
113
+ // The same stream feeds the declared channels: a write names no channel (axiom 2), and the
114
+ // hub turns this change into `records` frames on every channel it touches.
115
+ bus.hub.deliverChange(envelope.change);
116
+ };
117
+ }
118
+
119
+ /**
120
+ * A bus that reconnected is changes this node never saw, and the gap detector only notices on the
121
+ * NEXT message: with no later write, every window and cursor here stayed behind and every new
122
+ * subscriber joined the stale window. Said by the transport, repaired now.
123
+ */
124
+ export function reconnectHandler(bus: SyncBus): () => void {
125
+ return () => {
126
+ bus.gaps.forget();
127
+ const marked = bus.registry.invalidate();
128
+ const channelGaps = bus.hub.invalidate();
129
+ logger.warn('live.bus_reconnected', { desynced: marked, channelGaps });
130
+ };
131
+ }
@@ -5,7 +5,8 @@
5
5
  import { logger } from '@ultimat3/core';
6
6
  import type { ChannelHub } from './channel';
7
7
  import type { Topic } from './channel-decl';
8
- import { FrameRateLimitError } from './errors';
8
+ import { ChannelSids } from './channel-sids';
9
+ import { FrameRateLimitError, ProtocolVersionError } from './errors';
9
10
  import { FrameLanes, laneKeyOf } from './frame-lanes';
10
11
  import type { LiveQueryRegistry } from './live-query';
11
12
  import { type PresenceRegistry, presenceFrame } from './presence';
@@ -17,8 +18,20 @@ export interface FrameRouterOptions {
17
18
  readonly registry: LiveQueryRegistry;
18
19
  readonly buildId: string;
19
20
  readonly presence?: PresenceRegistry | undefined;
21
+ /** What the `hello` reply tells a client to beat at — `clientHeartbeatMs` on a real node. */
22
+ readonly heartbeatMs?: number | undefined;
23
+ /** Shared with the node, which names a seat's sid when a re-auth drops it. */
24
+ readonly channelSids?: ChannelSids | undefined;
20
25
  }
21
26
 
27
+ /**
28
+ * The longest `sid` a subscribe frame may carry. A sid is retained — in the subscription book, in
29
+ * `ChannelSids`, and echoed in every ack — so its size is the client's to choose only up to here.
30
+ * The bundled client sends a uuid for a live query and `channel:<topic>` for a channel, so the
31
+ * ceiling sits well above a topic of several uuid params.
32
+ */
33
+ export const MAX_SID_LENGTH = 512;
34
+
22
35
  export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
23
36
 
24
37
  /**
@@ -29,26 +42,39 @@ export type FrameRouter = (socket: SyncSocket, frame: Frame) => Promise<void>;
29
42
  */
30
43
  export function ackRefOf(frame: Frame | null, socketId: string): string {
31
44
  if (frame === null) return socketId;
32
- if (frame.type === 'subscribe') return frame.sid;
45
+ // An oversized sid is the refusal's reason, never its echo.
46
+ if (frame.type === 'subscribe' && frame.sid.length <= MAX_SID_LENGTH) return frame.sid;
33
47
  return socketId;
34
48
  }
35
49
 
36
- export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
50
+ export function frameRouter(options: FrameRouterOptions): FrameRouter {
37
51
  const presence = options.presence;
38
- /** Per socket: the topic each channel sid was joined under. Dies with the socket. */
39
- const channelTopics = new WeakMap<SyncSocket, Map<string, Topic>>();
52
+ const channelSids = options.channelSids ?? new ChannelSids();
40
53
 
41
54
  /**
42
55
  * Subscribing to a channel declared `events: true` IS joining its presence set: presence has no
43
56
  * frame of its own (it rides that channel's `events`), so a second round trip saying "and I am
44
- * here" would be a second way to do one thing. A records-only channel has no roster. Repeating the
45
- * frame is therefore also the heartbeat — `join` re-`put`s the member. The roster's answer is
46
- * read though nothing here can repair it: a dropped roster costs one heartbeat of blank room, and
47
- * the log is the only trace it leaves anywhere.
57
+ * here" would be a second way to do one thing. A records-only channel has no roster.
58
+ *
59
+ * Repeating the frame is the heartbeat. A beat renews the member's TTL (`heartbeat`) instead of
60
+ * joining again — that was a KV put and a fleet-wide `join` event per member per beat — and is
61
+ * still answered with the whole roster: a presence delta is an ephemeral frame, so the roster a
62
+ * beat brings back is what repairs a client that lost one. A member that had already expired is
63
+ * joined again in full.
64
+ *
65
+ * A node built WITHOUT presence still answers: an events-only channel is sent nothing else, so
66
+ * unanswered its subscriber read `joining` until the first app event. The answer is the roster
67
+ * this node can vouch for — nobody.
48
68
  */
49
- const joinPresence = async (socket: SyncSocket, name: Topic): Promise<void> => {
50
- if (!presence || options.hub.channelOf(name)?.channel.events !== true) return;
51
- const roster = await presence.join(name, { id: socket.id, actorId: socket.actorId });
69
+ const joinPresence = async (socket: SyncSocket, name: Topic, beat: boolean): Promise<void> => {
70
+ if (options.hub.channelOf(name)?.channel.events !== true) return;
71
+ const roster = !presence
72
+ ? { members: [], total: 0 }
73
+ : beat && (await presence.heartbeat(name, socket.id))
74
+ ? await presence.roster(name)
75
+ : await presence.join(name, { id: socket.id, actorId: socket.actorId });
76
+ // Read, though nothing here can repair it: the next beat re-sends the set, and the log is the
77
+ // only trace a dropped one leaves anywhere.
52
78
  if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
53
79
  logger.warn('sync.presence_roster_dropped', { topic: name, socketId: socket.id });
54
80
  }
@@ -90,6 +116,9 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
90
116
  // The actor the upgrade resolved, so a client can render who the server thinks it is
91
117
  // rather than who it thinks it sent.
92
118
  actorId: socket.actorId,
119
+ // The beat this node's REAL presence ttl and idle budget need. A client cannot read
120
+ // either, and one beating on its own default under a shorter ttl is a false leave.
121
+ ...(options.heartbeatMs === undefined ? {} : { heartbeatMs: options.heartbeatMs }),
93
122
  });
94
123
  if (socket.skewed) {
95
124
  socket.send({ type: 'update-available', v: PROTOCOL_VERSION, buildId: options.buildId });
@@ -97,23 +126,47 @@ export function createFrameRouter(options: FrameRouterOptions): FrameRouter {
97
126
  return;
98
127
  }
99
128
  case 'subscribe': {
129
+ if (frame.sid.length > MAX_SID_LENGTH) {
130
+ throw new ProtocolVersionError({
131
+ got: PROTOCOL_VERSION,
132
+ expected: PROTOCOL_VERSION,
133
+ detail: `subscribe.sid is ${frame.sid.length} characters; the limit is ${MAX_SID_LENGTH}`,
134
+ });
135
+ }
100
136
  if (frame.target.kind === 'channel') {
101
- // The topic is the DECLARATION's to spell (`channel.topic(params)`), so a drop finds it
102
- // by the sid its add was answered under — never by re-deriving it from client data.
103
- const topics = channelTopics.get(socket) ?? new Map<string, Topic>();
104
- channelTopics.set(socket, topics);
105
137
  if (frame.op === 'drop') {
106
- const name = topics.get(frame.sid);
138
+ // The topic is the DECLARATION's to spell (`channel.topic(params)`), so a drop finds
139
+ // it by the sid its add was answered under — never by re-deriving it from client data.
140
+ const name = channelSids.topicOf(socket, frame.sid);
107
141
  if (name === undefined) return;
108
- topics.delete(frame.sid);
142
+ channelSids.delete(socket, name);
109
143
  const events = options.hub.channelOf(name)?.channel.events === true;
110
144
  options.hub.unsubscribe(socket, name);
111
145
  if (presence && events) await presence.leave(name, socket.id);
112
146
  return;
113
147
  }
114
- const name = await options.hub.subscribeChannel(socket, frame.target);
115
- topics.set(frame.sid, name);
116
- await joinPresence(socket, name);
148
+ // A seat this sid already names may be DENIED by this ask (a suspended seat re-decided):
149
+ // the hub drops it, and the sid and the presence member must not outlive it.
150
+ const held = channelSids.topicOf(socket, frame.sid);
151
+ const room = held !== undefined && options.hub.channelOf(held)?.channel.events === true;
152
+ let name: Topic;
153
+ try {
154
+ name = await options.hub.subscribeChannel(socket, frame.target);
155
+ } catch (error) {
156
+ if (held !== undefined && !options.hub.topicsOf(socket).includes(held)) {
157
+ channelSids.delete(socket, held);
158
+ if (presence && room) await presence.leave(held, socket.id);
159
+ }
160
+ throw error;
161
+ }
162
+ // Not seated: the socket died while the guard was answering (a presence member written
163
+ // now is one no close will ever remove), or the seat is SUSPENDED — its guard could not
164
+ // decide — and a roster is exactly the delivery a suspension withholds.
165
+ if (socket.closed || !socket.topics.has(name)) return;
166
+ // A seat this socket already held makes the frame its beat, never a second join.
167
+ const beat = channelSids.sidOf(socket, name) !== undefined;
168
+ channelSids.set(socket, name, frame.sid);
169
+ await joinPresence(socket, name, beat);
117
170
  return;
118
171
  }
119
172
  if (frame.op === 'drop') {
@@ -6,6 +6,7 @@
6
6
  // reads false — so `maxConnections: NaN` is a node that accepts without limit and says nothing.
7
7
 
8
8
  import { finiteOption } from '@ultimat3/core';
9
+ import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
9
10
 
10
11
  const SUBJECT = 'the sync node';
11
12
 
@@ -81,7 +82,7 @@ export function syncNodeBounds(options: SyncNodeNumericOptions): SyncNodeBounds
81
82
  * that `SyncSocket` — not this module — owns the default of.
82
83
  *
83
84
  * They were screened only in `SyncSocket`'s constructor, which Bun runs inside `websocket.open`,
84
- * which Bun runs SYNCHRONOUSLY inside `server.upgrade`. Measured: `createSyncNode` did not throw,
85
+ * which Bun runs SYNCHRONOUSLY inside `server.upgrade`. Measured: `syncNode` did not throw,
85
86
  * `/healthz` and `/readyz` both answered, `ready` was true, and every upgrade threw `X_INVARIANT`
86
87
  * with the node holding zero sockets — a misconfiguration that fails per connection instead of at
87
88
  * boot, and each of those throws leaked the grant `handleUpgrade` records before the upgrade.
@@ -127,3 +128,17 @@ export function socketCeilings(options: SocketCeilings): SocketCeilings {
127
128
  /** Per CALL, not per node: `drain({ graceMs })` is an argument, so it is refused where it arrives. */
128
129
  export const drainGraceMs = (graceMs: number | undefined): number =>
129
130
  finiteOption('the sync node drain', 'graceMs', graceMs ?? DEFAULT_DRAIN_GRACE_MS);
131
+
132
+ /**
133
+ * The beat a client is TOLD (`hello.heartbeatMs`): a third of the node's real presence ttl, so one
134
+ * lost beat is never a false leave — and never slower than a quarter of the idle budget, or a
135
+ * long ttl would have this node evict a healthy socket for the silence it asked for. With no
136
+ * presence there is no ttl, and the client's own default is the starting point.
137
+ */
138
+ export function clientHeartbeatMs(
139
+ presenceBeatMs: number | undefined,
140
+ idleTimeoutMs: number,
141
+ ): number {
142
+ const idleBound = Math.max(1_000, Math.floor(idleTimeoutMs / 4));
143
+ return Math.min(presenceBeatMs ?? DEFAULT_HEARTBEAT_MS, idleBound);
144
+ }