@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/client.ts CHANGED
@@ -1,84 +1,65 @@
1
1
  // The client half. Framework-agnostic on purpose: the reactive primitive is injected, so this
2
- // package never imports solid-js and can be exercised by `bun test` with two closures.
3
- //
4
- // One client serves all three tiers. `useLive` is tier 2; passing a `store` + `queue` makes the
5
- // same call tier 3. Nothing about the subscription changes — that is the ladder's whole promise.
2
+ // package never imports solid-js and can be exercised by `bun test` with two closures. One client
3
+ // serves all three tiers: `useLive` is tier 2, and a `store` + `queue` makes the same call tier 3
4
+ // with nothing about the subscription changing that is the ladder's whole promise.
6
5
 
7
6
  import { type Clock, systemClock, uuid } from '@ultimat3/core';
8
7
  import type { Topic } from './channel';
8
+ import type {
9
+ ClientSocket,
10
+ LiveClientOptions,
11
+ LiveHandle,
12
+ LiveQueryRef,
13
+ MutatorRef,
14
+ SignalFactory,
15
+ Unsubscribe,
16
+ } from './client-contract';
17
+ import { applyFrame, type ClientFrameTarget } from './client-frames';
18
+ import { DEFAULT_HEARTBEAT_MS, Heartbeat } from './client-heartbeat';
19
+ import { type MutationDeps, mutationSender, recordMutation } from './client-mutations';
20
+ import { TopicBook, topicSubscribeFrame } from './client-topics';
9
21
  import type { LiveCursor } from './cursor';
10
- import type { JsonObject, JsonValue, Row, RowPatch } from './json';
11
- import type { LocalStore, LocalTx, TableMap } from './local-store';
12
- import { mutateFrame, type OfflineQueue } from './offline-queue';
13
- import { type ConflictStrategy, type RebaseLog, reconcile } from './rebase';
14
- import { decode, encode, type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
15
- import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
22
+ import { IdentityMap, privateScope } from './identity-map';
23
+ import type { JsonObject, JsonValue, Row } from './json';
24
+ import { type LiveState, type Registration, RowWindows } from './live-rows';
25
+ import type { TableMap } from './local-store';
26
+ import type { OfflineQueue } from './offline-queue';
27
+ import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
28
+ import { backoffDelay, defaultBackoff, timeoutScheduler } from './thundering-herd';
16
29
 
17
- /** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
18
- export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
30
+ /**
31
+ * The client's own shapes, re-exported from where they are declared: an app imports `ClientSocket`
32
+ * and `LiveClientOptions` from the client it configures, not from a file it never names.
33
+ */
34
+ export type {
35
+ ClientSocket,
36
+ LiveClientOptions,
37
+ LiveHandle,
38
+ LiveQueryRef,
39
+ MutatorRef,
40
+ SignalFactory,
41
+ Unsubscribe,
42
+ } from './client-contract';
19
43
 
20
- /** Injected socket, so tests drive the protocol without a network. */
21
- export interface ClientSocket {
22
- send(data: string): void;
23
- close(code?: number, reason?: string): void;
24
- onOpen(handler: () => void): void;
25
- onMessage(handler: (data: string) => void): void;
26
- onClose(handler: (code: number) => void): void;
27
- }
28
-
29
- export type LiveState = 'loading' | 'live' | 'stale' | 'offline';
30
-
31
- export interface LiveHandle<R extends Row = Row> {
32
- /** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
33
- readonly rows: () => readonly R[];
34
- readonly state: () => LiveState;
35
- readonly cursor: () => LiveCursor | null;
36
- unsubscribe(): void;
37
- }
38
-
39
- export interface LiveQueryRef {
40
- readonly name: string;
41
- }
44
+ /** The four states a live subscription renders. Declared with the window that holds them. */
45
+ export type { LiveState } from './live-rows';
42
46
 
43
- export interface MutatorRef<T extends TableMap = TableMap> {
44
- readonly name: string;
45
- /** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
46
- local?: (tx: LocalTx<T>, input: JsonValue) => void;
47
- readonly entity?: string;
48
- readonly conflict?: ConflictStrategy;
49
- }
50
-
51
- export interface LiveClientOptions<T extends TableMap = TableMap> {
52
- readonly signal: SignalFactory;
53
- /** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
54
- readonly connect: () => ClientSocket;
55
- readonly buildId: string;
56
- readonly actorId?: string | null;
57
- /** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
58
- readonly store?: LocalStore<T>;
59
- readonly queue?: OfflineQueue;
60
- readonly log?: RebaseLog<T>;
61
- readonly backoff?: BackoffPolicy;
62
- readonly rng?: Rng;
63
- readonly clock?: Clock;
64
- }
47
+ /** Private-use close code (4000–4999), so a heartbeat timeout is distinguishable in a log. */
48
+ const HEARTBEAT_TIMEOUT_CODE = 4000;
65
49
 
66
- interface Registration {
67
- readonly sid: string;
68
- readonly name: string;
69
- readonly input: JsonValue;
70
- readonly setRows: (rows: readonly Row[]) => void;
71
- readonly setState: (state: LiveState) => void;
72
- readonly setCursor: (cursor: LiveCursor | null) => void;
73
- rows: readonly Row[];
74
- cursor: LiveCursor | null;
75
- }
50
+ /** The default reporter: `console.error`, never core's `logger` — that writes `process.stderr`. */
51
+ const reportToConsole = (error: unknown): void => {
52
+ console.error(error);
53
+ };
76
54
 
77
55
  export class LiveClient<T extends TableMap = TableMap> {
78
56
  readonly #options: LiveClientOptions<T>;
79
57
  readonly #clock: Clock;
58
+ readonly #onError: (error: unknown) => void;
80
59
  readonly #registrations = new Map<string, Registration>();
81
- readonly #topics = new Map<string, Set<(message: JsonObject) => void>>();
60
+ readonly #windows: RowWindows;
61
+ readonly #topics = new TopicBook();
62
+ readonly #heartbeat: Heartbeat;
82
63
  readonly #setUpdate: (buildId: string | null) => void;
83
64
  readonly #setReconnectAt: (at: number | null) => void;
84
65
 
@@ -91,25 +72,33 @@ export class LiveClient<T extends TableMap = TableMap> {
91
72
  readonly signal: SignalFactory;
92
73
  /** The durable queue when tier 3 is configured, so a queue count is read off the queue itself. */
93
74
  readonly queue: OfflineQueue | undefined;
75
+ /**
76
+ * One row value per `(entity, id)` for this client. Taken from the local store when tier 3 is
77
+ * configured, so an optimistic write and the live query rendering that row are the same row —
78
+ * a second map here would be exactly the duplication an identity map exists to prevent.
79
+ */
80
+ readonly identity: IdentityMap;
94
81
 
95
82
  #socket: ClientSocket | null = null;
96
83
  #attempt = 0;
84
+ /** The armed reconnect's canceller, and the flag for "one timer in flight, the first wins". */
85
+ #reconnectTimer: (() => void) | null = null;
86
+ /** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */
87
+ #closed = false;
97
88
  /** A signal, not a field: `connected` is rendered, so a plain boolean would never re-render. */
98
89
  readonly #connected: () => boolean;
99
90
  readonly #setConnected: (next: boolean) => void;
100
- /**
101
- * Subscribers notified after every offline-queue mutation: a manual drain, the automatic drain
102
- * `connect()` runs on every reconnect, or an async ack/fail frame. `hooks.ts` wires its
103
- * invalidation signal through `onQueueChange` rather than each call site remembering to bump it
104
- * itself — see there for why that matters.
105
- */
91
+ /** Notified after every offline-queue mutation; `onQueueChange` says who subscribes, and why. */
106
92
  readonly #queueListeners = new Set<() => void>();
107
93
 
108
94
  constructor(options: LiveClientOptions<T>) {
109
95
  this.#options = options;
110
96
  this.#clock = options.clock ?? systemClock;
97
+ this.#onError = options.onError ?? reportToConsole;
111
98
  this.signal = options.signal;
112
99
  this.queue = options.queue;
100
+ this.identity = options.store?.identity ?? new IdentityMap();
101
+ this.#windows = new RowWindows(this.identity);
113
102
  const [update, setUpdate] = options.signal<string | null>(null);
114
103
  const [reconnectAt, setReconnectAt] = options.signal<number | null>(null);
115
104
  const [connected, setConnected] = options.signal<boolean>(false);
@@ -119,6 +108,13 @@ export class LiveClient<T extends TableMap = TableMap> {
119
108
  this.#setReconnectAt = setReconnectAt;
120
109
  this.#connected = connected;
121
110
  this.#setConnected = setConnected;
111
+ this.#heartbeat = new Heartbeat({
112
+ intervalMs: options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
113
+ schedule: options.scheduler ?? timeoutScheduler,
114
+ now: () => this.#clock.now().getTime(),
115
+ beat: () => this.#beat(),
116
+ onSilence: () => this.#silent(),
117
+ });
122
118
  }
123
119
 
124
120
  get connected(): boolean {
@@ -126,40 +122,112 @@ export class LiveClient<T extends TableMap = TableMap> {
126
122
  }
127
123
 
128
124
  connect(): void {
125
+ this.#closed = false;
126
+ this.#cancelReconnect();
127
+ // The socket we are replacing goes first. Left open, its `onMessage` keeps running: every
128
+ // patch frame applies twice, and the node holds two sockets for one client — double presence
129
+ // membership and double fanout — until the tab closes. Nulled before the close so the corpse's
130
+ // `onClose` takes its own early return rather than marking the new connection offline.
131
+ const previous = this.#socket;
132
+ this.#socket = null;
133
+ previous?.close(1000, 'reconnect');
134
+ // …and because that corpse's `onClose` returns, this is the only place the connection it was
135
+ // carrying can be written off: offline until the NEW socket opens. Reporting the replaced
136
+ // socket's state through the redial sent a `useLive` opened in that window straight onto an
137
+ // unopened socket — a subscribe frame ahead of `hello`, then a second one for the same sid
138
+ // when `onOpen` replayed it, which the node refuses with X_SUBSCRIPTION_ID_TAKEN.
139
+ //
140
+ // BEFORE the dial, and that order is the whole fix for the other half: `connect` is app code
141
+ // (`new WebSocket(url)` refuses on mixed content, or on a URL the page may not open) and it
142
+ // may throw. `close()` always got the state right on the way down; this path did none of it,
143
+ // so a refused dial left the client reporting itself online with no socket and no armed timer,
144
+ // marking every later mutation delivered into nothing. It still throws to the caller and still
145
+ // arms nothing — only the reconnect timer owns the retry chain.
146
+ this.#offline();
129
147
  const socket = this.#options.connect();
130
148
  this.#socket = socket;
131
149
  socket.onOpen(() => {
150
+ // A frame speaks only for its own socket — the same guard `onMessage` and `onClose` carry,
151
+ // and the one handler that had none. A replaced socket opening late would otherwise mark the
152
+ // live connection up and replay every subscription onto whatever socket is current.
153
+ if (this.#socket !== socket) return;
132
154
  this.#setConnected(true);
133
155
  this.#attempt = 0;
134
156
  this.#setReconnectAt(null);
135
- this.#send({
136
- type: 'hello',
137
- v: PROTOCOL_VERSION,
138
- buildId: this.#options.buildId,
139
- sessionId: null,
140
- actorId: this.#options.actorId ?? null,
141
- resume: [...this.#registrations.values()]
142
- .map((registration) => registration.cursor)
143
- .filter((cursor): cursor is LiveCursor => cursor !== null),
144
- });
157
+ // `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
158
+ // frame below, which is the only place resume is decided — sending it here too shipped every
159
+ // cursor twice per reconnect, once into a field the node discards.
160
+ this.#send(this.#hello());
145
161
  for (const registration of this.#registrations.values()) this.#sendSubscribe(registration);
146
- void this.drain();
162
+ // Topic membership lives on the node's socket and `hello` carries none of it, so a channel
163
+ // this client still holds a handler for is silent from the first reconnect onwards — and its
164
+ // presence membership is swept — unless every one of them is re-announced here.
165
+ for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
166
+ this.#heartbeat.start(this.#clock.now().getTime());
167
+ this.#detach(this.drain());
147
168
  });
148
169
  socket.onMessage((data) => {
149
- this.#onFrame(decode(data));
170
+ // A frame speaks only for its own socket, the same rule `onClose` follows. A replaced socket
171
+ // that is still draining bytes would otherwise fold its patches into the live registrations
172
+ // a second time, over newer state.
173
+ if (this.#socket !== socket) return;
174
+ this.#heartbeat.saw(this.#clock.now().getTime());
175
+ applyFrame(decode(data), this.#frameTarget);
150
176
  });
151
177
  socket.onClose(() => {
152
- this.#setConnected(false);
153
- for (const registration of this.#registrations.values()) registration.setState('offline');
154
- this.#scheduleReconnect(null);
178
+ // A close speaks only for its own socket: `connect()` may already have installed a newer one,
179
+ // and a corpse marking the live connection offline and arming a backoff is a working socket
180
+ // killed by a dead one. Dropping ours first keeps fire-and-forget `#send` out of the corpse.
181
+ if (this.#socket !== socket) return;
182
+ this.#socket = null;
183
+ this.#offline();
184
+ // A `reconnect` frame armed the server's own delay before closing us; rescheduling here would
185
+ // replace the delay the node assigned with a local backoff and re-cluster the herd it spread.
186
+ if (this.#reconnectTimer === null) this.#scheduleReconnect(null);
155
187
  });
156
188
  }
157
189
 
190
+ /**
191
+ * Everything a lost connection costs, whoever noticed it — a close, a replacement, an explicit
192
+ * teardown, a heartbeat that timed out. The queue half is the one that is easy to forget: a
193
+ * mutation handed to a socket that is now gone was never acknowledged, so it goes back in the
194
+ * queue rather than waiting for an ack nobody will send.
195
+ */
196
+ #offline(): void {
197
+ this.#heartbeat.stop();
198
+ this.#setConnected(false);
199
+ // Told once, not two ways: a `useConnection().offline` that flips while a `useLive` handle
200
+ // still reads 'live' is one dead socket rendered as two states.
201
+ for (const registration of this.#registrations.values()) registration.setState('offline');
202
+ const queue = this.#options.queue;
203
+ if (queue) this.#detach(queue.requeueInflight());
204
+ }
205
+
206
+ /**
207
+ * Explicit teardown: cancels the armed reconnect and drops the socket. Without it a client whose
208
+ * owner is gone keeps waking up and dialling forever — the timer is the only thing still holding
209
+ * it alive. `connect()` starts over, so this is a stop, not a tombstone.
210
+ */
211
+ close(code = 1000, reason = 'client closed'): void {
212
+ this.#closed = true;
213
+ this.#cancelReconnect();
214
+ this.#setReconnectAt(null);
215
+ this.#attempt = 0;
216
+ const socket = this.#socket;
217
+ this.#socket = null;
218
+ socket?.close(code, reason);
219
+ // The close this triggers is a dropped socket's, so it returns: going offline is our job now.
220
+ this.#offline();
221
+ }
222
+
158
223
  /** Tier 2 and tier 3 alike. The returned accessor is the reactive result set. */
159
224
  useLive<R extends Row = Row>(query: LiveQueryRef, input: JsonValue): LiveHandle<R> {
160
225
  const sid = uuid();
161
226
  const [rows, setRows] = this.#options.signal<readonly Row[]>([]);
162
- const [state, setState] = this.#options.signal<LiveState>('loading');
227
+ // 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
228
+ const [state, setState] = this.#options.signal<LiveState>(
229
+ this.#connected() ? 'loading' : 'offline',
230
+ );
163
231
  const [cursor, setCursor] = this.#options.signal<LiveCursor | null>(null);
164
232
  const registration: Registration = {
165
233
  sid,
@@ -168,51 +236,46 @@ export class LiveClient<T extends TableMap = TableMap> {
168
236
  setRows,
169
237
  setState,
170
238
  setCursor,
171
- rows: [],
239
+ // Private until the first snapshot names the entity: sharing rows with another query on a
240
+ // scope nobody confirmed would merge two entities that spell one id the same way.
241
+ scope: privateScope(query.name),
242
+ ids: [],
172
243
  cursor: null,
173
244
  };
174
245
  this.#registrations.set(sid, registration);
246
+ const close = this.#windows.open(registration);
175
247
  if (this.#connected()) this.#sendSubscribe(registration);
248
+ const unsubscribe = (): void => {
249
+ this.#registrations.delete(sid);
250
+ close();
251
+ this.#send({
252
+ type: 'subscribe',
253
+ v: PROTOCOL_VERSION,
254
+ op: 'drop',
255
+ sid,
256
+ target: { kind: 'query', qid: query.name, input, cursor: null },
257
+ });
258
+ };
176
259
  return {
177
260
  rows: rows as () => readonly R[],
178
261
  state,
179
262
  cursor,
180
- unsubscribe: () => {
181
- this.#registrations.delete(sid);
182
- this.#send({
183
- type: 'subscribe',
184
- v: PROTOCOL_VERSION,
185
- op: 'drop',
186
- sid,
187
- target: { kind: 'query', qid: query.name, input, cursor: null },
188
- });
189
- },
263
+ unsubscribe,
264
+ [Symbol.dispose]: unsubscribe,
190
265
  };
191
266
  }
192
267
 
193
- subscribe(name: Topic, handler: (message: JsonObject) => void): () => void {
194
- const handlers = this.#topics.get(name) ?? new Set<(message: JsonObject) => void>();
195
- handlers.add(handler);
196
- this.#topics.set(name, handlers);
197
- this.#send({
198
- type: 'subscribe',
199
- v: PROTOCOL_VERSION,
200
- op: 'add',
201
- sid: name,
202
- target: { kind: 'topic', topic: name },
203
- });
204
- return () => {
205
- handlers.delete(handler);
206
- if (handlers.size > 0) return;
207
- this.#topics.delete(name);
208
- this.#send({
209
- type: 'subscribe',
210
- v: PROTOCOL_VERSION,
211
- op: 'drop',
212
- sid: name,
213
- target: { kind: 'topic', topic: name },
214
- });
268
+ subscribe(name: Topic, handler: (message: JsonObject) => void): Unsubscribe {
269
+ this.#topics.add(name, handler);
270
+ this.#send(topicSubscribeFrame(name, 'add'));
271
+ // A function is an object: attaching `[Symbol.dispose]` keeps the existing callable contract
272
+ // (`const unsub = channel.subscribe(...); unsub()`) intact while adding `using sub = ...`.
273
+ const unsubscribe: Unsubscribe = (): void => {
274
+ if (!this.#topics.remove(name, handler)) return;
275
+ this.#send(topicSubscribeFrame(name, 'drop'));
215
276
  };
277
+ unsubscribe[Symbol.dispose] = unsubscribe;
278
+ return unsubscribe;
216
279
  }
217
280
 
218
281
  /** Tier 1 publish. The server re-checks the topic policy; this is a request, not an assertion. */
@@ -227,59 +290,35 @@ export class LiveClient<T extends TableMap = TableMap> {
227
290
  }
228
291
 
229
292
  /**
230
- * The mutator entry point. Applies the optimistic twin, records a rebase entry, queues durably,
231
- * then drains. Offline, everything but the drain still happens — that is tier 3's one extra
232
- * property over tier 2.
293
+ * The mutator entry point. Records the optimistic twin, the rebase entry and the durable queue
294
+ * entry, then drains. Offline, everything but the drain still happens — that is tier 3's one
295
+ * extra property over tier 2.
233
296
  */
234
297
  async mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void> {
235
- const idempotencyKey = key ?? `${mutator.name}:${uuid()}`;
236
- const store = this.#options.store;
237
- const queue = this.#options.queue;
238
- const local = mutator.local;
239
- const queued = await queue?.enqueue({
240
- key: idempotencyKey,
241
- name: mutator.name,
242
- input,
243
- at: this.#clock.now().getTime(),
244
- });
245
- if (store && local) {
246
- store.apply(idempotencyKey, (tx) => local(tx, input));
247
- this.#options.log?.record({
248
- key: idempotencyKey,
249
- seq: queued?.seq ?? 0,
250
- entity: mutator.entity ?? mutator.name,
251
- strategy: mutator.conflict ?? 'server-wins',
252
- apply: (tx) => local(tx, input),
253
- });
254
- }
255
- if (!queue) {
256
- this.#send(
257
- mutateFrame({
258
- key: idempotencyKey,
259
- seq: 0,
260
- name: mutator.name,
261
- input,
262
- enqueuedAt: this.#clock.now().getTime(),
263
- attempts: 0,
264
- status: 'pending',
265
- error: null,
266
- }),
267
- );
268
- return;
269
- }
270
- await this.drain();
298
+ await recordMutation(this.#mutations, mutator, input, key);
299
+ if (this.#options.queue) await this.drain();
271
300
  }
272
301
 
273
302
  /** Sends every pending mutation in sequence order. Stops at the first one the socket refuses. */
274
303
  async drain(): Promise<void> {
275
304
  const queue = this.#options.queue;
276
305
  if (!queue || !this.#connected()) return;
277
- await queue.drain(async (mutation) => {
278
- this.#send(mutateFrame(mutation));
279
- });
306
+ await queue.drain(mutationSender(this.#mutations));
280
307
  this.#notifyQueueChange();
281
308
  }
282
309
 
310
+ /** The mutation path's view of this client. Built per call, exactly like `#frameTarget`. */
311
+ get #mutations(): MutationDeps<T> {
312
+ return {
313
+ store: this.#options.store,
314
+ queue: this.#options.queue,
315
+ log: this.#options.log,
316
+ now: () => this.#clock.now().getTime(),
317
+ socket: () => this.#socket,
318
+ send: (frame) => this.#send(frame),
319
+ };
320
+ }
321
+
283
322
  /**
284
323
  * Fires whenever the offline queue changes for any reason: a direct `mutate`/`drain` call, the
285
324
  * automatic drain `connect()` runs on every reconnect, or an async ack/fail frame arriving over
@@ -310,130 +349,125 @@ export class LiveClient<T extends TableMap = TableMap> {
310
349
  });
311
350
  }
312
351
 
313
- #onFrame(frame: Frame): void {
314
- switch (frame.type) {
315
- case 'snapshot': {
316
- const registration = this.#registrations.get(frame.sid);
317
- if (!registration) return;
318
- registration.rows = frame.rows;
319
- registration.cursor = frame.cursor;
320
- registration.setRows(frame.rows);
321
- registration.setCursor(frame.cursor);
322
- registration.setState('live');
323
- return;
324
- }
325
- case 'patch': {
326
- const registration = this.#registrations.get(frame.sid);
327
- if (registration) {
328
- registration.rows = applyPatches(registration.rows, frame.patches);
329
- registration.setRows(registration.rows);
330
- registration.setState('live');
331
- return;
332
- }
333
- // No registration: it is a tier-1 channel message on `sid = topic`.
334
- const handlers = this.#topics.get(frame.sid);
335
- if (!handlers) return;
336
- for (const patch of frame.patches) {
337
- if (patch.row === null) continue;
338
- for (const handler of handlers) handler(patch.row);
339
- }
340
- return;
341
- }
342
- case 'ack': {
343
- const queue = this.#options.queue;
344
- // `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
345
- // than notifying right after the call keeps this correct even if that ordering ever
346
- // changes, and it still fires exactly once the persisted write actually lands.
347
- const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
348
- void settled?.then(() => this.#notifyQueueChange());
349
- return;
350
- }
351
- case 'rebase': {
352
- const store = this.#options.store;
353
- const log = this.#options.log;
354
- if (!store || !log) return;
355
- reconcile({
356
- store,
357
- log,
358
- ack: {
359
- key: frame.key,
360
- entity: frame.entity,
361
- id: frame.row?.id ?? frame.key,
362
- row: frame.row,
363
- },
364
- });
365
- return;
366
- }
367
- case 'reconnect': {
368
- this.#scheduleReconnect(frame.afterMs);
369
- this.#socket?.close(1001, frame.reason);
370
- return;
371
- }
372
- case 'update-available': {
373
- this.#setUpdate(frame.buildId);
374
- return;
375
- }
376
- case 'presence': {
377
- const handlers = this.#topics.get(frame.topic);
378
- if (!handlers) return;
379
- const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
380
- for (const handler of handlers) handler(message);
381
- return;
382
- }
383
- case 'hello':
384
- case 'subscribe':
385
- case 'mutate':
386
- // Client-authored frames: never received. Ignored rather than thrown, so a future
387
- // bidirectional use of the same kind cannot break an old client.
388
- return;
389
- }
352
+ /**
353
+ * The client's inbound surface, handed to the router. Built once: a frame reaches exactly these
354
+ * members and nothing else on the client.
355
+ */
356
+ get #frameTarget(): ClientFrameTarget<T> {
357
+ return {
358
+ registration: (sid) => this.#registrations.get(sid),
359
+ windows: this.#windows,
360
+ topicHandlers: (topic) => this.#topics.handlers(topic),
361
+ queue: this.#options.queue,
362
+ store: this.#options.store,
363
+ log: this.#options.log,
364
+ // The client's clock, never `Date.now()`: a cursor's `at` is what decides a delta resume
365
+ // against a re-snapshot, so the frame path reads the same clock every other path does.
366
+ now: () => this.#clock.now().getTime(),
367
+ setUpdate: (buildId) => this.#setUpdate(buildId),
368
+ scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
369
+ closeSocket: (code, reason) => this.#socket?.close(code, reason),
370
+ notifyQueueChange: () => this.#notifyQueueChange(),
371
+ detach: (work) => this.#detach(work),
372
+ };
390
373
  }
391
374
 
392
- /** Honours a server-assigned delay when there is one; otherwise jittered exponential backoff. */
375
+ /** The opening frame, and the heartbeat's. One shape, because it makes one claim: I am here. */
376
+ #hello(): Frame {
377
+ return {
378
+ type: 'hello',
379
+ v: PROTOCOL_VERSION,
380
+ buildId: this.#options.buildId,
381
+ sessionId: null,
382
+ actorId: this.#options.actorId ?? null,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * One liveness pass, and it buys exactly two things. `hello` provokes an answer on any socket,
388
+ * which is the only way a browser learns a half-open one is dead — nothing else ever will, since
389
+ * a half-open socket fires no `onClose`. Re-sending each topic is the node's own presence
390
+ * heartbeat: subscribing IS being in the room, so a client that stopped repeating it is swept
391
+ * out of every room it is still receiving from.
392
+ *
393
+ * It is NOT how a deploy is noticed. `socket.skewed` compares the build id the upgrade recorded
394
+ * against this node's, both fixed for the socket's whole life, so every `hello` on one socket
395
+ * gets the same answer forever; `update-available` reaches a client on the socket it opens
396
+ * against the *new* node, which is a reconnect and never a beat.
397
+ */
398
+ #beat(): void {
399
+ this.#send(this.#hello());
400
+ for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
401
+ }
402
+
403
+ /**
404
+ * Nothing has come back for two heartbeat windows. A half-open socket fires no `onClose` — that
405
+ * is what makes it half-open — so this client is the only thing that can end it. It is dropped
406
+ * here rather than awaited: a browser `close()` on a black-holed connection can sit in CLOSING
407
+ * until the TCP close handshake times out, and the reconnect must not wait that out.
408
+ */
409
+ #silent(): void {
410
+ const socket = this.#socket;
411
+ this.#socket = null;
412
+ this.#offline();
413
+ socket?.close(HEARTBEAT_TIMEOUT_CODE, 'heartbeat timeout');
414
+ if (this.#reconnectTimer === null) this.#scheduleReconnect(null);
415
+ }
416
+
417
+ /**
418
+ * Honours a server-assigned delay when there is one; otherwise jittered exponential backoff.
419
+ * Publishing `reconnectAt` is the render half — arming the timer is the half that makes the
420
+ * client come back, and `connect()` is the only thing it calls.
421
+ */
393
422
  #scheduleReconnect(serverDelayMs: number | null): void {
423
+ if (this.#closed) return;
424
+ this.#cancelReconnect();
394
425
  const rng = this.#options.rng ?? Math.random;
395
426
  const delay =
396
427
  serverDelayMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? defaultBackoff, rng);
397
428
  this.#attempt += 1;
398
429
  this.#setReconnectAt(this.#clock.now().getTime() + delay);
430
+ const schedule = this.#options.scheduler ?? timeoutScheduler;
431
+ this.#reconnectTimer = schedule(() => {
432
+ // Cleared before dialling, not after: the attempt's own close must be free to arm the next
433
+ // one. `reconnectAt` stays put until the socket opens, so a countdown does not blink to null.
434
+ this.#reconnectTimer = null;
435
+ if (this.#closed) return;
436
+ try {
437
+ this.connect();
438
+ } catch (error) {
439
+ // A socket constructor may refuse (mixed content, a URL the page may not open), and one
440
+ // refusal ending the chain is the same outage as never arming — so the next attempt is put
441
+ // on first. Reported, never rethrown: nothing awaits a timer, so a throw out of one is an
442
+ // uncaught exception that can kill the process that was going to retry. Only this path
443
+ // changes — a `connect()` the app called itself still throws to it, and still arms nothing.
444
+ this.#scheduleReconnect(null);
445
+ this.#onError(error);
446
+ }
447
+ }, delay);
448
+ }
449
+
450
+ #cancelReconnect(): void {
451
+ const cancel = this.#reconnectTimer;
452
+ this.#reconnectTimer = null;
453
+ cancel?.();
399
454
  }
400
455
 
401
456
  #send(frame: Frame): void {
402
457
  this.#socket?.send(encode(frame));
403
458
  }
404
459
 
405
- #notifyQueueChange(): void {
406
- for (const listener of this.#queueListeners) listener();
460
+ /**
461
+ * Work nobody awaits: the drain `onOpen` runs, a queue write from a socket that just died. It
462
+ * bottoms out in `QueueStore.save()` — OPFS or IndexedDB, both allowed to reject — and an
463
+ * unhandled rejection in a tab is `window.onerror`, in Bun a dead process. `onError` is the seam
464
+ * the reconnect timer already reports through; it is never `logger`, which writes stderr.
465
+ */
466
+ #detach(work: Promise<unknown>): void {
467
+ void work.catch(this.#onError);
407
468
  }
408
- }
409
469
 
410
- /** Minimal in-place patch application: the shape a Solid store update maps onto directly. */
411
- export function applyPatches(rows: readonly Row[], patches: readonly RowPatch[]): readonly Row[] {
412
- let next = rows;
413
- for (const patch of patches) {
414
- if (patch.op === 'delete') {
415
- next = next.filter((row) => row.id !== patch.id);
416
- continue;
417
- }
418
- if (patch.row === null) continue;
419
- const index = next.findIndex((row) => row.id === patch.id);
420
- const current = index >= 0 ? next[index] : undefined;
421
- const merged: Row = { ...(current ?? {}), ...patch.row, id: patch.id };
422
- if (index >= 0) {
423
- const copy = [...next];
424
- copy[index] = merged;
425
- next = copy;
426
- } else if (patch.index !== undefined) {
427
- const copy = [...next];
428
- copy.splice(patch.index, 0, merged);
429
- next = copy;
430
- } else {
431
- next = [...next, merged];
432
- }
470
+ #notifyQueueChange(): void {
471
+ for (const listener of this.#queueListeners) listener();
433
472
  }
434
- return next;
435
- }
436
-
437
- function memberJson(member: PresenceMember): JsonValue {
438
- return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
439
473
  }