@ultimat3/realtime 20.2.1 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +300 -952
- package/README.md +192 -131
- 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 +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -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 +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- 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 +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- 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 +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- 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 +227 -0
- package/src/use-query.ts +260 -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 {
|
|
22
|
+
import { browserBackoff, policyDelay, timeoutScheduler } from './thundering-herd';
|
|
29
23
|
|
|
30
|
-
/**
|
|
31
|
-
* The client's own shapes, re-exported from where they are declared: an app imports `ClientSocket`
|
|
32
|
-
* and `LiveClientOptions` from the client it configures, not from a file it never names.
|
|
33
|
-
*/
|
|
34
24
|
export type {
|
|
35
25
|
ClientSocket,
|
|
36
|
-
LiveClientLike,
|
|
37
26
|
LiveClientOptions,
|
|
38
27
|
LiveHandle,
|
|
39
28
|
LiveQueryRef,
|
|
40
|
-
MutatorRef,
|
|
41
|
-
SignalFactory,
|
|
42
29
|
Unsubscribe,
|
|
43
30
|
} from './client-contract';
|
|
44
31
|
|
|
@@ -57,32 +44,17 @@ const reportToConsole = (error: unknown): void => {
|
|
|
57
44
|
console.error(error);
|
|
58
45
|
};
|
|
59
46
|
|
|
60
|
-
export class LiveClient
|
|
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,34 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
90
62
|
#reconnectTimer: (() => void) | null = null;
|
|
91
63
|
/** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */
|
|
92
64
|
#closed = false;
|
|
93
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
73
|
+
this.store = options.store ?? new RecordStore();
|
|
74
|
+
this.#windows = new RowWindows(this.store);
|
|
75
|
+
this.#channels = new ChannelBook({
|
|
76
|
+
store: this.store,
|
|
77
|
+
send: (frame) => this.#send(frame),
|
|
78
|
+
connected: () => this.#connected,
|
|
79
|
+
catchUp: options.catchUp,
|
|
80
|
+
report: (error) => this.#onError(error),
|
|
81
|
+
// A failed catch-up retries on the reconnect curve this client already dials on.
|
|
82
|
+
retry: (attempt, run) =>
|
|
83
|
+
(this.#options.scheduler ?? timeoutScheduler)(
|
|
84
|
+
run,
|
|
85
|
+
// `attempt` is the book's failure count, 1 on the first failure: core's count as is.
|
|
86
|
+
policyDelay(
|
|
87
|
+
this.#options.backoff ?? browserBackoff,
|
|
88
|
+
attempt,
|
|
89
|
+
this.#options.rng ?? Math.random,
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
});
|
|
116
93
|
this.#heartbeat = new Heartbeat({
|
|
117
94
|
intervalMs: finiteOption(
|
|
118
95
|
'the sync client',
|
|
@@ -127,7 +104,25 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
127
104
|
}
|
|
128
105
|
|
|
129
106
|
get connected(): boolean {
|
|
130
|
-
return this.#connected
|
|
107
|
+
return this.#connected;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Epoch ms of the next reconnect attempt; `null` while the socket is up. */
|
|
111
|
+
reconnectAt(): number | null {
|
|
112
|
+
return this.#reconnectAt;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The buildId the server announced, or `null` while this build is current. */
|
|
116
|
+
appUpdateAvailable(): string | null {
|
|
117
|
+
return this.#update;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Called after `connected`, `reconnectAt()` or `appUpdateAvailable()` moves. */
|
|
121
|
+
onStatus(listener: () => void): () => void {
|
|
122
|
+
this.#statusListeners.add(listener);
|
|
123
|
+
return () => {
|
|
124
|
+
this.#statusListeners.delete(listener);
|
|
125
|
+
};
|
|
131
126
|
}
|
|
132
127
|
|
|
133
128
|
connect(): void {
|
|
@@ -142,7 +137,7 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
142
137
|
previous?.close(1000, 'reconnect');
|
|
143
138
|
// …and because that corpse's `onClose` returns, this is the only place the connection it was
|
|
144
139
|
// carrying can be written off: offline until the NEW socket opens. Reporting the replaced
|
|
145
|
-
// socket's state through the redial sent a
|
|
140
|
+
// socket's state through the redial sent a live subscription opened in that window straight onto an
|
|
146
141
|
// unopened socket — a subscribe frame ahead of `hello`, then a second one for the same sid
|
|
147
142
|
// when `onOpen` replayed it, which the node refuses with X_SUBSCRIPTION_ID_TAKEN.
|
|
148
143
|
//
|
|
@@ -160,20 +155,17 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
160
155
|
// and the one handler that had none. A replaced socket opening late would otherwise mark the
|
|
161
156
|
// live connection up and replay every subscription onto whatever socket is current.
|
|
162
157
|
if (this.#socket !== socket) return;
|
|
163
|
-
this.#setConnected(true);
|
|
164
158
|
this.#attempt = 0;
|
|
165
|
-
this.#
|
|
159
|
+
this.#setStatus({ connected: true, reconnectAt: null });
|
|
166
160
|
// `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
|
|
167
161
|
// frame below, which is the only place resume is decided — sending it here too shipped every
|
|
168
162
|
// cursor twice per reconnect, once into a field the node discards.
|
|
169
163
|
this.#send(this.#hello());
|
|
170
164
|
for (const registration of this.#registrations.values()) this.#sendSubscribe(registration);
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
|
|
165
|
+
// Channel membership lives on the node's socket and `hello` carries none of it, so every
|
|
166
|
+
// channel is re-announced here — each from its own cursor, so the node replays the rest.
|
|
167
|
+
this.#channels.resubscribe();
|
|
175
168
|
this.#heartbeat.start(this.#clock.now().getTime());
|
|
176
|
-
this.#detach(this.drain());
|
|
177
169
|
});
|
|
178
170
|
socket.onMessage((data) => {
|
|
179
171
|
// A frame speaks only for its own socket, the same rule `onClose` follows. A replaced socket
|
|
@@ -198,18 +190,19 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
198
190
|
|
|
199
191
|
/**
|
|
200
192
|
* Everything a lost connection costs, whoever noticed it — a close, a replacement, an explicit
|
|
201
|
-
* teardown, a heartbeat that timed out.
|
|
202
|
-
* mutation handed to a socket that is now gone was never acknowledged, so it goes back in the
|
|
203
|
-
* queue rather than waiting for an ack nobody will send.
|
|
193
|
+
* teardown, a heartbeat that timed out.
|
|
204
194
|
*/
|
|
205
195
|
#offline(): void {
|
|
206
196
|
this.#heartbeat.stop();
|
|
207
|
-
this.#
|
|
208
|
-
// Told once, not two ways: a `useConnection().offline` that flips while a
|
|
209
|
-
//
|
|
210
|
-
for (const registration of this.#registrations.values())
|
|
211
|
-
|
|
212
|
-
|
|
197
|
+
this.#setStatus({ connected: false });
|
|
198
|
+
// Told once, not two ways: a `useConnection().offline` that flips while a live window still
|
|
199
|
+
// reads 'live' is one dead socket rendered as two states. A refused one stays refused.
|
|
200
|
+
for (const registration of this.#registrations.values()) {
|
|
201
|
+
if (registration.state === 'failed' || registration.state === 'offline') continue;
|
|
202
|
+
registration.state = 'offline';
|
|
203
|
+
registration.notify();
|
|
204
|
+
}
|
|
205
|
+
this.#channels.offline();
|
|
213
206
|
}
|
|
214
207
|
|
|
215
208
|
/**
|
|
@@ -220,7 +213,7 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
220
213
|
close(code = 1000, reason = 'client closed'): void {
|
|
221
214
|
this.#closed = true;
|
|
222
215
|
this.#cancelReconnect();
|
|
223
|
-
this.#
|
|
216
|
+
this.#setStatus({ reconnectAt: null });
|
|
224
217
|
this.#attempt = 0;
|
|
225
218
|
const socket = this.#socket;
|
|
226
219
|
this.#socket = null;
|
|
@@ -229,34 +222,40 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
229
222
|
this.#offline();
|
|
230
223
|
}
|
|
231
224
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Subscribe to a live query. The window holds ids; the rows are the page store's, so a record
|
|
227
|
+
* updated by an HTTP response or another window re-renders here too, with no second copy.
|
|
228
|
+
*/
|
|
229
|
+
subscribeLive<R extends object = JsonObject>(
|
|
230
|
+
query: LiveQueryRef,
|
|
231
|
+
input: JsonValue,
|
|
232
|
+
): LiveHandle<R> {
|
|
234
233
|
const sid = uuid();
|
|
235
|
-
const
|
|
236
|
-
// 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
|
|
237
|
-
const [state, setState] = this.#options.signal<LiveState>(
|
|
238
|
-
this.#connected() ? 'loading' : 'offline',
|
|
239
|
-
);
|
|
240
|
-
const [cursor, setCursor] = this.#options.signal<LiveCursor | null>(null);
|
|
234
|
+
const listeners = new Set<() => void>();
|
|
241
235
|
const registration: Registration = {
|
|
242
236
|
sid,
|
|
243
237
|
name: query.name,
|
|
244
238
|
input,
|
|
245
|
-
|
|
246
|
-
setState,
|
|
247
|
-
setCursor,
|
|
248
|
-
// Private until the first snapshot names the entity: sharing rows with another query on a
|
|
249
|
-
// scope nobody confirmed would merge two entities that spell one id the same way.
|
|
250
|
-
scope: privateScope(query.name),
|
|
239
|
+
type: unnamedType(query.name),
|
|
251
240
|
ids: [],
|
|
252
241
|
cursor: null,
|
|
242
|
+
// 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
|
|
243
|
+
state: this.#connected ? 'loading' : 'offline',
|
|
244
|
+
error: undefined,
|
|
245
|
+
notify: () => {
|
|
246
|
+
for (const listener of listeners) listener();
|
|
247
|
+
},
|
|
253
248
|
};
|
|
254
249
|
this.#registrations.set(sid, registration);
|
|
255
250
|
const close = this.#windows.open(registration);
|
|
256
|
-
if (this.#connected
|
|
251
|
+
if (this.#connected) this.#sendSubscribe(registration);
|
|
252
|
+
let open = true;
|
|
257
253
|
const unsubscribe = (): void => {
|
|
254
|
+
if (!open) return;
|
|
255
|
+
open = false;
|
|
258
256
|
this.#registrations.delete(sid);
|
|
259
257
|
close();
|
|
258
|
+
listeners.clear();
|
|
260
259
|
this.#send({
|
|
261
260
|
type: 'subscribe',
|
|
262
261
|
v: PROTOCOL_VERSION,
|
|
@@ -266,81 +265,32 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
266
265
|
});
|
|
267
266
|
};
|
|
268
267
|
return {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
268
|
+
// Rows are typed by the caller's query; on the wire every one is a JSON object.
|
|
269
|
+
rows: () => this.#windows.rows(registration) as readonly R[],
|
|
270
|
+
state: (): LiveState => registration.state,
|
|
271
|
+
cursor: (): LiveCursor | null => registration.cursor,
|
|
272
|
+
error: (): unknown => registration.error,
|
|
273
|
+
onChange: (listener) => {
|
|
274
|
+
listeners.add(listener);
|
|
275
|
+
return () => {
|
|
276
|
+
listeners.delete(listener);
|
|
277
|
+
};
|
|
278
|
+
},
|
|
272
279
|
unsubscribe,
|
|
273
280
|
[Symbol.dispose]: unsubscribe,
|
|
274
281
|
};
|
|
275
282
|
}
|
|
276
283
|
|
|
277
|
-
subscribe(name: Topic, handler: (message: JsonObject) => void): Unsubscribe {
|
|
278
|
-
this.#topics.add(name, handler);
|
|
279
|
-
this.#send(topicSubscribeFrame(name, 'add'));
|
|
280
|
-
// A function is an object: attaching `[Symbol.dispose]` keeps the existing callable contract
|
|
281
|
-
// (`const unsub = channel.subscribe(...); unsub()`) intact while adding `using sub = ...`.
|
|
282
|
-
const unsubscribe: Unsubscribe = (): void => {
|
|
283
|
-
if (!this.#topics.remove(name, handler)) return;
|
|
284
|
-
this.#send(topicSubscribeFrame(name, 'drop'));
|
|
285
|
-
};
|
|
286
|
-
unsubscribe[Symbol.dispose] = unsubscribe;
|
|
287
|
-
return unsubscribe;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** Tier 1 publish. The server re-checks the topic policy; this is a request, not an assertion. */
|
|
291
|
-
publish(name: Topic, message: JsonObject): void {
|
|
292
|
-
this.#send({
|
|
293
|
-
type: 'patch',
|
|
294
|
-
v: PROTOCOL_VERSION,
|
|
295
|
-
sid: name,
|
|
296
|
-
lsn: '',
|
|
297
|
-
patches: [{ op: 'insert', id: uuid(), row: message, lsn: '' }],
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* The mutator entry point. Records the optimistic twin, the rebase entry and the durable queue
|
|
303
|
-
* entry, then drains. Offline, everything but the drain still happens — that is tier 3's one
|
|
304
|
-
* extra property over tier 2.
|
|
305
|
-
*/
|
|
306
|
-
async mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void> {
|
|
307
|
-
await recordMutation(this.#mutations, mutator, input, key);
|
|
308
|
-
if (this.#options.queue) await this.drain();
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Sends every pending mutation in sequence order. Stops at the first one the socket refuses. */
|
|
312
|
-
async drain(): Promise<void> {
|
|
313
|
-
const queue = this.#options.queue;
|
|
314
|
-
if (!queue || !this.#connected()) return;
|
|
315
|
-
await queue.drain(mutationSender(this.#mutations));
|
|
316
|
-
this.#notifyQueueChange();
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** The mutation path's view of this client. Built per call, exactly like `#frameTarget`. */
|
|
320
|
-
get #mutations(): MutationDeps<T> {
|
|
321
|
-
return {
|
|
322
|
-
store: this.#options.store,
|
|
323
|
-
queue: this.#options.queue,
|
|
324
|
-
log: this.#options.log,
|
|
325
|
-
now: () => this.#clock.now().getTime(),
|
|
326
|
-
socket: () => this.#socket,
|
|
327
|
-
send: (frame) => this.#send(frame),
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
284
|
/**
|
|
332
|
-
*
|
|
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.
|
|
285
|
+
* Hold a declared channel. N holders on one topic share ONE membership; the last release drops
|
|
286
|
+
* it. Its `records` land in the store; `events` and presence reach `handlers` only.
|
|
338
287
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
288
|
+
holdChannel<K extends string>(
|
|
289
|
+
ref: ChannelRef<K>,
|
|
290
|
+
params: Readonly<Record<K, string>>,
|
|
291
|
+
handlers?: ChannelHandlers,
|
|
292
|
+
): ChannelMembership {
|
|
293
|
+
return this.#channels.hold(ref, params, handlers);
|
|
344
294
|
}
|
|
345
295
|
|
|
346
296
|
#sendSubscribe(registration: Registration): void {
|
|
@@ -362,22 +312,18 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
362
312
|
* The client's inbound surface, handed to the router. Built once: a frame reaches exactly these
|
|
363
313
|
* members and nothing else on the client.
|
|
364
314
|
*/
|
|
365
|
-
get #frameTarget(): ClientFrameTarget
|
|
315
|
+
get #frameTarget(): ClientFrameTarget {
|
|
366
316
|
return {
|
|
367
317
|
registration: (sid) => this.#registrations.get(sid),
|
|
368
318
|
windows: this.#windows,
|
|
369
|
-
|
|
370
|
-
queue: this.#options.queue,
|
|
371
|
-
store: this.#options.store,
|
|
372
|
-
log: this.#options.log,
|
|
319
|
+
channels: this.#channels,
|
|
373
320
|
// The client's clock, never `Date.now()`: a cursor's `at` is what decides a delta resume
|
|
374
321
|
// against a re-snapshot, so the frame path reads the same clock every other path does.
|
|
375
322
|
now: () => this.#clock.now().getTime(),
|
|
376
|
-
setUpdate: (buildId) => this.#
|
|
323
|
+
setUpdate: (buildId) => this.#setStatus({ update: buildId }),
|
|
377
324
|
scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
|
|
378
325
|
closeSocket: (code, reason) => this.#socket?.close(code, reason),
|
|
379
|
-
|
|
380
|
-
detach: (work) => this.#detach(work),
|
|
326
|
+
report: (error) => this.#onError(error),
|
|
381
327
|
};
|
|
382
328
|
}
|
|
383
329
|
|
|
@@ -407,7 +353,7 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
407
353
|
*/
|
|
408
354
|
#beat(): void {
|
|
409
355
|
this.#send(this.#hello());
|
|
410
|
-
|
|
356
|
+
this.#channels.beat();
|
|
411
357
|
}
|
|
412
358
|
|
|
413
359
|
/**
|
|
@@ -434,9 +380,10 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
434
380
|
this.#cancelReconnect();
|
|
435
381
|
const rng = this.#options.rng ?? Math.random;
|
|
436
382
|
const delay =
|
|
437
|
-
|
|
383
|
+
// `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one.
|
|
384
|
+
serverDelayMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng);
|
|
438
385
|
this.#attempt += 1;
|
|
439
|
-
this.#
|
|
386
|
+
this.#setStatus({ reconnectAt: this.#clock.now().getTime() + delay });
|
|
440
387
|
const schedule = this.#options.scheduler ?? timeoutScheduler;
|
|
441
388
|
this.#reconnectTimer = schedule(() => {
|
|
442
389
|
// Cleared before dialling, not after: the attempt's own close must be free to arm the next
|
|
@@ -467,17 +414,25 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
467
414
|
this.#socket?.send(encode(frame));
|
|
468
415
|
}
|
|
469
416
|
|
|
470
|
-
/**
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
417
|
+
/** One status write, one notification — and none for a write that changed nothing. */
|
|
418
|
+
#setStatus(next: {
|
|
419
|
+
connected?: boolean;
|
|
420
|
+
reconnectAt?: number | null;
|
|
421
|
+
update?: string | null;
|
|
422
|
+
}): void {
|
|
423
|
+
let moved = false;
|
|
424
|
+
if (next.connected !== undefined && next.connected !== this.#connected) {
|
|
425
|
+
this.#connected = next.connected;
|
|
426
|
+
moved = true;
|
|
427
|
+
}
|
|
428
|
+
if (next.reconnectAt !== undefined && next.reconnectAt !== this.#reconnectAt) {
|
|
429
|
+
this.#reconnectAt = next.reconnectAt;
|
|
430
|
+
moved = true;
|
|
431
|
+
}
|
|
432
|
+
if (next.update !== undefined && next.update !== this.#update) {
|
|
433
|
+
this.#update = next.update;
|
|
434
|
+
moved = true;
|
|
435
|
+
}
|
|
436
|
+
if (moved) for (const listener of this.#statusListeners) listener();
|
|
482
437
|
}
|
|
483
438
|
}
|
package/src/cursor.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// and the budget exists to make the expensive answer (a snapshot) the *chosen* one, not the
|
|
4
4
|
// accidental one. See README "Reconnect is the hard part".
|
|
5
5
|
|
|
6
|
-
import { type Clock, systemClock } from '@ultimat3/core';
|
|
7
|
-
import { CursorStaleError } from './errors';
|
|
6
|
+
import { type Clock, systemClock } from '@ultimat3/core/page';
|
|
8
7
|
import type { Row, RowPatch } from './json';
|
|
8
|
+
import { CursorStaleError } from './page-errors';
|
|
9
9
|
|
|
10
10
|
/** Ids are bounded so a cursor stays small enough to ship on every `subscribe` frame. */
|
|
11
11
|
export const CURSOR_ID_LIMIT = 512;
|
|
@@ -161,6 +161,11 @@ export function advance(
|
|
|
161
161
|
lsn: string,
|
|
162
162
|
now: number,
|
|
163
163
|
): LiveCursor {
|
|
164
|
+
// An update moves no id in or out, and it is the common change: the ids are reused as they are,
|
|
165
|
+
// so a fan-out to N subscribers of a W-row window is not N rebuilds of a W-id set per change.
|
|
166
|
+
if (!patches.some((patch) => patch.op !== 'update')) {
|
|
167
|
+
return { qid: cursor.qid, lsn, ids: cursor.ids, at: now };
|
|
168
|
+
}
|
|
164
169
|
const ids = new Set(cursor.ids);
|
|
165
170
|
for (const patch of patches) {
|
|
166
171
|
if (patch.op === 'delete') ids.delete(patch.id);
|