@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
@@ -0,0 +1,289 @@
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
+
43
+ interface Entry {
44
+ readonly topic: string;
45
+ readonly name: string;
46
+ readonly catchUp: string;
47
+ readonly params: Readonly<Record<string, string>>;
48
+ readonly holders: Set<ChannelHandlers>;
49
+ readonly listeners: Set<() => void>;
50
+ state: ChannelState;
51
+ error: unknown;
52
+ /** The epoch the cursor counts in; `null` before the first frame. */
53
+ epoch: string | null;
54
+ /** Highest seq with no hole below it — what a resubscribe resumes from. */
55
+ contiguous: number | null;
56
+ /** Applied seqs above `contiguous`: a replay that fills a hole is new, one above is a duplicate. */
57
+ readonly above: Set<number>;
58
+ /** Frames that arrived while the catch-up read was in flight, applied after it lands. */
59
+ buffered: ChannelRecordsFrame[] | null;
60
+ }
61
+
62
+ export interface ChannelMembership extends Disposable {
63
+ readonly topic: string;
64
+ state(): ChannelState;
65
+ error(): unknown;
66
+ onChange(listener: () => void): () => void;
67
+ release(): void;
68
+ }
69
+
70
+ export class ChannelBook {
71
+ readonly #deps: ChannelBookDeps;
72
+ readonly #byTopic = new Map<string, Entry>();
73
+
74
+ constructor(deps: ChannelBookDeps) {
75
+ this.#deps = deps;
76
+ }
77
+
78
+ /** Join (or share) one channel. N holders on one topic are ONE subscribe frame. */
79
+ hold<K extends string>(
80
+ ref: ChannelRef<K>,
81
+ params: Readonly<Record<K, string>>,
82
+ handlers: ChannelHandlers = {},
83
+ ): ChannelMembership {
84
+ const topic = ref.topic(params);
85
+ let entry = this.#byTopic.get(topic);
86
+ if (entry === undefined) {
87
+ entry = {
88
+ topic,
89
+ name: ref.name,
90
+ catchUp: ref.catchUp,
91
+ params,
92
+ holders: new Set(),
93
+ listeners: new Set(),
94
+ state: this.#deps.connected() ? 'joining' : 'offline',
95
+ error: undefined,
96
+ epoch: null,
97
+ contiguous: null,
98
+ above: new Set(),
99
+ buffered: null,
100
+ };
101
+ this.#byTopic.set(topic, entry);
102
+ if (this.#deps.connected()) this.#deps.send(this.#frame(entry, 'add', true));
103
+ }
104
+ const held = entry;
105
+ held.holders.add(handlers);
106
+ let open = true;
107
+ const release = (): void => {
108
+ if (!open) return;
109
+ open = false;
110
+ held.holders.delete(handlers);
111
+ if (held.holders.size > 0) return;
112
+ this.#byTopic.delete(topic);
113
+ this.#deps.send(this.#frame(held, 'drop', false));
114
+ };
115
+ return {
116
+ topic,
117
+ state: () => held.state,
118
+ error: () => held.error,
119
+ onChange: (listener) => {
120
+ held.listeners.add(listener);
121
+ return () => {
122
+ held.listeners.delete(listener);
123
+ };
124
+ },
125
+ release,
126
+ [Symbol.dispose]: release,
127
+ };
128
+ }
129
+
130
+ /** Every membership again, on a new socket — resuming from each cursor. */
131
+ resubscribe(): void {
132
+ for (const entry of this.#byTopic.values()) {
133
+ this.#set(entry, 'joining');
134
+ this.#deps.send(this.#frame(entry, 'add', true));
135
+ }
136
+ }
137
+
138
+ /** The presence heartbeat: repeat each membership with NO `since`, so the node replays nothing. */
139
+ beat(): void {
140
+ for (const entry of this.#byTopic.values()) this.#deps.send(this.#frame(entry, 'add', false));
141
+ }
142
+
143
+ offline(): void {
144
+ for (const entry of this.#byTopic.values()) {
145
+ if (entry.state !== 'failed') this.#set(entry, 'offline');
146
+ }
147
+ }
148
+
149
+ /** The sid a channel is subscribed under — what a refusal `ack` names. */
150
+ refused(sid: string, error: unknown): boolean {
151
+ const entry = [...this.#byTopic.values()].find((candidate) => sidOf(candidate) === sid);
152
+ if (entry === undefined) return false;
153
+ entry.error = error;
154
+ this.#set(entry, 'failed');
155
+ return true;
156
+ }
157
+
158
+ records(frame: ChannelRecordsFrame): void {
159
+ const entry = this.#byTopic.get(frame.channel);
160
+ if (entry === undefined) return;
161
+ if (entry.buffered !== null) {
162
+ entry.buffered.push(frame);
163
+ return;
164
+ }
165
+ if (entry.epoch !== null && entry.epoch !== frame.epoch) {
166
+ // A new epoch: the node restarted or the client landed on another one. Its seqs mean
167
+ // nothing against ours, so the channel is re-read and this frame waits behind the read.
168
+ this.#catchUp(entry, [frame]);
169
+ return;
170
+ }
171
+ this.#apply(entry, frame);
172
+ }
173
+
174
+ gap(frame: ReplayGapFrame): void {
175
+ const entry = this.#byTopic.get(frame.channel);
176
+ if (entry === undefined) return;
177
+ this.#catchUp(entry, [], frame.epoch);
178
+ }
179
+
180
+ event(frame: ChannelEventsFrame): void {
181
+ const entry = this.#byTopic.get(frame.channel);
182
+ const presence = readPresence(frame.event);
183
+ for (const holder of entry?.holders ?? []) {
184
+ if (presence !== null) holder.onPresence?.(presence);
185
+ else holder.onEvent?.(frame.event);
186
+ }
187
+ }
188
+
189
+ /** Where a resubscribe resumes: the highest contiguous seq, in the epoch it counts in. */
190
+ since(topic: string): { readonly epoch: string; readonly seq: number } | undefined {
191
+ const entry = this.#byTopic.get(topic);
192
+ if (entry?.epoch === null || entry?.contiguous === null || entry === undefined)
193
+ return undefined;
194
+ return { epoch: entry.epoch, seq: entry.contiguous };
195
+ }
196
+
197
+ #apply(entry: Entry, frame: ChannelRecordsFrame): void {
198
+ if (entry.epoch === null) {
199
+ entry.epoch = frame.epoch;
200
+ entry.contiguous = frame.seq - 1;
201
+ }
202
+ const contiguous = entry.contiguous ?? frame.seq - 1;
203
+ // A duplicate is dropped; a replayed seq filling a hole is new. A numeric hole on its own is
204
+ // never a gap — a row this socket may not see is skipped for it; only `replay-gap` is.
205
+ if (frame.seq <= contiguous || entry.above.has(frame.seq)) return;
206
+ const store = this.#deps.store;
207
+ store.batch(() => {
208
+ for (const [type, rows] of Object.entries(frame.adopt ?? {})) store.adopt(type, rows);
209
+ for (const [type, keys] of Object.entries(frame.remove ?? {})) store.remove(type, keys);
210
+ // This page's own write, echoed: settled in the same batch as its rows, so its twin is
211
+ // never replayed over the truth that already holds it.
212
+ if (frame.write !== undefined) {
213
+ const carried = new Set<RecordKey>();
214
+ carriedBy({ records: frame.adopt ?? {}, removed: frame.remove ?? {} }, carried);
215
+ store.settleWrite(frame.write, carried);
216
+ }
217
+ });
218
+ entry.above.add(frame.seq);
219
+ let next = contiguous;
220
+ while (entry.above.delete(next + 1)) next += 1;
221
+ entry.contiguous = next;
222
+ if (entry.state !== 'live') this.#set(entry, 'live');
223
+ }
224
+
225
+ /**
226
+ * Re-read the channel through its catch-up query, holding every frame that arrives meanwhile;
227
+ * then apply those, in order, over the read. The cursor restarts in the new epoch.
228
+ */
229
+ #catchUp(entry: Entry, pending: ChannelRecordsFrame[], epoch?: string): void {
230
+ if (entry.buffered !== null) {
231
+ entry.buffered.push(...pending);
232
+ return;
233
+ }
234
+ entry.buffered = [...pending];
235
+ entry.epoch = epoch ?? pending[0]?.epoch ?? entry.epoch;
236
+ entry.contiguous = null;
237
+ entry.above.clear();
238
+ this.#set(entry, 'catching-up');
239
+ this.#deps.catchUp(entry.catchUp, entry.params).then(
240
+ () => this.#drain(entry),
241
+ (error: unknown) => {
242
+ this.#deps.report(error);
243
+ this.#drain(entry);
244
+ },
245
+ );
246
+ }
247
+
248
+ #drain(entry: Entry): void {
249
+ const held = entry.buffered ?? [];
250
+ entry.buffered = null;
251
+ // Frames of an epoch other than the one caught up to predate it; the read already holds them.
252
+ const current = held.filter((frame) => frame.epoch === entry.epoch);
253
+ current.sort((a, b) => a.seq - b.seq);
254
+ for (const frame of current) {
255
+ if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
256
+ this.#apply(entry, frame);
257
+ }
258
+ if (entry.state === 'catching-up') this.#set(entry, 'live');
259
+ }
260
+
261
+ #set(entry: Entry, state: ChannelState): void {
262
+ entry.state = state;
263
+ for (const listener of entry.listeners) listener();
264
+ }
265
+
266
+ #frame(entry: Entry, op: 'add' | 'drop', resume: boolean): SubscribeFrame {
267
+ const since = resume && op === 'add' ? this.since(entry.topic) : undefined;
268
+ return {
269
+ type: 'subscribe',
270
+ v: PROTOCOL_VERSION,
271
+ op,
272
+ sid: sidOf(entry),
273
+ target: {
274
+ kind: 'channel',
275
+ channel: entry.name,
276
+ params: entry.params,
277
+ ...(since === undefined ? {} : { since }),
278
+ },
279
+ };
280
+ }
281
+ }
282
+
283
+ /** A channel membership's sid is its topic behind this — what the socket engine routes on too. */
284
+ export const CHANNEL_SID_PREFIX = 'channel:';
285
+
286
+ /** One sid per topic: re-sending it after a reconnect is the same membership, not a second one. */
287
+ function sidOf(entry: Entry): string {
288
+ return `${CHANNEL_SID_PREFIX}${entry.topic}`;
289
+ }
@@ -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
  }
@@ -3,42 +3,34 @@
3
3
  // every piece of the client a frame may touch, so the blast radius of a new frame kind is a
4
4
  // reviewable list rather than "whatever the router could reach through `this`".
5
5
 
6
+ import type { ChannelBook } from './client-channels';
6
7
  import { CLOSE } from './close-codes';
7
8
  import { advance } from './cursor';
8
- import type { JsonObject, JsonValue } from './json';
9
9
  import type { Registration, RowWindows } from './live-rows';
10
- import type { LocalStore, TableMap } from './local-store';
11
- import type { OfflineQueue } from './offline-queue';
12
- import { type RebaseLog, reconcile, rollbackMutation } from './rebase';
13
- import type { Frame, PresenceMember } from './sync-protocol';
10
+ import type { Frame } from './sync-protocol';
14
11
 
15
12
  /** Declared with the window it projects; re-exported here because the router is what writes it. */
16
13
  export type { LiveState, Registration } from './live-rows';
17
14
 
18
15
  /**
19
16
  * The code a `reconnect` frame closes with: `CLOSE.drain`, the same number the node uses for a
20
- * drain it closes itself, so a log reads one code for one event whichever side closed first. It
21
- * was 1001, and a browser refuses that from script: `WebSocket.close()` throws
22
- * `InvalidAccessError: The close code must be either 1000, or between 3000 and 4999` — measured
23
- * in Chrome, an uncaught exception in every tab on every node drain. The reconnect still happened,
24
- * because the node closed the socket a moment later; the exception was the only trace.
25
- * `HEARTBEAT_TIMEOUT_CODE` in `client.ts` is the sibling, for the other close the client makes.
17
+ * drain it closes itself, so a log reads one code for one event whichever side closed first. A
18
+ * browser refuses 1001 from script (`InvalidAccessError`), which is why it is not that.
26
19
  */
27
20
  export const RECONNECT_CODE = CLOSE.drain;
28
21
 
29
22
  /**
30
23
  * Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
31
24
  * client itself could touch the reconnect timer, the socket and the outbound path, none of which
32
- * a received frame has any business writing.
25
+ * a received frame has any business writing. There is no write path here at all: the socket is
26
+ * read-only, and a client write is an HTTP call (`useMutation`).
33
27
  */
34
- export interface ClientFrameTarget<T extends TableMap = TableMap> {
28
+ export interface ClientFrameTarget {
35
29
  registration(sid: string): Registration | undefined;
36
- /** The projection every live window renders through. Rows live in its map, never on a frame. */
30
+ /** The projection every live window renders through. Rows live in the store, never on a frame. */
37
31
  readonly windows: RowWindows;
38
- topicHandlers(topic: string): ReadonlySet<(message: JsonObject) => void> | undefined;
39
- readonly queue: OfflineQueue | undefined;
40
- readonly store: LocalStore<T> | undefined;
41
- readonly log: RebaseLog<T> | undefined;
32
+ /** The declared channels this client holds: their cursors, their handlers, their catch-up. */
33
+ readonly channels: ChannelBook;
42
34
  /** The client's clock. A cursor carries `at`, and nothing here may read `Date.now()`. */
43
35
  now(): number;
44
36
  /** A newer build is live; the app decides when to reload. */
@@ -46,62 +38,26 @@ export interface ClientFrameTarget<T extends TableMap = TableMap> {
46
38
  /** The node assigned this socket its own delay before closing it. */
47
39
  scheduleReconnect(afterMs: number | null): void;
48
40
  closeSocket(code: number, reason: string): void;
49
- notifyQueueChange(): void;
50
- /** Where a promise nobody awaits reports its failure. The client's `onError`, never a swallow. */
51
- detach(work: Promise<unknown>): void;
41
+ /** Where a refusal that names nothing this client holds is reported. */
42
+ report(error: unknown): void;
52
43
  }
53
44
 
54
- /**
55
- * The server refused a mutation: undo its optimistic half. Tier 2 has neither a store nor a log,
56
- * so there is nothing optimistic to undo and the queue entry is the whole record.
57
- */
58
- function rollbackFailed<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
59
- const store = target.store;
60
- const log = target.log;
61
- if (!store || !log) return;
62
- // One batch for the whole undo: the rollback and every mutator replayed behind it are one
63
- // frame's worth of change, so a live window holding those rows renders once.
64
- store.identity.batch(() => {
65
- rollbackMutation({ store, log, key });
66
- });
67
- }
68
-
69
- /**
70
- * The server took it, so the write is no longer optimistic: the journal goes (there is nothing to
71
- * roll back TO any more — this write is what the server has) and the rebase entry goes with it, or
72
- * every later reconcile replays a mutation the server already applied, over rows that have moved
73
- * on. The row itself stays exactly as the twin left it — an accepted write does not flicker.
74
- *
75
- * Both calls are no-ops for a key nothing holds, which is what makes this safe as the tail of the
76
- * `rebase` + `ack` pair: the rebase in front of it has already reconciled and dropped the same key.
77
- */
78
- function commitAccepted<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
79
- target.store?.commit(key);
80
- target.log?.drop(key);
81
- }
82
-
83
- /** Presence members cross the topic channel as plain JSON, like every other channel message. */
84
- function memberJson(member: PresenceMember): JsonValue {
85
- return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
86
- }
87
-
88
- export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrameTarget<T>): void {
45
+ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
89
46
  switch (frame.type) {
90
47
  case 'snapshot': {
91
48
  const registration = target.registration(frame.sid);
92
49
  if (!registration) return;
93
- // The entity is the server's, and it is what upgrades this window from its own private scope
94
- // to the one every other query over the same entity shares.
95
- target.windows.snapshot(registration, frame.entity ?? null, frame.rows);
50
+ // State first: the window notifies once, after it has moved, and a reader must see `live`
51
+ // beside the rows it is handed. The record type is the server's — a browser cannot derive it.
96
52
  registration.cursor = frame.cursor;
97
- registration.setCursor(frame.cursor);
98
- registration.setState('live');
53
+ registration.state = 'live';
54
+ registration.error = undefined;
55
+ target.windows.snapshot(registration, frame.entity ?? null, frame.rows, frame.keys);
99
56
  return;
100
57
  }
101
58
  case 'patch': {
102
59
  const registration = target.registration(frame.sid);
103
60
  if (registration) {
104
- target.windows.patch(registration, frame.patches);
105
61
  // The cursor moves with the patches, not only with a snapshot. Left behind, `cursor.at`
106
62
  // froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for
107
63
  // every client connected longer than `maxLagMs` — the delta resume the retained change
@@ -110,53 +66,26 @@ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrame
110
66
  if (registration.cursor && frame.lsn !== '') {
111
67
  const next = advance(registration.cursor, frame.patches, frame.lsn, target.now());
112
68
  registration.cursor = next;
113
- registration.setCursor(next);
114
69
  }
115
- registration.setState('live');
70
+ registration.state = 'live';
71
+ target.windows.patch(registration, frame.patches);
116
72
  return;
117
73
  }
118
- // No registration: it is a tier-1 channel message on `sid = topic`.
119
- const handlers = target.topicHandlers(frame.sid);
120
- if (!handlers) return;
121
- for (const patch of frame.patches) {
122
- if (patch.row === null) continue;
123
- for (const handler of handlers) handler(patch.row);
124
- }
74
+ // A patch names a live registration or nothing: a channel's rows ride `records` frames.
125
75
  return;
126
76
  }
127
77
  case 'ack': {
128
- const queue = target.queue;
129
- // A refused mutation is not a mutation: its optimistic twin has to come off the screen, and
130
- // its rebase entry has to leave the log, or a denied write stays rendered forever and every
131
- // later reconcile replays it. `ref` is the mutation key — the same key the `mutate` frame
132
- // carried — which is what makes both halves reachable from one frame.
133
- if (frame.error) rollbackFailed(frame.ref, target);
134
- else commitAccepted(frame.ref, target);
135
- // `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
136
- // than notifying right after the call keeps this correct even if that ordering ever
137
- // changes, and it still fires exactly once the persisted write actually lands.
138
- const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
139
- if (settled) target.detach(settled.then(() => target.notifyQueueChange()));
140
- return;
141
- }
142
- case 'rebase': {
143
- const store = target.store;
144
- const log = target.log;
145
- if (!store || !log) return;
146
- // One batch for the whole reconcile — a rollback, server truth and every replayed mutator
147
- // are one frame's worth of change, so a live window holding those rows renders once.
148
- store.identity.batch(() => {
149
- reconcile({
150
- store,
151
- log,
152
- ack: {
153
- key: frame.key,
154
- entity: frame.entity,
155
- id: frame.row?.id ?? frame.key,
156
- row: frame.row,
157
- },
158
- });
159
- });
78
+ // The socket carries no writes, so an `ack` is only ever a refusal: of a subscription (its
79
+ // `ref` is the sid) or of a frame the node could not read at all (its `ref` is the socket).
80
+ if (frame.error === null) return;
81
+ const registration = target.registration(frame.ref);
82
+ if (registration === undefined) {
83
+ if (!target.channels.refused(frame.ref, frame.error)) target.report(frame.error);
84
+ return;
85
+ }
86
+ registration.state = 'failed';
87
+ registration.error = frame.error;
88
+ registration.notify();
160
89
  return;
161
90
  }
162
91
  case 'reconnect': {
@@ -170,16 +99,19 @@ export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrame
170
99
  target.setUpdate(frame.buildId);
171
100
  return;
172
101
  }
173
- case 'presence': {
174
- const handlers = target.topicHandlers(frame.topic);
175
- if (!handlers) return;
176
- const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
177
- for (const handler of handlers) handler(message);
102
+ case 'records':
103
+ // The channel's cursor decides new from duplicate, and a new epoch re-reads the channel;
104
+ // the records go to the store and nowhere else.
105
+ target.channels.records(frame);
106
+ return;
107
+ case 'events':
108
+ target.channels.event(frame);
109
+ return;
110
+ case 'replay-gap':
111
+ target.channels.gap(frame);
178
112
  return;
179
- }
180
113
  case 'hello':
181
114
  case 'subscribe':
182
- case 'mutate':
183
115
  // Client-authored frames: never received. Ignored rather than thrown, so a future
184
116
  // bidirectional use of the same kind cannot break an old client.
185
117
  return;