@ultimat3/realtime 20.2.1 → 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.
- package/CLAUDE.md +186 -122
- package/README.md +121 -126
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +214 -0
- package/src/use-query.ts +255 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- 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
|
+
}
|
package/src/client-contract.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
88
|
-
readonly store?:
|
|
89
|
-
|
|
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
|
|
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
|
}
|
package/src/client-frames.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
21
|
-
*
|
|
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
|
|
28
|
+
export interface ClientFrameTarget {
|
|
35
29
|
registration(sid: string): Registration | undefined;
|
|
36
|
-
/** The projection every live window renders through. Rows live in
|
|
30
|
+
/** The projection every live window renders through. Rows live in the store, never on a frame. */
|
|
37
31
|
readonly windows: RowWindows;
|
|
38
|
-
|
|
39
|
-
readonly
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
//
|
|
94
|
-
//
|
|
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.
|
|
98
|
-
registration.
|
|
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.
|
|
70
|
+
registration.state = 'live';
|
|
71
|
+
target.windows.patch(registration, frame.patches);
|
|
116
72
|
return;
|
|
117
73
|
}
|
|
118
|
-
//
|
|
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
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 '
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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;
|