@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
@@ -0,0 +1,359 @@
1
+ // The client's declared channels: one membership per topic however many components hold it, the
2
+ // per-channel cursor that decides duplicate from new, and the catch-up read a `replay-gap` or a
3
+ // new epoch triggers. `records` frames go to the store and nowhere else; `events` go to handlers.
4
+
5
+ import { type PresenceEvent, readPresence } from './channel-presence';
6
+ import type { JsonObject } from './json';
7
+ import { carriedBy, type RecordKey } from './record-key';
8
+ import type { RecordStore } from './record-store';
9
+ import type {
10
+ ChannelEventsFrame,
11
+ ChannelRecordsFrame,
12
+ ReplayGapFrame,
13
+ SubscribeFrame,
14
+ } from './sync-protocol';
15
+ import { PROTOCOL_VERSION } from './wire-version';
16
+
17
+ /** What a browser needs of a `channel()` declaration: its name, its topic rule, its catch-up read. */
18
+ export interface ChannelRef<K extends string = string> {
19
+ readonly name: string;
20
+ readonly catchUp: string;
21
+ topic(params: Readonly<Record<K, string>>): string;
22
+ }
23
+
24
+ export type ChannelState = 'joining' | 'live' | 'catching-up' | 'offline' | 'failed';
25
+
26
+ export interface ChannelHandlers {
27
+ /** An app's own ephemeral `events` frame on this channel. Never written to the store. */
28
+ readonly onEvent?: (event: JsonObject) => void;
29
+ /** A presence roster or delta — the `events` frames `readPresence` recognises. */
30
+ readonly onPresence?: (event: PresenceEvent) => void;
31
+ }
32
+
33
+ /** Everything the book reaches on the client. Narrow, like `ClientFrameTarget`. */
34
+ export interface ChannelBookDeps {
35
+ readonly store: RecordStore;
36
+ send(frame: SubscribeFrame): void;
37
+ connected(): boolean;
38
+ /** Re-run the channel's catch-up read; its records land in the store through the transport. */
39
+ catchUp(query: string, params: Readonly<Record<string, string>>): Promise<unknown>;
40
+ report(error: unknown): void;
41
+ /**
42
+ * When a failed catch-up tries again on its own, on the client's reconnect curve. Absent, a
43
+ * failed read waits for the next open of the socket or the next gap.
44
+ */
45
+ readonly retry?: ((attempt: number, run: () => void) => () => void) | undefined;
46
+ }
47
+
48
+ interface Entry {
49
+ readonly topic: string;
50
+ readonly name: string;
51
+ readonly catchUp: string;
52
+ readonly params: Readonly<Record<string, string>>;
53
+ readonly holders: Set<ChannelHandlers>;
54
+ readonly listeners: Set<() => void>;
55
+ state: ChannelState;
56
+ error: unknown;
57
+ /** The epoch the cursor counts in; `null` before the first frame. */
58
+ epoch: string | null;
59
+ /** Highest seq with no hole below it — what a resubscribe resumes from. */
60
+ contiguous: number | null;
61
+ /** Applied seqs above `contiguous`: a replay that fills a hole is new, one above is a duplicate. */
62
+ readonly above: Set<number>;
63
+ /** Frames that arrived while the catch-up read was in flight, applied after it lands. */
64
+ buffered: ChannelRecordsFrame[] | null;
65
+ /**
66
+ * A gap announced while a read was in flight: one more read follows it, in this epoch (or the
67
+ * current one when the gap named none). A flag, never a count — the `coalesceReloads` shape.
68
+ */
69
+ again: { readonly epoch: string | undefined } | null;
70
+ /** The read is not in flight: it failed, and waits for a retry with `buffered` still held. */
71
+ waiting: boolean;
72
+ /** Consecutive failed reads — the retry curve's attempt number. */
73
+ failures: number;
74
+ /** Disarms the scheduled retry. */
75
+ disarm: (() => void) | null;
76
+ }
77
+
78
+ export interface ChannelMembership extends Disposable {
79
+ readonly topic: string;
80
+ state(): ChannelState;
81
+ error(): unknown;
82
+ onChange(listener: () => void): () => void;
83
+ release(): void;
84
+ }
85
+
86
+ export class ChannelBook {
87
+ readonly #deps: ChannelBookDeps;
88
+ readonly #byTopic = new Map<string, Entry>();
89
+
90
+ constructor(deps: ChannelBookDeps) {
91
+ this.#deps = deps;
92
+ }
93
+
94
+ /** Join (or share) one channel. N holders on one topic are ONE subscribe frame. */
95
+ hold<K extends string>(
96
+ ref: ChannelRef<K>,
97
+ params: Readonly<Record<K, string>>,
98
+ handlers: ChannelHandlers = {},
99
+ ): ChannelMembership {
100
+ const topic = ref.topic(params);
101
+ let entry = this.#byTopic.get(topic);
102
+ if (entry === undefined) {
103
+ entry = {
104
+ topic,
105
+ name: ref.name,
106
+ catchUp: ref.catchUp,
107
+ params,
108
+ holders: new Set(),
109
+ listeners: new Set(),
110
+ state: this.#deps.connected() ? 'joining' : 'offline',
111
+ error: undefined,
112
+ epoch: null,
113
+ contiguous: null,
114
+ above: new Set(),
115
+ buffered: null,
116
+ again: null,
117
+ waiting: false,
118
+ failures: 0,
119
+ disarm: null,
120
+ };
121
+ this.#byTopic.set(topic, entry);
122
+ if (this.#deps.connected()) this.#deps.send(this.#frame(entry, 'add', true));
123
+ }
124
+ const held = entry;
125
+ held.holders.add(handlers);
126
+ let open = true;
127
+ const release = (): void => {
128
+ if (!open) return;
129
+ open = false;
130
+ held.holders.delete(handlers);
131
+ if (held.holders.size > 0) return;
132
+ this.#byTopic.delete(topic);
133
+ held.disarm?.();
134
+ held.disarm = null;
135
+ this.#deps.send(this.#frame(held, 'drop', false));
136
+ };
137
+ return {
138
+ topic,
139
+ state: () => held.state,
140
+ error: () => held.error,
141
+ onChange: (listener) => {
142
+ held.listeners.add(listener);
143
+ return () => {
144
+ held.listeners.delete(listener);
145
+ };
146
+ },
147
+ release,
148
+ [Symbol.dispose]: release,
149
+ };
150
+ }
151
+
152
+ /** Every membership again, on a new socket — resuming from each cursor. */
153
+ resubscribe(): void {
154
+ for (const entry of this.#byTopic.values()) {
155
+ // A catch-up that failed retries on the new socket: the read is what it was waiting for.
156
+ if (entry.waiting) {
157
+ this.#deps.send(this.#frame(entry, 'add', true));
158
+ this.#read(entry);
159
+ continue;
160
+ }
161
+ this.#set(entry, 'joining');
162
+ this.#deps.send(this.#frame(entry, 'add', true));
163
+ }
164
+ }
165
+
166
+ /** The presence heartbeat: repeat each membership with NO `since`, so the node replays nothing. */
167
+ beat(): void {
168
+ for (const entry of this.#byTopic.values()) this.#deps.send(this.#frame(entry, 'add', false));
169
+ }
170
+
171
+ offline(): void {
172
+ for (const entry of this.#byTopic.values()) {
173
+ if (entry.state !== 'failed') this.#set(entry, 'offline');
174
+ }
175
+ }
176
+
177
+ /** The sid a channel is subscribed under — what a refusal `ack` names. */
178
+ refused(sid: string, error: unknown): boolean {
179
+ const entry = [...this.#byTopic.values()].find((candidate) => sidOf(candidate) === sid);
180
+ if (entry === undefined) return false;
181
+ entry.error = error;
182
+ this.#set(entry, 'failed');
183
+ return true;
184
+ }
185
+
186
+ records(frame: ChannelRecordsFrame): void {
187
+ const entry = this.#byTopic.get(frame.channel);
188
+ if (entry === undefined) return;
189
+ if (entry.buffered !== null) {
190
+ entry.buffered.push(frame);
191
+ return;
192
+ }
193
+ if (entry.epoch !== null && entry.epoch !== frame.epoch) {
194
+ // A new epoch: the node restarted or the client landed on another one. Its seqs mean
195
+ // nothing against ours, so the channel is re-read and this frame waits behind the read.
196
+ this.#catchUp(entry, [frame]);
197
+ return;
198
+ }
199
+ this.#apply(entry, frame);
200
+ }
201
+
202
+ gap(frame: ReplayGapFrame): void {
203
+ const entry = this.#byTopic.get(frame.channel);
204
+ if (entry === undefined) return;
205
+ this.#catchUp(entry, [], frame.epoch);
206
+ }
207
+
208
+ event(frame: ChannelEventsFrame): void {
209
+ const entry = this.#byTopic.get(frame.channel);
210
+ const presence = readPresence(frame.event);
211
+ for (const holder of entry?.holders ?? []) {
212
+ if (presence !== null) holder.onPresence?.(presence);
213
+ else holder.onEvent?.(frame.event);
214
+ }
215
+ }
216
+
217
+ /** Where a resubscribe resumes: the highest contiguous seq, in the epoch it counts in. */
218
+ since(topic: string): { readonly epoch: string; readonly seq: number } | undefined {
219
+ const entry = this.#byTopic.get(topic);
220
+ if (entry?.epoch === null || entry?.contiguous === null || entry === undefined)
221
+ return undefined;
222
+ return { epoch: entry.epoch, seq: entry.contiguous };
223
+ }
224
+
225
+ #apply(entry: Entry, frame: ChannelRecordsFrame): void {
226
+ if (entry.epoch === null) {
227
+ entry.epoch = frame.epoch;
228
+ entry.contiguous = frame.seq - 1;
229
+ }
230
+ const contiguous = entry.contiguous ?? frame.seq - 1;
231
+ // A duplicate is dropped; a replayed seq filling a hole is new. A numeric hole on its own is
232
+ // never a gap — a row this socket may not see is skipped for it; only `replay-gap` is.
233
+ if (frame.seq <= contiguous || entry.above.has(frame.seq)) return;
234
+ const store = this.#deps.store;
235
+ store.batch(() => {
236
+ for (const [type, rows] of Object.entries(frame.adopt ?? {})) store.adopt(type, rows);
237
+ for (const [type, keys] of Object.entries(frame.remove ?? {})) store.remove(type, keys);
238
+ // This page's own write, echoed: settled in the same batch as its rows, so its twin is
239
+ // never replayed over the truth that already holds it.
240
+ if (frame.write !== undefined) {
241
+ const carried = new Set<RecordKey>();
242
+ carriedBy({ records: frame.adopt ?? {}, removed: frame.remove ?? {} }, carried);
243
+ store.settleWrite(frame.write, carried);
244
+ }
245
+ });
246
+ entry.above.add(frame.seq);
247
+ let next = contiguous;
248
+ while (entry.above.delete(next + 1)) next += 1;
249
+ entry.contiguous = next;
250
+ if (entry.state !== 'live') this.#set(entry, 'live');
251
+ }
252
+
253
+ /**
254
+ * Re-read the channel through its catch-up query, holding every frame that arrives meanwhile;
255
+ * then apply those, in order, over the read. The cursor restarts in the new epoch.
256
+ *
257
+ * A gap that lands DURING the read is not dropped: it earns one more read after this one (it
258
+ * was ignored, and the hole it announced was never repaired).
259
+ */
260
+ #catchUp(entry: Entry, pending: ChannelRecordsFrame[], epoch?: string): void {
261
+ if (entry.buffered !== null) {
262
+ entry.buffered.push(...pending);
263
+ if (pending.length === 0) entry.again = { epoch: epoch ?? entry.again?.epoch };
264
+ // No read in flight — the last one failed — so this gap is the retry, now.
265
+ if (entry.waiting) this.#read(entry);
266
+ return;
267
+ }
268
+ entry.buffered = [...pending];
269
+ this.#restart(entry, epoch ?? pending[0]?.epoch ?? entry.epoch);
270
+ this.#read(entry);
271
+ }
272
+
273
+ /** The cursor, reset into `epoch`: every seq before the read is the read's to answer. */
274
+ #restart(entry: Entry, epoch: string | null): void {
275
+ entry.epoch = epoch;
276
+ entry.contiguous = null;
277
+ entry.above.clear();
278
+ }
279
+
280
+ #read(entry: Entry): void {
281
+ entry.waiting = false;
282
+ entry.error = undefined;
283
+ entry.disarm?.();
284
+ entry.disarm = null;
285
+ this.#set(entry, 'catching-up');
286
+ this.#deps.catchUp(entry.catchUp, entry.params).then(
287
+ () => this.#drain(entry),
288
+ (error: unknown) => {
289
+ this.#deps.report(error);
290
+ // NOT live: the frames held behind the read would land over a store it never refreshed.
291
+ // Failed, the error exposed, and the frames still held for the retry — which the next
292
+ // open of the socket runs (`resubscribe`), or the next gap.
293
+ entry.waiting = true;
294
+ entry.error = error;
295
+ entry.failures += 1;
296
+ if (this.#byTopic.get(entry.topic) !== entry) return;
297
+ this.#set(entry, 'failed');
298
+ entry.disarm =
299
+ this.#deps.retry?.(entry.failures, () => {
300
+ entry.disarm = null;
301
+ if (entry.waiting && this.#byTopic.get(entry.topic) === entry) this.#read(entry);
302
+ }) ?? null;
303
+ },
304
+ );
305
+ }
306
+
307
+ #drain(entry: Entry): void {
308
+ const held = entry.buffered ?? [];
309
+ // A newer epoch arrived while the read was in flight (the node restarted mid-read): the read
310
+ // answered for the OLD one, so it is re-read in the new one, keeping only that epoch's frames.
311
+ // `#drain` used to discard them, and the rows they carried with them.
312
+ const newer = held.find((frame) => frame.epoch !== entry.epoch);
313
+ if (entry.again !== null || newer !== undefined) {
314
+ const epoch = newer?.epoch ?? entry.again?.epoch ?? entry.epoch;
315
+ entry.again = null;
316
+ entry.buffered = held.filter((frame) => frame.epoch === epoch);
317
+ this.#restart(entry, epoch);
318
+ this.#read(entry);
319
+ return;
320
+ }
321
+ entry.buffered = null;
322
+ entry.failures = 0;
323
+ const current = [...held].sort((a, b) => a.seq - b.seq);
324
+ for (const frame of current) {
325
+ if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
326
+ this.#apply(entry, frame);
327
+ }
328
+ if (entry.state === 'catching-up') this.#set(entry, 'live');
329
+ }
330
+
331
+ #set(entry: Entry, state: ChannelState): void {
332
+ entry.state = state;
333
+ for (const listener of entry.listeners) listener();
334
+ }
335
+
336
+ #frame(entry: Entry, op: 'add' | 'drop', resume: boolean): SubscribeFrame {
337
+ const since = resume && op === 'add' ? this.since(entry.topic) : undefined;
338
+ return {
339
+ type: 'subscribe',
340
+ v: PROTOCOL_VERSION,
341
+ op,
342
+ sid: sidOf(entry),
343
+ target: {
344
+ kind: 'channel',
345
+ channel: entry.name,
346
+ params: entry.params,
347
+ ...(since === undefined ? {} : { since }),
348
+ },
349
+ };
350
+ }
351
+ }
352
+
353
+ /** A channel membership's sid is its topic behind this — what the socket engine routes on too. */
354
+ export const CHANNEL_SID_PREFIX = 'channel:';
355
+
356
+ /** One sid per topic: re-sending it after a reconnect is the same membership, not a second one. */
357
+ function sidOf(entry: Entry): string {
358
+ return `${CHANNEL_SID_PREFIX}${entry.topic}`;
359
+ }
@@ -1,42 +1,45 @@
1
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.
2
+ // gives back. Declared apart from the client that implements them, so a hook module can name the
3
+ // shapes without importing the connection lifecycle underneath — and the bytes that come with it.
5
4
 
6
- import type { Clock } from '@ultimat3/core';
5
+ import type { Clock, Row } from '@ultimat3/core/page';
7
6
  import type { LiveCursor } from './cursor';
8
- import type { JsonValue, Row } from './json';
9
7
  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';
8
+ import type { RecordStore } from './record-store';
13
9
  import type { BackoffPolicy, Rng, Scheduler } from './thundering-herd';
14
10
 
15
- /** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
11
+ /**
12
+ * A reactive primitive: `createSignal` narrowed to two functions. Installed PER ISLAND BUNDLE
13
+ * (`installRealtime`), because every island carries its own solid-js and a signal made by one
14
+ * bundle's copy is invisible to another's effects.
15
+ */
16
16
  export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
17
17
 
18
- /** Injected socket, so tests drive the protocol without a network. */
18
+ /**
19
+ * The socket seam. `browser-socket.ts` is the one production implementation — the only
20
+ * `new WebSocket` in the framework — and test harnesses supply their own. Not on the public API:
21
+ * an app never constructs a socket, the page does.
22
+ */
19
23
  export interface ClientSocket {
20
24
  send(data: string): void;
21
25
  close(code?: number, reason?: string): void;
22
26
  onOpen(handler: () => void): void;
23
27
  onMessage(handler: (data: string) => void): void;
24
28
  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
29
  readonly bufferedAmount?: number;
31
30
  }
32
31
 
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;
32
+ /** One live query's window, as plain reads plus a change listener — no reactive runtime here. */
33
+ export interface LiveHandle<R extends object = Row> extends Disposable {
34
+ rows(): readonly R[];
35
+ state(): LiveState;
36
+ cursor(): LiveCursor | null;
37
+ /** What the node answered when it refused the subscription; `undefined` unless `failed`. */
38
+ error(): unknown;
39
+ /** Called after anything the reads above answer has moved. Returns the unsubscribe. */
40
+ onChange(listener: () => void): () => void;
38
41
  unsubscribe(): void;
39
- /** The same call as `unsubscribe`, so `using sub = client.useLive(...)` just works. */
42
+ /** The same call as `unsubscribe`, so `using sub = client.subscribeLive(...)` just works. */
40
43
  [Symbol.dispose](): void;
41
44
  }
42
45
 
@@ -47,59 +50,26 @@ export interface LiveQueryRef {
47
50
  readonly name: string;
48
51
  }
49
52
 
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
- /**
59
- * What the HOOKS need a client to be — every member `hooks.ts` reads, and not one more.
60
- *
61
- * A structural interface rather than the `LiveClient` class, and the reason is measured: a value
62
- * import of that class from the hook seam put the whole connection lifecycle (heartbeat, topic
63
- * book, mutation sender, wire protocol, backoff) into every island that calls `useLive`, taking a
64
- * `useLive`-only browser chunk from 8,368 B to 26,571 B. The server render's client
65
- * (`server-render-client.ts`) satisfies this and imports no lifecycle at all, so the browser pays
66
- * nothing for a shape only the server uses. `type-pins.ts` pins that `LiveClient` still satisfies
67
- * it, so a member added there and not here is a build error rather than a hook that cannot see it.
68
- */
69
- export interface LiveClientLike<T extends TableMap = TableMap> {
70
- readonly signal: SignalFactory;
71
- readonly queue: OfflineQueue | undefined;
72
- readonly connected: boolean;
73
- readonly reconnectAt: () => number | null;
74
- readonly appUpdateAvailable: () => string | null;
75
- useLive<R extends Row>(query: LiveQueryRef, input: JsonValue): LiveHandle<R>;
76
- mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void>;
77
- drain(): Promise<void>;
78
- onQueueChange(listener: () => void): () => void;
79
- }
80
-
81
- export interface LiveClientOptions<T extends TableMap = TableMap> {
82
- readonly signal: SignalFactory;
53
+ export interface LiveClientOptions {
83
54
  /** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
84
55
  readonly connect: () => ClientSocket;
85
56
  readonly buildId: string;
86
57
  readonly actorId?: string | null;
87
- /** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
88
- readonly store?: LocalStore<T>;
89
- readonly queue?: OfflineQueue;
90
- readonly log?: RebaseLog<T>;
58
+ /** The page's record store every window renders out of. A client builds its own when absent. */
59
+ readonly store?: RecordStore;
60
+ /** Default `browserBackoff`: capped at `BROWSER_RECONNECT_MAX_MS`, never the server's 30s. */
91
61
  readonly backoff?: BackoffPolicy;
92
62
  readonly rng?: Rng;
93
63
  readonly clock?: Clock;
94
64
  /** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
95
65
  readonly scheduler?: Scheduler;
96
- /**
97
- * How often a live socket re-announces itself, in ms. `0` disables it. Defaults to
98
- * `DEFAULT_HEARTBEAT_MS`, 15s. The one knob for the beat: `realtime.heartbeatMs` in
99
- * `app.config.ts` was deleted 2026-08-19 because nothing read it, so this is not a restatement
100
- * of a server value — browser code could never have reached one.
101
- */
66
+ /** How often a live socket re-announces itself, in ms. `0` disables it. Default 15s. */
102
67
  readonly heartbeatMs?: number;
103
- /** Where a dial failure inside the reconnect timer is reported. Defaults to `reportToConsole`. */
68
+ /** Where a failure nobody awaits is reported. Defaults to `console.error`. */
104
69
  readonly onError?: (error: unknown) => void;
70
+ /**
71
+ * A channel's catch-up read, re-run on `replay-gap` or a new epoch: the named query, through
72
+ * core's transport, so its records land in the store. `page-socket.ts` supplies the real one.
73
+ */
74
+ readonly catchUp: (query: string, params: Readonly<Record<string, string>>) => Promise<unknown>;
105
75
  }