@ultimat3/realtime 20.2.0 → 21.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 (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
package/src/client.ts CHANGED
@@ -1,44 +1,31 @@
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. 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.
1
+ // The client half of the sync protocol: one socket's lifecycle — dial, beat, reconnect — and the
2
+ // live windows and topics riding it. Framework-agnostic and reactive-runtime-free on purpose: ONE
3
+ // client serves the whole page (every island bundle reaches it through the page handle), and a
4
+ // signal belongs to one bundle's solid-js, so everything here is a plain read plus a listener.
5
+ // Read-only: the socket carries no writes (`useMutation` is HTTP).
5
6
 
6
- import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core';
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';
7
+ import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core/page';
8
+ import {
9
+ ChannelBook,
10
+ type ChannelHandlers,
11
+ type ChannelMembership,
12
+ type ChannelRef,
13
+ } from './client-channels';
14
+ import type { ClientSocket, LiveClientOptions, LiveHandle, LiveQueryRef } from './client-contract';
17
15
  import { applyFrame, type ClientFrameTarget } from './client-frames';
18
16
  import { DEFAULT_HEARTBEAT_MS, Heartbeat } from './client-heartbeat';
19
- import { type MutationDeps, mutationSender, recordMutation } from './client-mutations';
20
- import { TopicBook, topicSubscribeFrame } from './client-topics';
21
17
  import type { LiveCursor } from './cursor';
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';
18
+ import type { JsonObject, JsonValue } from './json';
19
+ import { type LiveState, type Registration, RowWindows, unnamedType } from './live-rows';
20
+ import { RecordStore } from './record-store';
27
21
  import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
28
- import { backoffDelay, defaultBackoff, timeoutScheduler } from './thundering-herd';
22
+ import { backoffDelay, browserBackoff, timeoutScheduler } from './thundering-herd';
29
23
 
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
24
  export type {
35
25
  ClientSocket,
36
- LiveClientLike,
37
26
  LiveClientOptions,
38
27
  LiveHandle,
39
28
  LiveQueryRef,
40
- MutatorRef,
41
- SignalFactory,
42
29
  Unsubscribe,
43
30
  } from './client-contract';
44
31
 
@@ -57,32 +44,17 @@ const reportToConsole = (error: unknown): void => {
57
44
  console.error(error);
58
45
  };
59
46
 
60
- export class LiveClient<T extends TableMap = TableMap> {
61
- readonly #options: LiveClientOptions<T>;
47
+ export class LiveClient {
48
+ readonly #options: LiveClientOptions;
62
49
  readonly #clock: Clock;
63
50
  readonly #onError: (error: unknown) => void;
64
51
  readonly #registrations = new Map<string, Registration>();
65
52
  readonly #windows: RowWindows;
66
- readonly #topics = new TopicBook();
53
+ readonly #channels: ChannelBook;
67
54
  readonly #heartbeat: Heartbeat;
68
- readonly #setUpdate: (buildId: string | null) => void;
69
- readonly #setReconnectAt: (at: number | null) => void;
70
-
71
- readonly appUpdateAvailable: () => string | null;
72
- readonly reconnectAt: () => number | null;
73
- /**
74
- * The reactive primitive the app injected, re-exposed so anything built on this client derives
75
- * its signals from the same runtime. One reactive runtime per app, never two.
76
- */
77
- readonly signal: SignalFactory;
78
- /** The durable queue when tier 3 is configured, so a queue count is read off the queue itself. */
79
- readonly queue: OfflineQueue | undefined;
80
- /**
81
- * One row value per `(entity, id)` for this client. Taken from the local store when tier 3 is
82
- * configured, so an optimistic write and the live query rendering that row are the same row —
83
- * a second map here would be exactly the duplication an identity map exists to prevent.
84
- */
85
- readonly identity: IdentityMap;
55
+ readonly #statusListeners = new Set<() => void>();
56
+ /** The page's record store every window renders out of. */
57
+ readonly store: RecordStore;
86
58
 
87
59
  #socket: ClientSocket | null = null;
88
60
  #attempt = 0;
@@ -90,29 +62,23 @@ export class LiveClient<T extends TableMap = TableMap> {
90
62
  #reconnectTimer: (() => void) | null = null;
91
63
  /** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */
92
64
  #closed = false;
93
- /** A signal, not a field: `connected` is rendered, so a plain boolean would never re-render. */
94
- readonly #connected: () => boolean;
95
- readonly #setConnected: (next: boolean) => void;
96
- /** Notified after every offline-queue mutation; `onQueueChange` says who subscribes, and why. */
97
- readonly #queueListeners = new Set<() => void>();
65
+ #connected = false;
66
+ #reconnectAt: number | null = null;
67
+ #update: string | null = null;
98
68
 
99
- constructor(options: LiveClientOptions<T>) {
69
+ constructor(options: LiveClientOptions) {
100
70
  this.#options = options;
101
71
  this.#clock = options.clock ?? systemClock;
102
72
  this.#onError = options.onError ?? reportToConsole;
103
- this.signal = options.signal;
104
- this.queue = options.queue;
105
- this.identity = options.store?.identity ?? new IdentityMap();
106
- this.#windows = new RowWindows(this.identity);
107
- const [update, setUpdate] = options.signal<string | null>(null);
108
- const [reconnectAt, setReconnectAt] = options.signal<number | null>(null);
109
- const [connected, setConnected] = options.signal<boolean>(false);
110
- this.appUpdateAvailable = update;
111
- this.#setUpdate = setUpdate;
112
- this.reconnectAt = reconnectAt;
113
- this.#setReconnectAt = setReconnectAt;
114
- this.#connected = connected;
115
- this.#setConnected = setConnected;
73
+ this.store = options.store ?? new RecordStore();
74
+ this.#windows = new RowWindows(this.store);
75
+ this.#channels = new ChannelBook({
76
+ store: this.store,
77
+ send: (frame) => this.#send(frame),
78
+ connected: () => this.#connected,
79
+ catchUp: options.catchUp,
80
+ report: (error) => this.#onError(error),
81
+ });
116
82
  this.#heartbeat = new Heartbeat({
117
83
  intervalMs: finiteOption(
118
84
  'the sync client',
@@ -127,7 +93,25 @@ export class LiveClient<T extends TableMap = TableMap> {
127
93
  }
128
94
 
129
95
  get connected(): boolean {
130
- return this.#connected();
96
+ return this.#connected;
97
+ }
98
+
99
+ /** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
100
+ reconnectAt(): number | null {
101
+ return this.#reconnectAt;
102
+ }
103
+
104
+ /** The buildId the server announced, or `null` while this build is current. */
105
+ appUpdateAvailable(): string | null {
106
+ return this.#update;
107
+ }
108
+
109
+ /** Called after `connected`, `reconnectAt()` or `appUpdateAvailable()` moves. */
110
+ onStatus(listener: () => void): () => void {
111
+ this.#statusListeners.add(listener);
112
+ return () => {
113
+ this.#statusListeners.delete(listener);
114
+ };
131
115
  }
132
116
 
133
117
  connect(): void {
@@ -142,7 +126,7 @@ export class LiveClient<T extends TableMap = TableMap> {
142
126
  previous?.close(1000, 'reconnect');
143
127
  // …and because that corpse's `onClose` returns, this is the only place the connection it was
144
128
  // carrying can be written off: offline until the NEW socket opens. Reporting the replaced
145
- // socket's state through the redial sent a `useLive` opened in that window straight onto an
129
+ // socket's state through the redial sent a live subscription opened in that window straight onto an
146
130
  // unopened socket — a subscribe frame ahead of `hello`, then a second one for the same sid
147
131
  // when `onOpen` replayed it, which the node refuses with X_SUBSCRIPTION_ID_TAKEN.
148
132
  //
@@ -160,20 +144,17 @@ export class LiveClient<T extends TableMap = TableMap> {
160
144
  // and the one handler that had none. A replaced socket opening late would otherwise mark the
161
145
  // live connection up and replay every subscription onto whatever socket is current.
162
146
  if (this.#socket !== socket) return;
163
- this.#setConnected(true);
164
147
  this.#attempt = 0;
165
- this.#setReconnectAt(null);
148
+ this.#setStatus({ connected: true, reconnectAt: null });
166
149
  // `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
167
150
  // frame below, which is the only place resume is decided — sending it here too shipped every
168
151
  // cursor twice per reconnect, once into a field the node discards.
169
152
  this.#send(this.#hello());
170
153
  for (const registration of this.#registrations.values()) this.#sendSubscribe(registration);
171
- // Topic membership lives on the node's socket and `hello` carries none of it, so a channel
172
- // this client still holds a handler for is silent from the first reconnect onwards — and its
173
- // presence membership is swept — unless every one of them is re-announced here.
174
- for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
154
+ // Channel membership lives on the node's socket and `hello` carries none of it, so every
155
+ // channel is re-announced here — each from its own cursor, so the node replays the rest.
156
+ this.#channels.resubscribe();
175
157
  this.#heartbeat.start(this.#clock.now().getTime());
176
- this.#detach(this.drain());
177
158
  });
178
159
  socket.onMessage((data) => {
179
160
  // A frame speaks only for its own socket, the same rule `onClose` follows. A replaced socket
@@ -198,18 +179,19 @@ export class LiveClient<T extends TableMap = TableMap> {
198
179
 
199
180
  /**
200
181
  * Everything a lost connection costs, whoever noticed it — a close, a replacement, an explicit
201
- * teardown, a heartbeat that timed out. The queue half is the one that is easy to forget: a
202
- * mutation handed to a socket that is now gone was never acknowledged, so it goes back in the
203
- * queue rather than waiting for an ack nobody will send.
182
+ * teardown, a heartbeat that timed out.
204
183
  */
205
184
  #offline(): void {
206
185
  this.#heartbeat.stop();
207
- this.#setConnected(false);
208
- // Told once, not two ways: a `useConnection().offline` that flips while a `useLive` handle
209
- // still reads 'live' is one dead socket rendered as two states.
210
- for (const registration of this.#registrations.values()) registration.setState('offline');
211
- const queue = this.#options.queue;
212
- if (queue) this.#detach(queue.requeueInflight());
186
+ this.#setStatus({ connected: false });
187
+ // Told once, not two ways: a `useConnection().offline` that flips while a live window still
188
+ // reads 'live' is one dead socket rendered as two states. A refused one stays refused.
189
+ for (const registration of this.#registrations.values()) {
190
+ if (registration.state === 'failed' || registration.state === 'offline') continue;
191
+ registration.state = 'offline';
192
+ registration.notify();
193
+ }
194
+ this.#channels.offline();
213
195
  }
214
196
 
215
197
  /**
@@ -220,7 +202,7 @@ export class LiveClient<T extends TableMap = TableMap> {
220
202
  close(code = 1000, reason = 'client closed'): void {
221
203
  this.#closed = true;
222
204
  this.#cancelReconnect();
223
- this.#setReconnectAt(null);
205
+ this.#setStatus({ reconnectAt: null });
224
206
  this.#attempt = 0;
225
207
  const socket = this.#socket;
226
208
  this.#socket = null;
@@ -229,34 +211,40 @@ export class LiveClient<T extends TableMap = TableMap> {
229
211
  this.#offline();
230
212
  }
231
213
 
232
- /** Tier 2 and tier 3 alike. The returned accessor is the reactive result set. */
233
- useLive<R extends Row = Row>(query: LiveQueryRef, input: JsonValue): LiveHandle<R> {
214
+ /**
215
+ * Subscribe to a live query. The window holds ids; the rows are the page store's, so a record
216
+ * updated by an HTTP response or another window re-renders here too, with no second copy.
217
+ */
218
+ subscribeLive<R extends object = JsonObject>(
219
+ query: LiveQueryRef,
220
+ input: JsonValue,
221
+ ): LiveHandle<R> {
234
222
  const sid = uuid();
235
- const [rows, setRows] = this.#options.signal<readonly Row[]>([]);
236
- // 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
237
- const [state, setState] = this.#options.signal<LiveState>(
238
- this.#connected() ? 'loading' : 'offline',
239
- );
240
- const [cursor, setCursor] = this.#options.signal<LiveCursor | null>(null);
223
+ const listeners = new Set<() => void>();
241
224
  const registration: Registration = {
242
225
  sid,
243
226
  name: query.name,
244
227
  input,
245
- setRows,
246
- setState,
247
- setCursor,
248
- // Private until the first snapshot names the entity: sharing rows with another query on a
249
- // scope nobody confirmed would merge two entities that spell one id the same way.
250
- scope: privateScope(query.name),
228
+ type: unnamedType(query.name),
251
229
  ids: [],
252
230
  cursor: null,
231
+ // 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
232
+ state: this.#connected ? 'loading' : 'offline',
233
+ error: undefined,
234
+ notify: () => {
235
+ for (const listener of listeners) listener();
236
+ },
253
237
  };
254
238
  this.#registrations.set(sid, registration);
255
239
  const close = this.#windows.open(registration);
256
- if (this.#connected()) this.#sendSubscribe(registration);
240
+ if (this.#connected) this.#sendSubscribe(registration);
241
+ let open = true;
257
242
  const unsubscribe = (): void => {
243
+ if (!open) return;
244
+ open = false;
258
245
  this.#registrations.delete(sid);
259
246
  close();
247
+ listeners.clear();
260
248
  this.#send({
261
249
  type: 'subscribe',
262
250
  v: PROTOCOL_VERSION,
@@ -266,81 +254,32 @@ export class LiveClient<T extends TableMap = TableMap> {
266
254
  });
267
255
  };
268
256
  return {
269
- rows: rows as () => readonly R[],
270
- state,
271
- cursor,
257
+ // Rows are typed by the caller's query; on the wire every one is a JSON object.
258
+ rows: () => this.#windows.rows(registration) as readonly R[],
259
+ state: (): LiveState => registration.state,
260
+ cursor: (): LiveCursor | null => registration.cursor,
261
+ error: (): unknown => registration.error,
262
+ onChange: (listener) => {
263
+ listeners.add(listener);
264
+ return () => {
265
+ listeners.delete(listener);
266
+ };
267
+ },
272
268
  unsubscribe,
273
269
  [Symbol.dispose]: unsubscribe,
274
270
  };
275
271
  }
276
272
 
277
- subscribe(name: Topic, handler: (message: JsonObject) => void): Unsubscribe {
278
- this.#topics.add(name, handler);
279
- this.#send(topicSubscribeFrame(name, 'add'));
280
- // A function is an object: attaching `[Symbol.dispose]` keeps the existing callable contract
281
- // (`const unsub = channel.subscribe(...); unsub()`) intact while adding `using sub = ...`.
282
- const unsubscribe: Unsubscribe = (): void => {
283
- if (!this.#topics.remove(name, handler)) return;
284
- this.#send(topicSubscribeFrame(name, 'drop'));
285
- };
286
- unsubscribe[Symbol.dispose] = unsubscribe;
287
- return unsubscribe;
288
- }
289
-
290
- /** Tier 1 publish. The server re-checks the topic policy; this is a request, not an assertion. */
291
- publish(name: Topic, message: JsonObject): void {
292
- this.#send({
293
- type: 'patch',
294
- v: PROTOCOL_VERSION,
295
- sid: name,
296
- lsn: '',
297
- patches: [{ op: 'insert', id: uuid(), row: message, lsn: '' }],
298
- });
299
- }
300
-
301
- /**
302
- * The mutator entry point. Records the optimistic twin, the rebase entry and the durable queue
303
- * entry, then drains. Offline, everything but the drain still happens — that is tier 3's one
304
- * extra property over tier 2.
305
- */
306
- async mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void> {
307
- await recordMutation(this.#mutations, mutator, input, key);
308
- if (this.#options.queue) await this.drain();
309
- }
310
-
311
- /** Sends every pending mutation in sequence order. Stops at the first one the socket refuses. */
312
- async drain(): Promise<void> {
313
- const queue = this.#options.queue;
314
- if (!queue || !this.#connected()) return;
315
- await queue.drain(mutationSender(this.#mutations));
316
- this.#notifyQueueChange();
317
- }
318
-
319
- /** The mutation path's view of this client. Built per call, exactly like `#frameTarget`. */
320
- get #mutations(): MutationDeps<T> {
321
- return {
322
- store: this.#options.store,
323
- queue: this.#options.queue,
324
- log: this.#options.log,
325
- now: () => this.#clock.now().getTime(),
326
- socket: () => this.#socket,
327
- send: (frame) => this.#send(frame),
328
- };
329
- }
330
-
331
273
  /**
332
- * Fires whenever the offline queue changes for any reason: a direct `mutate`/`drain` call, the
333
- * automatic drain `connect()` runs on every reconnect, or an async ack/fail frame arriving over
334
- * the socket. `hooks.ts` is the only subscriber today — it bumps its invalidation signal here at
335
- * `setLiveClient` time, so a component reading `useMutationQueue()` stays live across every
336
- * transition, not just the ones a hook happens to await directly. Returns an unsubscribe
337
- * function.
274
+ * Hold a declared channel. N holders on one topic share ONE membership; the last release drops
275
+ * it. Its `records` land in the store; `events` and presence reach `handlers` only.
338
276
  */
339
- onQueueChange(listener: () => void): () => void {
340
- this.#queueListeners.add(listener);
341
- return () => {
342
- this.#queueListeners.delete(listener);
343
- };
277
+ holdChannel<K extends string>(
278
+ ref: ChannelRef<K>,
279
+ params: Readonly<Record<K, string>>,
280
+ handlers?: ChannelHandlers,
281
+ ): ChannelMembership {
282
+ return this.#channels.hold(ref, params, handlers);
344
283
  }
345
284
 
346
285
  #sendSubscribe(registration: Registration): void {
@@ -362,22 +301,18 @@ export class LiveClient<T extends TableMap = TableMap> {
362
301
  * The client's inbound surface, handed to the router. Built once: a frame reaches exactly these
363
302
  * members and nothing else on the client.
364
303
  */
365
- get #frameTarget(): ClientFrameTarget<T> {
304
+ get #frameTarget(): ClientFrameTarget {
366
305
  return {
367
306
  registration: (sid) => this.#registrations.get(sid),
368
307
  windows: this.#windows,
369
- topicHandlers: (topic) => this.#topics.handlers(topic),
370
- queue: this.#options.queue,
371
- store: this.#options.store,
372
- log: this.#options.log,
308
+ channels: this.#channels,
373
309
  // The client's clock, never `Date.now()`: a cursor's `at` is what decides a delta resume
374
310
  // against a re-snapshot, so the frame path reads the same clock every other path does.
375
311
  now: () => this.#clock.now().getTime(),
376
- setUpdate: (buildId) => this.#setUpdate(buildId),
312
+ setUpdate: (buildId) => this.#setStatus({ update: buildId }),
377
313
  scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
378
314
  closeSocket: (code, reason) => this.#socket?.close(code, reason),
379
- notifyQueueChange: () => this.#notifyQueueChange(),
380
- detach: (work) => this.#detach(work),
315
+ report: (error) => this.#onError(error),
381
316
  };
382
317
  }
383
318
 
@@ -407,7 +342,7 @@ export class LiveClient<T extends TableMap = TableMap> {
407
342
  */
408
343
  #beat(): void {
409
344
  this.#send(this.#hello());
410
- for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
345
+ this.#channels.beat();
411
346
  }
412
347
 
413
348
  /**
@@ -434,9 +369,9 @@ export class LiveClient<T extends TableMap = TableMap> {
434
369
  this.#cancelReconnect();
435
370
  const rng = this.#options.rng ?? Math.random;
436
371
  const delay =
437
- serverDelayMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? defaultBackoff, rng);
372
+ serverDelayMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? browserBackoff, rng);
438
373
  this.#attempt += 1;
439
- this.#setReconnectAt(this.#clock.now().getTime() + delay);
374
+ this.#setStatus({ reconnectAt: this.#clock.now().getTime() + delay });
440
375
  const schedule = this.#options.scheduler ?? timeoutScheduler;
441
376
  this.#reconnectTimer = schedule(() => {
442
377
  // Cleared before dialling, not after: the attempt's own close must be free to arm the next
@@ -467,17 +402,25 @@ export class LiveClient<T extends TableMap = TableMap> {
467
402
  this.#socket?.send(encode(frame));
468
403
  }
469
404
 
470
- /**
471
- * Work nobody awaits: the drain `onOpen` runs, a queue write from a socket that just died. It
472
- * bottoms out in `QueueStore.save()` — OPFS or IndexedDB, both allowed to reject — and an
473
- * unhandled rejection in a tab is `window.onerror`, in Bun a dead process. `onError` is the seam
474
- * the reconnect timer already reports through; it is never `logger`, which writes stderr.
475
- */
476
- #detach(work: Promise<unknown>): void {
477
- void work.catch(this.#onError);
478
- }
479
-
480
- #notifyQueueChange(): void {
481
- for (const listener of this.#queueListeners) listener();
405
+ /** One status write, one notification — and none for a write that changed nothing. */
406
+ #setStatus(next: {
407
+ connected?: boolean;
408
+ reconnectAt?: number | null;
409
+ update?: string | null;
410
+ }): void {
411
+ let moved = false;
412
+ if (next.connected !== undefined && next.connected !== this.#connected) {
413
+ this.#connected = next.connected;
414
+ moved = true;
415
+ }
416
+ if (next.reconnectAt !== undefined && next.reconnectAt !== this.#reconnectAt) {
417
+ this.#reconnectAt = next.reconnectAt;
418
+ moved = true;
419
+ }
420
+ if (next.update !== undefined && next.update !== this.#update) {
421
+ this.#update = next.update;
422
+ moved = true;
423
+ }
424
+ if (moved) for (const listener of this.#statusListeners) listener();
482
425
  }
483
426
  }
package/src/cursor.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  // and the budget exists to make the expensive answer (a snapshot) the *chosen* one, not the
4
4
  // accidental one. See README "Reconnect is the hard part".
5
5
 
6
- import { type Clock, systemClock } from '@ultimat3/core';
7
- import { CursorStaleError } from './errors';
6
+ import { type Clock, systemClock } from '@ultimat3/core/page';
8
7
  import type { Row, RowPatch } from './json';
8
+ import { CursorStaleError } from './page-errors';
9
9
 
10
10
  /** Ids are bounded so a cursor stays small enough to ship on every `subscribe` frame. */
11
11
  export const CURSOR_ID_LIMIT = 512;