@ultimat3/realtime 20.2.1 → 22.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 (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  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 +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -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 +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. 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 { browserBackoff, policyDelay, 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,34 @@ 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
+ // A failed catch-up retries on the reconnect curve this client already dials on.
82
+ retry: (attempt, run) =>
83
+ (this.#options.scheduler ?? timeoutScheduler)(
84
+ run,
85
+ // `attempt` is the book's failure count, 1 on the first failure: core's count as is.
86
+ policyDelay(
87
+ this.#options.backoff ?? browserBackoff,
88
+ attempt,
89
+ this.#options.rng ?? Math.random,
90
+ ),
91
+ ),
92
+ });
116
93
  this.#heartbeat = new Heartbeat({
117
94
  intervalMs: finiteOption(
118
95
  'the sync client',
@@ -127,7 +104,25 @@ export class LiveClient<T extends TableMap = TableMap> {
127
104
  }
128
105
 
129
106
  get connected(): boolean {
130
- return this.#connected();
107
+ return this.#connected;
108
+ }
109
+
110
+ /** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
111
+ reconnectAt(): number | null {
112
+ return this.#reconnectAt;
113
+ }
114
+
115
+ /** The buildId the server announced, or `null` while this build is current. */
116
+ appUpdateAvailable(): string | null {
117
+ return this.#update;
118
+ }
119
+
120
+ /** Called after `connected`, `reconnectAt()` or `appUpdateAvailable()` moves. */
121
+ onStatus(listener: () => void): () => void {
122
+ this.#statusListeners.add(listener);
123
+ return () => {
124
+ this.#statusListeners.delete(listener);
125
+ };
131
126
  }
132
127
 
133
128
  connect(): void {
@@ -142,7 +137,7 @@ export class LiveClient<T extends TableMap = TableMap> {
142
137
  previous?.close(1000, 'reconnect');
143
138
  // …and because that corpse's `onClose` returns, this is the only place the connection it was
144
139
  // 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
140
+ // socket's state through the redial sent a live subscription opened in that window straight onto an
146
141
  // unopened socket — a subscribe frame ahead of `hello`, then a second one for the same sid
147
142
  // when `onOpen` replayed it, which the node refuses with X_SUBSCRIPTION_ID_TAKEN.
148
143
  //
@@ -160,20 +155,17 @@ export class LiveClient<T extends TableMap = TableMap> {
160
155
  // and the one handler that had none. A replaced socket opening late would otherwise mark the
161
156
  // live connection up and replay every subscription onto whatever socket is current.
162
157
  if (this.#socket !== socket) return;
163
- this.#setConnected(true);
164
158
  this.#attempt = 0;
165
- this.#setReconnectAt(null);
159
+ this.#setStatus({ connected: true, reconnectAt: null });
166
160
  // `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
167
161
  // frame below, which is the only place resume is decided — sending it here too shipped every
168
162
  // cursor twice per reconnect, once into a field the node discards.
169
163
  this.#send(this.#hello());
170
164
  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'));
165
+ // Channel membership lives on the node's socket and `hello` carries none of it, so every
166
+ // channel is re-announced here — each from its own cursor, so the node replays the rest.
167
+ this.#channels.resubscribe();
175
168
  this.#heartbeat.start(this.#clock.now().getTime());
176
- this.#detach(this.drain());
177
169
  });
178
170
  socket.onMessage((data) => {
179
171
  // A frame speaks only for its own socket, the same rule `onClose` follows. A replaced socket
@@ -198,18 +190,19 @@ export class LiveClient<T extends TableMap = TableMap> {
198
190
 
199
191
  /**
200
192
  * 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.
193
+ * teardown, a heartbeat that timed out.
204
194
  */
205
195
  #offline(): void {
206
196
  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());
197
+ this.#setStatus({ connected: false });
198
+ // Told once, not two ways: a `useConnection().offline` that flips while a live window still
199
+ // reads 'live' is one dead socket rendered as two states. A refused one stays refused.
200
+ for (const registration of this.#registrations.values()) {
201
+ if (registration.state === 'failed' || registration.state === 'offline') continue;
202
+ registration.state = 'offline';
203
+ registration.notify();
204
+ }
205
+ this.#channels.offline();
213
206
  }
214
207
 
215
208
  /**
@@ -220,7 +213,7 @@ export class LiveClient<T extends TableMap = TableMap> {
220
213
  close(code = 1000, reason = 'client closed'): void {
221
214
  this.#closed = true;
222
215
  this.#cancelReconnect();
223
- this.#setReconnectAt(null);
216
+ this.#setStatus({ reconnectAt: null });
224
217
  this.#attempt = 0;
225
218
  const socket = this.#socket;
226
219
  this.#socket = null;
@@ -229,34 +222,40 @@ export class LiveClient<T extends TableMap = TableMap> {
229
222
  this.#offline();
230
223
  }
231
224
 
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> {
225
+ /**
226
+ * Subscribe to a live query. The window holds ids; the rows are the page store's, so a record
227
+ * updated by an HTTP response or another window re-renders here too, with no second copy.
228
+ */
229
+ subscribeLive<R extends object = JsonObject>(
230
+ query: LiveQueryRef,
231
+ input: JsonValue,
232
+ ): LiveHandle<R> {
234
233
  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);
234
+ const listeners = new Set<() => void>();
241
235
  const registration: Registration = {
242
236
  sid,
243
237
  name: query.name,
244
238
  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),
239
+ type: unnamedType(query.name),
251
240
  ids: [],
252
241
  cursor: null,
242
+ // 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
243
+ state: this.#connected ? 'loading' : 'offline',
244
+ error: undefined,
245
+ notify: () => {
246
+ for (const listener of listeners) listener();
247
+ },
253
248
  };
254
249
  this.#registrations.set(sid, registration);
255
250
  const close = this.#windows.open(registration);
256
- if (this.#connected()) this.#sendSubscribe(registration);
251
+ if (this.#connected) this.#sendSubscribe(registration);
252
+ let open = true;
257
253
  const unsubscribe = (): void => {
254
+ if (!open) return;
255
+ open = false;
258
256
  this.#registrations.delete(sid);
259
257
  close();
258
+ listeners.clear();
260
259
  this.#send({
261
260
  type: 'subscribe',
262
261
  v: PROTOCOL_VERSION,
@@ -266,81 +265,32 @@ export class LiveClient<T extends TableMap = TableMap> {
266
265
  });
267
266
  };
268
267
  return {
269
- rows: rows as () => readonly R[],
270
- state,
271
- cursor,
268
+ // Rows are typed by the caller's query; on the wire every one is a JSON object.
269
+ rows: () => this.#windows.rows(registration) as readonly R[],
270
+ state: (): LiveState => registration.state,
271
+ cursor: (): LiveCursor | null => registration.cursor,
272
+ error: (): unknown => registration.error,
273
+ onChange: (listener) => {
274
+ listeners.add(listener);
275
+ return () => {
276
+ listeners.delete(listener);
277
+ };
278
+ },
272
279
  unsubscribe,
273
280
  [Symbol.dispose]: unsubscribe,
274
281
  };
275
282
  }
276
283
 
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
284
  /**
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.
285
+ * Hold a declared channel. N holders on one topic share ONE membership; the last release drops
286
+ * it. Its `records` land in the store; `events` and presence reach `handlers` only.
338
287
  */
339
- onQueueChange(listener: () => void): () => void {
340
- this.#queueListeners.add(listener);
341
- return () => {
342
- this.#queueListeners.delete(listener);
343
- };
288
+ holdChannel<K extends string>(
289
+ ref: ChannelRef<K>,
290
+ params: Readonly<Record<K, string>>,
291
+ handlers?: ChannelHandlers,
292
+ ): ChannelMembership {
293
+ return this.#channels.hold(ref, params, handlers);
344
294
  }
345
295
 
346
296
  #sendSubscribe(registration: Registration): void {
@@ -362,22 +312,18 @@ export class LiveClient<T extends TableMap = TableMap> {
362
312
  * The client's inbound surface, handed to the router. Built once: a frame reaches exactly these
363
313
  * members and nothing else on the client.
364
314
  */
365
- get #frameTarget(): ClientFrameTarget<T> {
315
+ get #frameTarget(): ClientFrameTarget {
366
316
  return {
367
317
  registration: (sid) => this.#registrations.get(sid),
368
318
  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,
319
+ channels: this.#channels,
373
320
  // The client's clock, never `Date.now()`: a cursor's `at` is what decides a delta resume
374
321
  // against a re-snapshot, so the frame path reads the same clock every other path does.
375
322
  now: () => this.#clock.now().getTime(),
376
- setUpdate: (buildId) => this.#setUpdate(buildId),
323
+ setUpdate: (buildId) => this.#setStatus({ update: buildId }),
377
324
  scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
378
325
  closeSocket: (code, reason) => this.#socket?.close(code, reason),
379
- notifyQueueChange: () => this.#notifyQueueChange(),
380
- detach: (work) => this.#detach(work),
326
+ report: (error) => this.#onError(error),
381
327
  };
382
328
  }
383
329
 
@@ -407,7 +353,7 @@ export class LiveClient<T extends TableMap = TableMap> {
407
353
  */
408
354
  #beat(): void {
409
355
  this.#send(this.#hello());
410
- for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
356
+ this.#channels.beat();
411
357
  }
412
358
 
413
359
  /**
@@ -434,9 +380,10 @@ export class LiveClient<T extends TableMap = TableMap> {
434
380
  this.#cancelReconnect();
435
381
  const rng = this.#options.rng ?? Math.random;
436
382
  const delay =
437
- serverDelayMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? defaultBackoff, rng);
383
+ // `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one.
384
+ serverDelayMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng);
438
385
  this.#attempt += 1;
439
- this.#setReconnectAt(this.#clock.now().getTime() + delay);
386
+ this.#setStatus({ reconnectAt: this.#clock.now().getTime() + delay });
440
387
  const schedule = this.#options.scheduler ?? timeoutScheduler;
441
388
  this.#reconnectTimer = schedule(() => {
442
389
  // Cleared before dialling, not after: the attempt's own close must be free to arm the next
@@ -467,17 +414,25 @@ export class LiveClient<T extends TableMap = TableMap> {
467
414
  this.#socket?.send(encode(frame));
468
415
  }
469
416
 
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();
417
+ /** One status write, one notification — and none for a write that changed nothing. */
418
+ #setStatus(next: {
419
+ connected?: boolean;
420
+ reconnectAt?: number | null;
421
+ update?: string | null;
422
+ }): void {
423
+ let moved = false;
424
+ if (next.connected !== undefined && next.connected !== this.#connected) {
425
+ this.#connected = next.connected;
426
+ moved = true;
427
+ }
428
+ if (next.reconnectAt !== undefined && next.reconnectAt !== this.#reconnectAt) {
429
+ this.#reconnectAt = next.reconnectAt;
430
+ moved = true;
431
+ }
432
+ if (next.update !== undefined && next.update !== this.#update) {
433
+ this.#update = next.update;
434
+ moved = true;
435
+ }
436
+ if (moved) for (const listener of this.#statusListeners) listener();
482
437
  }
483
438
  }
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;
@@ -161,6 +161,11 @@ export function advance(
161
161
  lsn: string,
162
162
  now: number,
163
163
  ): LiveCursor {
164
+ // An update moves no id in or out, and it is the common change: the ids are reused as they are,
165
+ // so a fan-out to N subscribers of a W-row window is not N rebuilds of a W-id set per change.
166
+ if (!patches.some((patch) => patch.op !== 'update')) {
167
+ return { qid: cursor.qid, lsn, ids: cursor.ids, at: now };
168
+ }
164
169
  const ids = new Set(cursor.ids);
165
170
  for (const patch of patches) {
166
171
  if (patch.op === 'delete') ids.delete(patch.id);