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