@ultimat3/realtime 1.2.0 → 2.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 +591 -0
  2. package/README.md +320 -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 +174 -19
  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 +96 -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 +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  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 +284 -243
  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
package/src/channel.ts CHANGED
@@ -4,9 +4,9 @@
4
4
  // append-only stream, so tier 1 needs no frame of its own. That is why climbing the ladder is a
5
5
  // config change: the client's frame handler is the same code at every rung.
6
6
 
7
- import type { Actor } from '@ultimat3/core';
7
+ import { type Actor, logger, renderThrowable } from '@ultimat3/core';
8
8
  import { formatLsn } from './changefeed';
9
- import { SubscriptionLimitError, TopicForbiddenError } from './errors';
9
+ import { isPolicyDenial, SubscriptionLimitError, TopicForbiddenError } from './errors';
10
10
  import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
11
11
  import type { JsonObject } from './json';
12
12
  import type { SocketRegistry, SyncSocket } from './socket';
@@ -46,6 +46,30 @@ export interface ChannelHubOptions {
46
46
  readonly transport: Transport;
47
47
  readonly sockets: SocketRegistry;
48
48
  readonly maxTopicsPerSocket?: number;
49
+ /**
50
+ * Distinct topics this node will bridge at once. Each one is a live transport subscription, and
51
+ * `topic()` admits any `[A-Za-z0-9_-]+` segment — so even a guard as tight as `org.<myorg>.>`
52
+ * admits unbounded distinct names inside one tenant, and a per-socket cap bounds nothing.
53
+ */
54
+ readonly maxTopicsPerNode?: number;
55
+ }
56
+
57
+ /** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
58
+ export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
59
+
60
+ /**
61
+ * One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
62
+ * into the table before it is awaited: looked up before the await and written after it, two sockets
63
+ * reaching one topic at once opened two transport subscriptions — the second replacing the first in
64
+ * the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
65
+ * anything else, delivering every message on that topic a second time for the life of the process.
66
+ *
67
+ * `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
68
+ * runs, so the reservation has to exist before there is anything to reserve it with.
69
+ */
70
+ interface Bridge {
71
+ sub: Promise<TransportSubscription> | null;
72
+ refs: number;
49
73
  }
50
74
 
51
75
  /**
@@ -56,14 +80,43 @@ export class ChannelHub {
56
80
  readonly #transport: Transport;
57
81
  readonly #sockets: SocketRegistry;
58
82
  readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
59
- readonly #bridges = new Map<string, { sub: TransportSubscription; refs: number }>();
83
+ readonly #bridges = new Map<string, Bridge>();
84
+ /**
85
+ * Topics this socket has asked for and not yet joined. Weakly keyed, so it needs no teardown
86
+ * path of its own: a socket that dies mid-subscribe takes its claims with it.
87
+ */
88
+ readonly #claimed = new WeakMap<SyncSocket, number>();
60
89
  readonly #maxTopicsPerSocket: number;
90
+ readonly #maxTopicsPerNode: number;
91
+ #guardFailures = 0;
61
92
  #sequence = 0n;
93
+ /** Set by `close()`. Read by `#open`, which is the only thing that can reach a late subscription. */
94
+ #closed = false;
62
95
 
63
96
  constructor(options: ChannelHubOptions) {
64
97
  this.#transport = options.transport;
65
98
  this.#sockets = options.sockets;
66
99
  this.#maxTopicsPerSocket = options.maxTopicsPerSocket ?? 64;
100
+ this.#maxTopicsPerNode = options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE;
101
+ }
102
+
103
+ /** Sockets this node will deliver `name` to. The metric the fanout reads. */
104
+ subscriberCount(name: Topic): number {
105
+ return this.#sockets.subscriberCount(name);
106
+ }
107
+
108
+ /** Distinct topics bridged from this node — one live transport subscription each. */
109
+ get topicCount(): number {
110
+ return this.#bridges.size;
111
+ }
112
+
113
+ /**
114
+ * `channel.guard_failed` for this node: guards that raised instead of deciding, during a re-auth.
115
+ * Never a denial — the same split `LiveQueryRegistry.reauthorize` makes one layer up, and an
116
+ * alert fires on one of them.
117
+ */
118
+ get guardFailures(): number {
119
+ return this.#guardFailures;
67
120
  }
68
121
 
69
122
  /** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
@@ -72,36 +125,87 @@ export class ChannelHub {
72
125
  return this;
73
126
  }
74
127
 
128
+ /**
129
+ * Both caps and the node's bridge slot are taken SYNCHRONOUSLY, before the guard is awaited: read
130
+ * at the top and acted on after two awaits, one WebSocket write carrying N subscribe frames
131
+ * passed each of them N times, and `maxTopicsPerSocket`/`maxTopicsPerNode` bounded nothing.
132
+ */
75
133
  async subscribe(socket: SyncSocket, name: Topic): Promise<void> {
76
134
  if (socket.topics.has(name)) return;
77
- if (socket.topics.size >= this.#maxTopicsPerSocket) {
135
+ const claimed = this.#claimed.get(socket) ?? 0;
136
+ if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
78
137
  throw new SubscriptionLimitError({
79
138
  scope: 'socket',
80
139
  id: socket.id,
81
140
  limit: this.#maxTopicsPerSocket,
141
+ // Named, never defaulted: the default for this scope is `maxPerSocket`, which is
142
+ // `LiveQueryRegistry`'s cap on live subscriptions — a different ceiling in a different
143
+ // constructor, so an operator following this fix line would have moved the wrong number.
144
+ knob: 'maxTopicsPerSocket',
82
145
  });
83
146
  }
84
- await this.#authorize(socket.actor, name);
85
- await this.#bridge(name);
86
- socket.subscribeTopic(name);
147
+ // Refused before the guard runs and before a transport subscription is opened: a node that is
148
+ // out of topics has nothing to decide, and the answer must not depend on who asked.
149
+ const bridge = this.#reserve(name);
150
+ this.#claimed.set(socket, claimed + 1);
151
+ try {
152
+ await this.#authorize(socket.actor, name);
153
+ await this.#open(name, bridge);
154
+ } catch (error) {
155
+ // The slot this subscribe took, given back on the one path that will never fill it.
156
+ this.#release(name);
157
+ throw error;
158
+ } finally {
159
+ const held = this.#claimed.get(socket) ?? 1;
160
+ if (held <= 1) this.#claimed.delete(socket);
161
+ else this.#claimed.set(socket, held - 1);
162
+ }
163
+ // A concurrent subscribe for this same socket and topic got there first: it holds the one
164
+ // membership this socket's close will give back, so the reference taken above has to go now or
165
+ // it is a bridge nothing will ever release.
166
+ if (socket.topics.has(name)) {
167
+ this.#release(name);
168
+ return;
169
+ }
170
+ // Through the registry, never `socket.subscribeTopic` directly: membership and the index the
171
+ // fanout reads are one fact, and two call sites for one fact is the drift that makes an index
172
+ // wrong. The registry owns it because it is the only thing that sees a socket die.
173
+ this.#sockets.joinTopic(socket, name);
87
174
  }
88
175
 
89
176
  unsubscribe(socket: SyncSocket, name: Topic): void {
90
177
  if (!socket.topics.has(name)) return;
91
- socket.unsubscribeTopic(name);
178
+ this.#sockets.leaveTopic(socket, name);
92
179
  this.#release(name);
93
180
  }
94
181
 
95
- /** Called when a socket's session changes (login, logout, role change, token refresh). */
182
+ /**
183
+ * Called when a socket's session changes (login, logout, role change, token refresh).
184
+ *
185
+ * A denial drops the topic; anything else keeps it. A guard is app code and may reach a database,
186
+ * so `catch { unsubscribe }` reported a store that timed out as a revoked grant — during one
187
+ * outage, every topic on every re-authenticated socket on the node, silently, with the client
188
+ * never told to resubscribe. The same split `LiveQueryRegistry.reauthorize` already makes, and
189
+ * for the same reason: a failure is not a decision.
190
+ */
96
191
  async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
97
192
  socket.actor = actor;
98
193
  const dropped: Topic[] = [];
99
194
  for (const name of [...socket.topics] as Topic[]) {
100
195
  try {
101
196
  await this.#authorize(actor, name);
102
- } catch {
103
- this.unsubscribe(socket, name);
104
- dropped.push(name);
197
+ } catch (error) {
198
+ if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
199
+ this.unsubscribe(socket, name);
200
+ dropped.push(name);
201
+ continue;
202
+ }
203
+ this.#guardFailures += 1;
204
+ logger.warn('channel.guard_failed', {
205
+ topic: name,
206
+ socketId: socket.id,
207
+ error: renderThrowable(error),
208
+ });
105
209
  }
106
210
  }
107
211
  return dropped;
@@ -120,7 +224,14 @@ export class ChannelHub {
120
224
  }
121
225
 
122
226
  async close(): Promise<void> {
123
- for (const bridge of this.#bridges.values()) bridge.sub.unsubscribe();
227
+ // Set BEFORE the table is walked, because the table is not the whole story: a reservation an
228
+ // in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
229
+ // nothing to it and `clear()` drops the entry. That open then lands on a `Bridge` nothing can
230
+ // name — `#release` looks the topic up, misses and returns — and its handler keeps calling
231
+ // `deliver` for the life of the process. The same orphan the `Bridge` comment describes, one
232
+ // state earlier, so the open that creates the subscription has to be the thing that closes it.
233
+ this.#closed = true;
234
+ for (const bridge of this.#bridges.values()) unsubscribeWhenOpen(bridge);
124
235
  this.#bridges.clear();
125
236
  }
126
237
 
@@ -147,17 +258,46 @@ export class ChannelHub {
147
258
  }
148
259
  }
149
260
 
150
- /** One transport subscription per topic per node, refcounted across sockets. */
151
- async #bridge(name: Topic): Promise<void> {
261
+ /**
262
+ * The node's slot for this topic, taken synchronously. One bridge per topic per node, refcounted
263
+ * across sockets — and the refcount includes the subscribes still deciding, so the count the node
264
+ * cap reads is the count that will exist.
265
+ */
266
+ #reserve(name: Topic): Bridge {
152
267
  const existing = this.#bridges.get(name);
153
268
  if (existing) {
154
269
  existing.refs += 1;
155
- return;
270
+ return existing;
271
+ }
272
+ if (this.#bridges.size >= this.#maxTopicsPerNode) {
273
+ throw new SubscriptionLimitError({
274
+ scope: 'node',
275
+ id: 'topics',
276
+ limit: this.#maxTopicsPerNode,
277
+ knob: 'maxTopicsPerNode',
278
+ });
156
279
  }
157
- const sub = await this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
280
+ const created: Bridge = { sub: null, refs: 1 };
281
+ this.#bridges.set(name, created);
282
+ return created;
283
+ }
284
+
285
+ /** Opens the reserved bridge once, and shares the in-flight open with everyone else waiting. */
286
+ async #open(name: Topic, bridge: Bridge): Promise<void> {
287
+ // Published into the bridge before it is awaited: that is what makes a second subscriber join
288
+ // this open instead of starting a second one the table can never reach again.
289
+ bridge.sub ??= this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
158
290
  this.#sockets.deliver(name, decode(payload));
159
291
  });
160
- this.#bridges.set(name, { sub, refs: 1 });
292
+ await bridge.sub;
293
+ // The hub shut down while the transport was answering. `close()` either never saw this bridge
294
+ // or saw it with nothing to close, so this is the last reference to the subscription: it closes
295
+ // here or never. The entry goes with it, so a second post-close subscribe opens and closes its
296
+ // own rather than double-unsubscribing this one's handle.
297
+ if (this.#closed) {
298
+ unsubscribeWhenOpen(bridge);
299
+ if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
300
+ }
161
301
  }
162
302
 
163
303
  #release(name: Topic): void {
@@ -165,11 +305,26 @@ export class ChannelHub {
165
305
  if (!bridge) return;
166
306
  bridge.refs -= 1;
167
307
  if (bridge.refs > 0) return;
168
- bridge.sub.unsubscribe();
308
+ unsubscribeWhenOpen(bridge);
169
309
  this.#bridges.delete(name);
170
310
  }
171
311
  }
172
312
 
313
+ /**
314
+ * A bridge released while its subscription is still opening still has to be closed — the transport
315
+ * hands the handle back after the caller has gone, and dropping the promise would leave a live
316
+ * subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
317
+ * rejection was already answered to the subscriber that caused it.
318
+ */
319
+ function unsubscribeWhenOpen(bridge: Bridge): void {
320
+ void bridge.sub?.then(
321
+ (sub) => {
322
+ sub.unsubscribe();
323
+ },
324
+ () => undefined,
325
+ );
326
+ }
327
+
173
328
  export function channelFrame(name: Topic, lsn: string, message: JsonObject): Frame {
174
329
  return {
175
330
  type: 'patch',
@@ -0,0 +1,81 @@
1
+ // What a client IS, as types: the injected seams, the options, and the handles a subscription
2
+ // gives back. Declared apart from the client that implements them for the same reason
3
+ // `live-contract.ts` is — the hooks, the typed projection, the type pins and the mutation path all
4
+ // need these shapes, and none of them needs the connection lifecycle that runs underneath.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import type { LiveCursor } from './cursor';
8
+ import type { JsonValue, Row } from './json';
9
+ import type { LiveState } from './live-rows';
10
+ import type { LocalStore, LocalTx, TableMap } from './local-store';
11
+ import type { OfflineQueue } from './offline-queue';
12
+ import type { ConflictStrategy, RebaseLog } from './rebase';
13
+ import type { BackoffPolicy, Rng, Scheduler } from './thundering-herd';
14
+
15
+ /** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
16
+ export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
17
+
18
+ /** Injected socket, so tests drive the protocol without a network. */
19
+ export interface ClientSocket {
20
+ send(data: string): void;
21
+ close(code?: number, reason?: string): void;
22
+ onOpen(handler: () => void): void;
23
+ onMessage(handler: (data: string) => void): void;
24
+ onClose(handler: (code: number) => void): void;
25
+ /**
26
+ * Bytes queued but not yet on the wire — `WebSocket.bufferedAmount`. Optional because a socket
27
+ * that cannot answer is treated as never backed up; supplying it is what lets the mutation drain
28
+ * stop instead of pushing a queue the tab is not draining into one it cannot see.
29
+ */
30
+ readonly bufferedAmount?: number;
31
+ }
32
+
33
+ export interface LiveHandle<R extends Row = Row> extends Disposable {
34
+ /** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
35
+ readonly rows: () => readonly R[];
36
+ readonly state: () => LiveState;
37
+ readonly cursor: () => LiveCursor | null;
38
+ unsubscribe(): void;
39
+ /** The same call as `unsubscribe`, so `using sub = client.useLive(...)` just works. */
40
+ [Symbol.dispose](): void;
41
+ }
42
+
43
+ /** What `subscribe()` returns for a tier-1 topic: callable to unsubscribe, and `using`-able too. */
44
+ export type Unsubscribe = (() => void) & Disposable;
45
+
46
+ export interface LiveQueryRef {
47
+ readonly name: string;
48
+ }
49
+
50
+ export interface MutatorRef<T extends TableMap = TableMap> {
51
+ readonly name: string;
52
+ /** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
53
+ local?: (tx: LocalTx<T>, input: JsonValue) => void;
54
+ readonly entity?: string;
55
+ readonly conflict?: ConflictStrategy;
56
+ }
57
+
58
+ export interface LiveClientOptions<T extends TableMap = TableMap> {
59
+ readonly signal: SignalFactory;
60
+ /** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
61
+ readonly connect: () => ClientSocket;
62
+ readonly buildId: string;
63
+ readonly actorId?: string | null;
64
+ /** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
65
+ readonly store?: LocalStore<T>;
66
+ readonly queue?: OfflineQueue;
67
+ readonly log?: RebaseLog<T>;
68
+ readonly backoff?: BackoffPolicy;
69
+ readonly rng?: Rng;
70
+ readonly clock?: Clock;
71
+ /** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
72
+ readonly scheduler?: Scheduler;
73
+ /**
74
+ * How often a live socket re-announces itself, in ms. `0` disables it. Defaults to
75
+ * `DEFAULT_HEARTBEAT_MS` — the same 15s as the server's `realtime.heartbeatMs`, which browser
76
+ * code cannot read, so it is an option here rather than a value shipped down the wire.
77
+ */
78
+ readonly heartbeatMs?: number;
79
+ /** Where a dial failure inside the reconnect timer is reported. Defaults to `reportToConsole`. */
80
+ readonly onError?: (error: unknown) => void;
81
+ }
@@ -0,0 +1,175 @@
1
+ // What a RECEIVED frame does to client state — the mirror of `sync-node.ts`'s inbound handler,
2
+ // and the only inbound surface `client.ts` exposes. `ClientFrameTarget` is the point: it names
3
+ // every piece of the client a frame may touch, so the blast radius of a new frame kind is a
4
+ // reviewable list rather than "whatever the router could reach through `this`".
5
+
6
+ import { advance } from './cursor';
7
+ import type { JsonObject, JsonValue } from './json';
8
+ import type { Registration, RowWindows } from './live-rows';
9
+ import type { LocalStore, TableMap } from './local-store';
10
+ import type { OfflineQueue } from './offline-queue';
11
+ import { type RebaseLog, reconcile, rollbackMutation } from './rebase';
12
+ import type { Frame, PresenceMember } from './sync-protocol';
13
+
14
+ /** Declared with the window it projects; re-exported here because the router is what writes it. */
15
+ export type { LiveState, Registration } from './live-rows';
16
+
17
+ /**
18
+ * Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
19
+ * client itself could touch the reconnect timer, the socket and the outbound path, none of which
20
+ * a received frame has any business writing.
21
+ */
22
+ export interface ClientFrameTarget<T extends TableMap = TableMap> {
23
+ registration(sid: string): Registration | undefined;
24
+ /** The projection every live window renders through. Rows live in its map, never on a frame. */
25
+ readonly windows: RowWindows;
26
+ topicHandlers(topic: string): ReadonlySet<(message: JsonObject) => void> | undefined;
27
+ readonly queue: OfflineQueue | undefined;
28
+ readonly store: LocalStore<T> | undefined;
29
+ readonly log: RebaseLog<T> | undefined;
30
+ /** The client's clock. A cursor carries `at`, and nothing here may read `Date.now()`. */
31
+ now(): number;
32
+ /** A newer build is live; the app decides when to reload. */
33
+ setUpdate(buildId: string | null): void;
34
+ /** The node assigned this socket its own delay before closing it. */
35
+ scheduleReconnect(afterMs: number | null): void;
36
+ closeSocket(code: number, reason: string): void;
37
+ notifyQueueChange(): void;
38
+ /** Where a promise nobody awaits reports its failure. The client's `onError`, never a swallow. */
39
+ detach(work: Promise<unknown>): void;
40
+ }
41
+
42
+ /**
43
+ * The server refused a mutation: undo its optimistic half. Tier 2 has neither a store nor a log,
44
+ * so there is nothing optimistic to undo and the queue entry is the whole record.
45
+ */
46
+ function rollbackFailed<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
47
+ const store = target.store;
48
+ const log = target.log;
49
+ if (!store || !log) return;
50
+ // One batch for the whole undo: the rollback and every mutator replayed behind it are one
51
+ // frame's worth of change, so a live window holding those rows renders once.
52
+ store.identity.batch(() => {
53
+ rollbackMutation({ store, log, key });
54
+ });
55
+ }
56
+
57
+ /**
58
+ * The server took it, so the write is no longer optimistic: the journal goes (there is nothing to
59
+ * roll back TO any more — this write is what the server has) and the rebase entry goes with it, or
60
+ * every later reconcile replays a mutation the server already applied, over rows that have moved
61
+ * on. The row itself stays exactly as the twin left it — an accepted write does not flicker.
62
+ *
63
+ * Both calls are no-ops for a key nothing holds, which is what makes this safe as the tail of the
64
+ * `rebase` + `ack` pair: the rebase in front of it has already reconciled and dropped the same key.
65
+ */
66
+ function commitAccepted<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
67
+ target.store?.commit(key);
68
+ target.log?.drop(key);
69
+ }
70
+
71
+ /** Presence members cross the topic channel as plain JSON, like every other channel message. */
72
+ function memberJson(member: PresenceMember): JsonValue {
73
+ return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
74
+ }
75
+
76
+ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrameTarget<T>): void {
77
+ switch (frame.type) {
78
+ case 'snapshot': {
79
+ const registration = target.registration(frame.sid);
80
+ if (!registration) return;
81
+ // The entity is the server's, and it is what upgrades this window from its own private scope
82
+ // to the one every other query over the same entity shares.
83
+ target.windows.snapshot(registration, frame.entity ?? null, frame.rows);
84
+ registration.cursor = frame.cursor;
85
+ registration.setCursor(frame.cursor);
86
+ registration.setState('live');
87
+ return;
88
+ }
89
+ case 'patch': {
90
+ const registration = target.registration(frame.sid);
91
+ if (registration) {
92
+ target.windows.patch(registration, frame.patches);
93
+ // The cursor moves with the patches, not only with a snapshot. Left behind, `cursor.at`
94
+ // froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for
95
+ // every client connected longer than `maxLagMs` — the delta resume the retained change
96
+ // window exists for, dead exactly during the deploy storm it was built for. An empty lsn
97
+ // is a tier-1 channel frame's, so it never rewinds one.
98
+ if (registration.cursor && frame.lsn !== '') {
99
+ const next = advance(registration.cursor, frame.patches, frame.lsn, target.now());
100
+ registration.cursor = next;
101
+ registration.setCursor(next);
102
+ }
103
+ registration.setState('live');
104
+ return;
105
+ }
106
+ // No registration: it is a tier-1 channel message on `sid = topic`.
107
+ const handlers = target.topicHandlers(frame.sid);
108
+ if (!handlers) return;
109
+ for (const patch of frame.patches) {
110
+ if (patch.row === null) continue;
111
+ for (const handler of handlers) handler(patch.row);
112
+ }
113
+ return;
114
+ }
115
+ case 'ack': {
116
+ const queue = target.queue;
117
+ // A refused mutation is not a mutation: its optimistic twin has to come off the screen, and
118
+ // its rebase entry has to leave the log, or a denied write stays rendered forever and every
119
+ // later reconcile replays it. `ref` is the mutation key — the same key the `mutate` frame
120
+ // carried — which is what makes both halves reachable from one frame.
121
+ if (frame.error) rollbackFailed(frame.ref, target);
122
+ else commitAccepted(frame.ref, target);
123
+ // `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
124
+ // than notifying right after the call keeps this correct even if that ordering ever
125
+ // changes, and it still fires exactly once the persisted write actually lands.
126
+ const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
127
+ if (settled) target.detach(settled.then(() => target.notifyQueueChange()));
128
+ return;
129
+ }
130
+ case 'rebase': {
131
+ const store = target.store;
132
+ const log = target.log;
133
+ if (!store || !log) return;
134
+ // One batch for the whole reconcile — a rollback, server truth and every replayed mutator
135
+ // are one frame's worth of change, so a live window holding those rows renders once.
136
+ store.identity.batch(() => {
137
+ reconcile({
138
+ store,
139
+ log,
140
+ ack: {
141
+ key: frame.key,
142
+ entity: frame.entity,
143
+ id: frame.row?.id ?? frame.key,
144
+ row: frame.row,
145
+ },
146
+ });
147
+ });
148
+ return;
149
+ }
150
+ case 'reconnect': {
151
+ // Order is load-bearing: arming first is what makes the close this triggers keep the delay
152
+ // the node assigned to *this* socket instead of falling back to a local backoff.
153
+ target.scheduleReconnect(frame.afterMs);
154
+ target.closeSocket(1001, frame.reason);
155
+ return;
156
+ }
157
+ case 'update-available': {
158
+ target.setUpdate(frame.buildId);
159
+ return;
160
+ }
161
+ case 'presence': {
162
+ const handlers = target.topicHandlers(frame.topic);
163
+ if (!handlers) return;
164
+ const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
165
+ for (const handler of handlers) handler(message);
166
+ return;
167
+ }
168
+ case 'hello':
169
+ case 'subscribe':
170
+ case 'mutate':
171
+ // Client-authored frames: never received. Ignored rather than thrown, so a future
172
+ // bidirectional use of the same kind cannot break an old client.
173
+ return;
174
+ }
175
+ }
@@ -0,0 +1,77 @@
1
+ // The client's liveness pass: re-announce this socket before the node forgets it, and notice a
2
+ // socket that has stopped answering. A policy (when to beat, when to give up), not a wire detail —
3
+ // which is why it is here and not inside `client.ts`'s connection lifecycle.
4
+
5
+ import type { Scheduler } from './thundering-herd';
6
+
7
+ /**
8
+ * The same 15s as `realtime.heartbeatMs` in `@ultimat3/core`'s config, restated rather than read:
9
+ * that value is server configuration and this is browser code, so the client cannot reach it. The
10
+ * two are kept equal on purpose — a node's presence TTL is sized against this interval.
11
+ */
12
+ export const DEFAULT_HEARTBEAT_MS = 15_000;
13
+
14
+ export interface HeartbeatOptions {
15
+ /** `0` (or less) disables the pass entirely — the shape a test that owns the clock wants. */
16
+ readonly intervalMs: number;
17
+ readonly schedule: Scheduler;
18
+ readonly now: () => number;
19
+ /** Re-announces this socket. Called only while the socket is still answering. */
20
+ readonly beat: () => void;
21
+ /** Two windows of silence: the socket is half-open and only this client can end it. */
22
+ readonly onSilence: () => void;
23
+ }
24
+
25
+ /**
26
+ * One armed tick at a time, re-armed by itself. It is deliberately NOT an interval: the reconnect
27
+ * timer is the same injected `Scheduler` seam, and a client is either beating on a live socket or
28
+ * backing off towards a new one — never both, so one armed timer is the whole mechanism.
29
+ */
30
+ export class Heartbeat {
31
+ readonly #options: HeartbeatOptions;
32
+ #cancel: (() => void) | null = null;
33
+ #lastSeen = 0;
34
+
35
+ constructor(options: HeartbeatOptions) {
36
+ this.#options = options;
37
+ }
38
+
39
+ /** The socket is up. `now` seeds the silence window, so the first tick judges this connection. */
40
+ start(now: number): void {
41
+ this.stop();
42
+ if (this.#options.intervalMs <= 0) return;
43
+ this.#lastSeen = now;
44
+ this.#arm();
45
+ }
46
+
47
+ /** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
48
+ saw(now: number): void {
49
+ this.#lastSeen = now;
50
+ }
51
+
52
+ stop(): void {
53
+ const cancel = this.#cancel;
54
+ this.#cancel = null;
55
+ cancel?.();
56
+ }
57
+
58
+ #arm(): void {
59
+ this.#cancel = this.#options.schedule(() => {
60
+ this.#cancel = null;
61
+ this.#tick();
62
+ }, this.#options.intervalMs);
63
+ }
64
+
65
+ #tick(): void {
66
+ const now = this.#options.now();
67
+ // Two windows, not one: a beat and the answer to it share the window they were sent in, so a
68
+ // single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
69
+ // a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
70
+ if (now - this.#lastSeen > this.#options.intervalMs * 2) {
71
+ this.#options.onSilence();
72
+ return;
73
+ }
74
+ this.#options.beat();
75
+ this.#arm();
76
+ }
77
+ }