@ultimat3/realtime 1.2.0 → 2.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 +591 -0
- package/README.md +320 -19
- package/package.json +6 -3
- package/src/apply-patches.ts +60 -0
- package/src/change-buffer.ts +77 -11
- package/src/channel.ts +174 -19
- package/src/client-contract.ts +81 -0
- package/src/client-frames.ts +175 -0
- package/src/client-heartbeat.ts +77 -0
- package/src/client-mutations.ts +114 -0
- package/src/client-topics.ts +54 -0
- package/src/client.ts +307 -273
- package/src/cursor.ts +7 -1
- package/src/errors.ts +193 -4
- package/src/frame-lanes.ts +58 -0
- package/src/hooks.ts +19 -5
- package/src/identity-map.ts +141 -0
- package/src/index.ts +96 -28
- package/src/json.ts +38 -1
- package/src/live-contract.ts +67 -0
- package/src/live-definition.ts +16 -11
- package/src/live-fanout.ts +150 -0
- package/src/live-query.ts +215 -268
- package/src/live-rows.ts +143 -0
- package/src/local-store.ts +86 -43
- package/src/nats-client.ts +132 -0
- package/src/nats-fake.ts +389 -344
- package/src/nats-jetstream.ts +21 -20
- package/src/nats-kv.ts +7 -7
- package/src/nats-lib-client.ts +210 -0
- package/src/nats-transport.ts +109 -138
- package/src/offline-queue.ts +146 -30
- package/src/pg-entity-row.ts +99 -31
- package/src/pg-replication.ts +84 -27
- package/src/pg-socket.ts +4 -1
- package/src/policy-gate.ts +13 -5
- package/src/presence.ts +76 -6
- package/src/query-hook.ts +56 -0
- package/src/query-window.ts +151 -0
- package/src/rebase.ts +68 -8
- package/src/replicator.ts +84 -11
- package/src/socket.ts +170 -14
- package/src/subscriber-gate.ts +209 -0
- package/src/subscription-book.ts +237 -0
- package/src/sync-auth.ts +124 -0
- package/src/sync-frames.ts +185 -0
- package/src/sync-listen.ts +73 -0
- package/src/sync-node.ts +284 -243
- package/src/sync-protocol.ts +115 -24
- package/src/sync-upgrade.ts +124 -0
- package/src/thundering-herd.ts +21 -0
- package/src/transport-env.ts +3 -3
- package/src/type-pins.ts +72 -0
- package/src/window-lock.ts +21 -0
- package/src/nats-commands.ts +0 -97
- package/src/nats-connection-fixture.ts +0 -105
- package/src/nats-connection.ts +0 -464
- package/src/nats-protocol.ts +0 -222
- package/src/nats-socket.ts +0 -236
- package/src/pg-connection-fixture.ts +0 -215
- package/src/pg-replication-fixture.ts +0 -261
package/src/client.ts
CHANGED
|
@@ -1,84 +1,65 @@
|
|
|
1
1
|
// The client half. Framework-agnostic on purpose: the reactive primitive is injected, so this
|
|
2
|
-
// package never imports solid-js and can be exercised by `bun test` with two closures.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// same call tier 3. Nothing about the subscription changes — that is the ladder's whole promise.
|
|
2
|
+
// package never imports solid-js and can be exercised by `bun test` with two closures. One client
|
|
3
|
+
// serves all three tiers: `useLive` is tier 2, and a `store` + `queue` makes the same call tier 3
|
|
4
|
+
// with nothing about the subscription changing — that is the ladder's whole promise.
|
|
6
5
|
|
|
7
6
|
import { type Clock, systemClock, uuid } from '@ultimat3/core';
|
|
8
7
|
import type { Topic } from './channel';
|
|
8
|
+
import type {
|
|
9
|
+
ClientSocket,
|
|
10
|
+
LiveClientOptions,
|
|
11
|
+
LiveHandle,
|
|
12
|
+
LiveQueryRef,
|
|
13
|
+
MutatorRef,
|
|
14
|
+
SignalFactory,
|
|
15
|
+
Unsubscribe,
|
|
16
|
+
} from './client-contract';
|
|
17
|
+
import { applyFrame, type ClientFrameTarget } from './client-frames';
|
|
18
|
+
import { DEFAULT_HEARTBEAT_MS, Heartbeat } from './client-heartbeat';
|
|
19
|
+
import { type MutationDeps, mutationSender, recordMutation } from './client-mutations';
|
|
20
|
+
import { TopicBook, topicSubscribeFrame } from './client-topics';
|
|
9
21
|
import type { LiveCursor } from './cursor';
|
|
10
|
-
import
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
22
|
+
import { IdentityMap, privateScope } from './identity-map';
|
|
23
|
+
import type { JsonObject, JsonValue, Row } from './json';
|
|
24
|
+
import { type LiveState, type Registration, RowWindows } from './live-rows';
|
|
25
|
+
import type { TableMap } from './local-store';
|
|
26
|
+
import type { OfflineQueue } from './offline-queue';
|
|
27
|
+
import { decode, encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
|
|
28
|
+
import { backoffDelay, defaultBackoff, timeoutScheduler } from './thundering-herd';
|
|
16
29
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
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
|
+
export type {
|
|
35
|
+
ClientSocket,
|
|
36
|
+
LiveClientOptions,
|
|
37
|
+
LiveHandle,
|
|
38
|
+
LiveQueryRef,
|
|
39
|
+
MutatorRef,
|
|
40
|
+
SignalFactory,
|
|
41
|
+
Unsubscribe,
|
|
42
|
+
} from './client-contract';
|
|
19
43
|
|
|
20
|
-
/**
|
|
21
|
-
export
|
|
22
|
-
send(data: string): void;
|
|
23
|
-
close(code?: number, reason?: string): void;
|
|
24
|
-
onOpen(handler: () => void): void;
|
|
25
|
-
onMessage(handler: (data: string) => void): void;
|
|
26
|
-
onClose(handler: (code: number) => void): void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type LiveState = 'loading' | 'live' | 'stale' | 'offline';
|
|
30
|
-
|
|
31
|
-
export interface LiveHandle<R extends Row = Row> {
|
|
32
|
-
/** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
|
|
33
|
-
readonly rows: () => readonly R[];
|
|
34
|
-
readonly state: () => LiveState;
|
|
35
|
-
readonly cursor: () => LiveCursor | null;
|
|
36
|
-
unsubscribe(): void;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface LiveQueryRef {
|
|
40
|
-
readonly name: string;
|
|
41
|
-
}
|
|
44
|
+
/** The four states a live subscription renders. Declared with the window that holds them. */
|
|
45
|
+
export type { LiveState } from './live-rows';
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
|
|
46
|
-
local?: (tx: LocalTx<T>, input: JsonValue) => void;
|
|
47
|
-
readonly entity?: string;
|
|
48
|
-
readonly conflict?: ConflictStrategy;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface LiveClientOptions<T extends TableMap = TableMap> {
|
|
52
|
-
readonly signal: SignalFactory;
|
|
53
|
-
/** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
|
|
54
|
-
readonly connect: () => ClientSocket;
|
|
55
|
-
readonly buildId: string;
|
|
56
|
-
readonly actorId?: string | null;
|
|
57
|
-
/** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
|
|
58
|
-
readonly store?: LocalStore<T>;
|
|
59
|
-
readonly queue?: OfflineQueue;
|
|
60
|
-
readonly log?: RebaseLog<T>;
|
|
61
|
-
readonly backoff?: BackoffPolicy;
|
|
62
|
-
readonly rng?: Rng;
|
|
63
|
-
readonly clock?: Clock;
|
|
64
|
-
}
|
|
47
|
+
/** Private-use close code (4000–4999), so a heartbeat timeout is distinguishable in a log. */
|
|
48
|
+
const HEARTBEAT_TIMEOUT_CODE = 4000;
|
|
65
49
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
readonly setRows: (rows: readonly Row[]) => void;
|
|
71
|
-
readonly setState: (state: LiveState) => void;
|
|
72
|
-
readonly setCursor: (cursor: LiveCursor | null) => void;
|
|
73
|
-
rows: readonly Row[];
|
|
74
|
-
cursor: LiveCursor | null;
|
|
75
|
-
}
|
|
50
|
+
/** The default reporter: `console.error`, never core's `logger` — that writes `process.stderr`. */
|
|
51
|
+
const reportToConsole = (error: unknown): void => {
|
|
52
|
+
console.error(error);
|
|
53
|
+
};
|
|
76
54
|
|
|
77
55
|
export class LiveClient<T extends TableMap = TableMap> {
|
|
78
56
|
readonly #options: LiveClientOptions<T>;
|
|
79
57
|
readonly #clock: Clock;
|
|
58
|
+
readonly #onError: (error: unknown) => void;
|
|
80
59
|
readonly #registrations = new Map<string, Registration>();
|
|
81
|
-
readonly #
|
|
60
|
+
readonly #windows: RowWindows;
|
|
61
|
+
readonly #topics = new TopicBook();
|
|
62
|
+
readonly #heartbeat: Heartbeat;
|
|
82
63
|
readonly #setUpdate: (buildId: string | null) => void;
|
|
83
64
|
readonly #setReconnectAt: (at: number | null) => void;
|
|
84
65
|
|
|
@@ -91,25 +72,33 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
91
72
|
readonly signal: SignalFactory;
|
|
92
73
|
/** The durable queue when tier 3 is configured, so a queue count is read off the queue itself. */
|
|
93
74
|
readonly queue: OfflineQueue | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* One row value per `(entity, id)` for this client. Taken from the local store when tier 3 is
|
|
77
|
+
* configured, so an optimistic write and the live query rendering that row are the same row —
|
|
78
|
+
* a second map here would be exactly the duplication an identity map exists to prevent.
|
|
79
|
+
*/
|
|
80
|
+
readonly identity: IdentityMap;
|
|
94
81
|
|
|
95
82
|
#socket: ClientSocket | null = null;
|
|
96
83
|
#attempt = 0;
|
|
84
|
+
/** The armed reconnect's canceller, and the flag for "one timer in flight, the first wins". */
|
|
85
|
+
#reconnectTimer: (() => void) | null = null;
|
|
86
|
+
/** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */
|
|
87
|
+
#closed = false;
|
|
97
88
|
/** A signal, not a field: `connected` is rendered, so a plain boolean would never re-render. */
|
|
98
89
|
readonly #connected: () => boolean;
|
|
99
90
|
readonly #setConnected: (next: boolean) => void;
|
|
100
|
-
/**
|
|
101
|
-
* Subscribers notified after every offline-queue mutation: a manual drain, the automatic drain
|
|
102
|
-
* `connect()` runs on every reconnect, or an async ack/fail frame. `hooks.ts` wires its
|
|
103
|
-
* invalidation signal through `onQueueChange` rather than each call site remembering to bump it
|
|
104
|
-
* itself — see there for why that matters.
|
|
105
|
-
*/
|
|
91
|
+
/** Notified after every offline-queue mutation; `onQueueChange` says who subscribes, and why. */
|
|
106
92
|
readonly #queueListeners = new Set<() => void>();
|
|
107
93
|
|
|
108
94
|
constructor(options: LiveClientOptions<T>) {
|
|
109
95
|
this.#options = options;
|
|
110
96
|
this.#clock = options.clock ?? systemClock;
|
|
97
|
+
this.#onError = options.onError ?? reportToConsole;
|
|
111
98
|
this.signal = options.signal;
|
|
112
99
|
this.queue = options.queue;
|
|
100
|
+
this.identity = options.store?.identity ?? new IdentityMap();
|
|
101
|
+
this.#windows = new RowWindows(this.identity);
|
|
113
102
|
const [update, setUpdate] = options.signal<string | null>(null);
|
|
114
103
|
const [reconnectAt, setReconnectAt] = options.signal<number | null>(null);
|
|
115
104
|
const [connected, setConnected] = options.signal<boolean>(false);
|
|
@@ -119,6 +108,13 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
119
108
|
this.#setReconnectAt = setReconnectAt;
|
|
120
109
|
this.#connected = connected;
|
|
121
110
|
this.#setConnected = setConnected;
|
|
111
|
+
this.#heartbeat = new Heartbeat({
|
|
112
|
+
intervalMs: options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS,
|
|
113
|
+
schedule: options.scheduler ?? timeoutScheduler,
|
|
114
|
+
now: () => this.#clock.now().getTime(),
|
|
115
|
+
beat: () => this.#beat(),
|
|
116
|
+
onSilence: () => this.#silent(),
|
|
117
|
+
});
|
|
122
118
|
}
|
|
123
119
|
|
|
124
120
|
get connected(): boolean {
|
|
@@ -126,40 +122,112 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
126
122
|
}
|
|
127
123
|
|
|
128
124
|
connect(): void {
|
|
125
|
+
this.#closed = false;
|
|
126
|
+
this.#cancelReconnect();
|
|
127
|
+
// The socket we are replacing goes first. Left open, its `onMessage` keeps running: every
|
|
128
|
+
// patch frame applies twice, and the node holds two sockets for one client — double presence
|
|
129
|
+
// membership and double fanout — until the tab closes. Nulled before the close so the corpse's
|
|
130
|
+
// `onClose` takes its own early return rather than marking the new connection offline.
|
|
131
|
+
const previous = this.#socket;
|
|
132
|
+
this.#socket = null;
|
|
133
|
+
previous?.close(1000, 'reconnect');
|
|
134
|
+
// …and because that corpse's `onClose` returns, this is the only place the connection it was
|
|
135
|
+
// carrying can be written off: offline until the NEW socket opens. Reporting the replaced
|
|
136
|
+
// socket's state through the redial sent a `useLive` opened in that window straight onto an
|
|
137
|
+
// unopened socket — a subscribe frame ahead of `hello`, then a second one for the same sid
|
|
138
|
+
// when `onOpen` replayed it, which the node refuses with X_SUBSCRIPTION_ID_TAKEN.
|
|
139
|
+
//
|
|
140
|
+
// BEFORE the dial, and that order is the whole fix for the other half: `connect` is app code
|
|
141
|
+
// (`new WebSocket(url)` refuses on mixed content, or on a URL the page may not open) and it
|
|
142
|
+
// may throw. `close()` always got the state right on the way down; this path did none of it,
|
|
143
|
+
// so a refused dial left the client reporting itself online with no socket and no armed timer,
|
|
144
|
+
// marking every later mutation delivered into nothing. It still throws to the caller and still
|
|
145
|
+
// arms nothing — only the reconnect timer owns the retry chain.
|
|
146
|
+
this.#offline();
|
|
129
147
|
const socket = this.#options.connect();
|
|
130
148
|
this.#socket = socket;
|
|
131
149
|
socket.onOpen(() => {
|
|
150
|
+
// A frame speaks only for its own socket — the same guard `onMessage` and `onClose` carry,
|
|
151
|
+
// and the one handler that had none. A replaced socket opening late would otherwise mark the
|
|
152
|
+
// live connection up and replay every subscription onto whatever socket is current.
|
|
153
|
+
if (this.#socket !== socket) return;
|
|
132
154
|
this.#setConnected(true);
|
|
133
155
|
this.#attempt = 0;
|
|
134
156
|
this.#setReconnectAt(null);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
sessionId: null,
|
|
140
|
-
actorId: this.#options.actorId ?? null,
|
|
141
|
-
resume: [...this.#registrations.values()]
|
|
142
|
-
.map((registration) => registration.cursor)
|
|
143
|
-
.filter((cursor): cursor is LiveCursor => cursor !== null),
|
|
144
|
-
});
|
|
157
|
+
// `hello` announces the connection and nothing else. Each cursor rides its own `subscribe`
|
|
158
|
+
// frame below, which is the only place resume is decided — sending it here too shipped every
|
|
159
|
+
// cursor twice per reconnect, once into a field the node discards.
|
|
160
|
+
this.#send(this.#hello());
|
|
145
161
|
for (const registration of this.#registrations.values()) this.#sendSubscribe(registration);
|
|
146
|
-
|
|
162
|
+
// Topic membership lives on the node's socket and `hello` carries none of it, so a channel
|
|
163
|
+
// this client still holds a handler for is silent from the first reconnect onwards — and its
|
|
164
|
+
// presence membership is swept — unless every one of them is re-announced here.
|
|
165
|
+
for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
|
|
166
|
+
this.#heartbeat.start(this.#clock.now().getTime());
|
|
167
|
+
this.#detach(this.drain());
|
|
147
168
|
});
|
|
148
169
|
socket.onMessage((data) => {
|
|
149
|
-
|
|
170
|
+
// A frame speaks only for its own socket, the same rule `onClose` follows. A replaced socket
|
|
171
|
+
// that is still draining bytes would otherwise fold its patches into the live registrations
|
|
172
|
+
// a second time, over newer state.
|
|
173
|
+
if (this.#socket !== socket) return;
|
|
174
|
+
this.#heartbeat.saw(this.#clock.now().getTime());
|
|
175
|
+
applyFrame(decode(data), this.#frameTarget);
|
|
150
176
|
});
|
|
151
177
|
socket.onClose(() => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
178
|
+
// A close speaks only for its own socket: `connect()` may already have installed a newer one,
|
|
179
|
+
// and a corpse marking the live connection offline and arming a backoff is a working socket
|
|
180
|
+
// killed by a dead one. Dropping ours first keeps fire-and-forget `#send` out of the corpse.
|
|
181
|
+
if (this.#socket !== socket) return;
|
|
182
|
+
this.#socket = null;
|
|
183
|
+
this.#offline();
|
|
184
|
+
// A `reconnect` frame armed the server's own delay before closing us; rescheduling here would
|
|
185
|
+
// replace the delay the node assigned with a local backoff and re-cluster the herd it spread.
|
|
186
|
+
if (this.#reconnectTimer === null) this.#scheduleReconnect(null);
|
|
155
187
|
});
|
|
156
188
|
}
|
|
157
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Everything a lost connection costs, whoever noticed it — a close, a replacement, an explicit
|
|
192
|
+
* teardown, a heartbeat that timed out. The queue half is the one that is easy to forget: a
|
|
193
|
+
* mutation handed to a socket that is now gone was never acknowledged, so it goes back in the
|
|
194
|
+
* queue rather than waiting for an ack nobody will send.
|
|
195
|
+
*/
|
|
196
|
+
#offline(): void {
|
|
197
|
+
this.#heartbeat.stop();
|
|
198
|
+
this.#setConnected(false);
|
|
199
|
+
// Told once, not two ways: a `useConnection().offline` that flips while a `useLive` handle
|
|
200
|
+
// still reads 'live' is one dead socket rendered as two states.
|
|
201
|
+
for (const registration of this.#registrations.values()) registration.setState('offline');
|
|
202
|
+
const queue = this.#options.queue;
|
|
203
|
+
if (queue) this.#detach(queue.requeueInflight());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Explicit teardown: cancels the armed reconnect and drops the socket. Without it a client whose
|
|
208
|
+
* owner is gone keeps waking up and dialling forever — the timer is the only thing still holding
|
|
209
|
+
* it alive. `connect()` starts over, so this is a stop, not a tombstone.
|
|
210
|
+
*/
|
|
211
|
+
close(code = 1000, reason = 'client closed'): void {
|
|
212
|
+
this.#closed = true;
|
|
213
|
+
this.#cancelReconnect();
|
|
214
|
+
this.#setReconnectAt(null);
|
|
215
|
+
this.#attempt = 0;
|
|
216
|
+
const socket = this.#socket;
|
|
217
|
+
this.#socket = null;
|
|
218
|
+
socket?.close(code, reason);
|
|
219
|
+
// The close this triggers is a dropped socket's, so it returns: going offline is our job now.
|
|
220
|
+
this.#offline();
|
|
221
|
+
}
|
|
222
|
+
|
|
158
223
|
/** Tier 2 and tier 3 alike. The returned accessor is the reactive result set. */
|
|
159
224
|
useLive<R extends Row = Row>(query: LiveQueryRef, input: JsonValue): LiveHandle<R> {
|
|
160
225
|
const sid = uuid();
|
|
161
226
|
const [rows, setRows] = this.#options.signal<readonly Row[]>([]);
|
|
162
|
-
|
|
227
|
+
// 'loading' is a promise that rows are on their way; with no socket, nothing is on its way.
|
|
228
|
+
const [state, setState] = this.#options.signal<LiveState>(
|
|
229
|
+
this.#connected() ? 'loading' : 'offline',
|
|
230
|
+
);
|
|
163
231
|
const [cursor, setCursor] = this.#options.signal<LiveCursor | null>(null);
|
|
164
232
|
const registration: Registration = {
|
|
165
233
|
sid,
|
|
@@ -168,51 +236,46 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
168
236
|
setRows,
|
|
169
237
|
setState,
|
|
170
238
|
setCursor,
|
|
171
|
-
|
|
239
|
+
// Private until the first snapshot names the entity: sharing rows with another query on a
|
|
240
|
+
// scope nobody confirmed would merge two entities that spell one id the same way.
|
|
241
|
+
scope: privateScope(query.name),
|
|
242
|
+
ids: [],
|
|
172
243
|
cursor: null,
|
|
173
244
|
};
|
|
174
245
|
this.#registrations.set(sid, registration);
|
|
246
|
+
const close = this.#windows.open(registration);
|
|
175
247
|
if (this.#connected()) this.#sendSubscribe(registration);
|
|
248
|
+
const unsubscribe = (): void => {
|
|
249
|
+
this.#registrations.delete(sid);
|
|
250
|
+
close();
|
|
251
|
+
this.#send({
|
|
252
|
+
type: 'subscribe',
|
|
253
|
+
v: PROTOCOL_VERSION,
|
|
254
|
+
op: 'drop',
|
|
255
|
+
sid,
|
|
256
|
+
target: { kind: 'query', qid: query.name, input, cursor: null },
|
|
257
|
+
});
|
|
258
|
+
};
|
|
176
259
|
return {
|
|
177
260
|
rows: rows as () => readonly R[],
|
|
178
261
|
state,
|
|
179
262
|
cursor,
|
|
180
|
-
unsubscribe
|
|
181
|
-
|
|
182
|
-
this.#send({
|
|
183
|
-
type: 'subscribe',
|
|
184
|
-
v: PROTOCOL_VERSION,
|
|
185
|
-
op: 'drop',
|
|
186
|
-
sid,
|
|
187
|
-
target: { kind: 'query', qid: query.name, input, cursor: null },
|
|
188
|
-
});
|
|
189
|
-
},
|
|
263
|
+
unsubscribe,
|
|
264
|
+
[Symbol.dispose]: unsubscribe,
|
|
190
265
|
};
|
|
191
266
|
}
|
|
192
267
|
|
|
193
|
-
subscribe(name: Topic, handler: (message: JsonObject) => void):
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
sid: name,
|
|
202
|
-
target: { kind: 'topic', topic: name },
|
|
203
|
-
});
|
|
204
|
-
return () => {
|
|
205
|
-
handlers.delete(handler);
|
|
206
|
-
if (handlers.size > 0) return;
|
|
207
|
-
this.#topics.delete(name);
|
|
208
|
-
this.#send({
|
|
209
|
-
type: 'subscribe',
|
|
210
|
-
v: PROTOCOL_VERSION,
|
|
211
|
-
op: 'drop',
|
|
212
|
-
sid: name,
|
|
213
|
-
target: { kind: 'topic', topic: name },
|
|
214
|
-
});
|
|
268
|
+
subscribe(name: Topic, handler: (message: JsonObject) => void): Unsubscribe {
|
|
269
|
+
this.#topics.add(name, handler);
|
|
270
|
+
this.#send(topicSubscribeFrame(name, 'add'));
|
|
271
|
+
// A function is an object: attaching `[Symbol.dispose]` keeps the existing callable contract
|
|
272
|
+
// (`const unsub = channel.subscribe(...); unsub()`) intact while adding `using sub = ...`.
|
|
273
|
+
const unsubscribe: Unsubscribe = (): void => {
|
|
274
|
+
if (!this.#topics.remove(name, handler)) return;
|
|
275
|
+
this.#send(topicSubscribeFrame(name, 'drop'));
|
|
215
276
|
};
|
|
277
|
+
unsubscribe[Symbol.dispose] = unsubscribe;
|
|
278
|
+
return unsubscribe;
|
|
216
279
|
}
|
|
217
280
|
|
|
218
281
|
/** Tier 1 publish. The server re-checks the topic policy; this is a request, not an assertion. */
|
|
@@ -227,59 +290,35 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
227
290
|
}
|
|
228
291
|
|
|
229
292
|
/**
|
|
230
|
-
* The mutator entry point.
|
|
231
|
-
* then drains. Offline, everything but the drain still happens — that is tier 3's one
|
|
232
|
-
* property over tier 2.
|
|
293
|
+
* The mutator entry point. Records the optimistic twin, the rebase entry and the durable queue
|
|
294
|
+
* entry, then drains. Offline, everything but the drain still happens — that is tier 3's one
|
|
295
|
+
* extra property over tier 2.
|
|
233
296
|
*/
|
|
234
297
|
async mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void> {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const queue = this.#options.queue;
|
|
238
|
-
const local = mutator.local;
|
|
239
|
-
const queued = await queue?.enqueue({
|
|
240
|
-
key: idempotencyKey,
|
|
241
|
-
name: mutator.name,
|
|
242
|
-
input,
|
|
243
|
-
at: this.#clock.now().getTime(),
|
|
244
|
-
});
|
|
245
|
-
if (store && local) {
|
|
246
|
-
store.apply(idempotencyKey, (tx) => local(tx, input));
|
|
247
|
-
this.#options.log?.record({
|
|
248
|
-
key: idempotencyKey,
|
|
249
|
-
seq: queued?.seq ?? 0,
|
|
250
|
-
entity: mutator.entity ?? mutator.name,
|
|
251
|
-
strategy: mutator.conflict ?? 'server-wins',
|
|
252
|
-
apply: (tx) => local(tx, input),
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
if (!queue) {
|
|
256
|
-
this.#send(
|
|
257
|
-
mutateFrame({
|
|
258
|
-
key: idempotencyKey,
|
|
259
|
-
seq: 0,
|
|
260
|
-
name: mutator.name,
|
|
261
|
-
input,
|
|
262
|
-
enqueuedAt: this.#clock.now().getTime(),
|
|
263
|
-
attempts: 0,
|
|
264
|
-
status: 'pending',
|
|
265
|
-
error: null,
|
|
266
|
-
}),
|
|
267
|
-
);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
await this.drain();
|
|
298
|
+
await recordMutation(this.#mutations, mutator, input, key);
|
|
299
|
+
if (this.#options.queue) await this.drain();
|
|
271
300
|
}
|
|
272
301
|
|
|
273
302
|
/** Sends every pending mutation in sequence order. Stops at the first one the socket refuses. */
|
|
274
303
|
async drain(): Promise<void> {
|
|
275
304
|
const queue = this.#options.queue;
|
|
276
305
|
if (!queue || !this.#connected()) return;
|
|
277
|
-
await queue.drain(
|
|
278
|
-
this.#send(mutateFrame(mutation));
|
|
279
|
-
});
|
|
306
|
+
await queue.drain(mutationSender(this.#mutations));
|
|
280
307
|
this.#notifyQueueChange();
|
|
281
308
|
}
|
|
282
309
|
|
|
310
|
+
/** The mutation path's view of this client. Built per call, exactly like `#frameTarget`. */
|
|
311
|
+
get #mutations(): MutationDeps<T> {
|
|
312
|
+
return {
|
|
313
|
+
store: this.#options.store,
|
|
314
|
+
queue: this.#options.queue,
|
|
315
|
+
log: this.#options.log,
|
|
316
|
+
now: () => this.#clock.now().getTime(),
|
|
317
|
+
socket: () => this.#socket,
|
|
318
|
+
send: (frame) => this.#send(frame),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
283
322
|
/**
|
|
284
323
|
* Fires whenever the offline queue changes for any reason: a direct `mutate`/`drain` call, the
|
|
285
324
|
* automatic drain `connect()` runs on every reconnect, or an async ack/fail frame arriving over
|
|
@@ -310,130 +349,125 @@ export class LiveClient<T extends TableMap = TableMap> {
|
|
|
310
349
|
});
|
|
311
350
|
}
|
|
312
351
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const handlers = this.#topics.get(frame.sid);
|
|
335
|
-
if (!handlers) return;
|
|
336
|
-
for (const patch of frame.patches) {
|
|
337
|
-
if (patch.row === null) continue;
|
|
338
|
-
for (const handler of handlers) handler(patch.row);
|
|
339
|
-
}
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
case 'ack': {
|
|
343
|
-
const queue = this.#options.queue;
|
|
344
|
-
// `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
|
|
345
|
-
// than notifying right after the call keeps this correct even if that ordering ever
|
|
346
|
-
// changes, and it still fires exactly once the persisted write actually lands.
|
|
347
|
-
const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
|
|
348
|
-
void settled?.then(() => this.#notifyQueueChange());
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
case 'rebase': {
|
|
352
|
-
const store = this.#options.store;
|
|
353
|
-
const log = this.#options.log;
|
|
354
|
-
if (!store || !log) return;
|
|
355
|
-
reconcile({
|
|
356
|
-
store,
|
|
357
|
-
log,
|
|
358
|
-
ack: {
|
|
359
|
-
key: frame.key,
|
|
360
|
-
entity: frame.entity,
|
|
361
|
-
id: frame.row?.id ?? frame.key,
|
|
362
|
-
row: frame.row,
|
|
363
|
-
},
|
|
364
|
-
});
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
case 'reconnect': {
|
|
368
|
-
this.#scheduleReconnect(frame.afterMs);
|
|
369
|
-
this.#socket?.close(1001, frame.reason);
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
case 'update-available': {
|
|
373
|
-
this.#setUpdate(frame.buildId);
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
case 'presence': {
|
|
377
|
-
const handlers = this.#topics.get(frame.topic);
|
|
378
|
-
if (!handlers) return;
|
|
379
|
-
const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
|
|
380
|
-
for (const handler of handlers) handler(message);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
case 'hello':
|
|
384
|
-
case 'subscribe':
|
|
385
|
-
case 'mutate':
|
|
386
|
-
// Client-authored frames: never received. Ignored rather than thrown, so a future
|
|
387
|
-
// bidirectional use of the same kind cannot break an old client.
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
352
|
+
/**
|
|
353
|
+
* The client's inbound surface, handed to the router. Built once: a frame reaches exactly these
|
|
354
|
+
* members and nothing else on the client.
|
|
355
|
+
*/
|
|
356
|
+
get #frameTarget(): ClientFrameTarget<T> {
|
|
357
|
+
return {
|
|
358
|
+
registration: (sid) => this.#registrations.get(sid),
|
|
359
|
+
windows: this.#windows,
|
|
360
|
+
topicHandlers: (topic) => this.#topics.handlers(topic),
|
|
361
|
+
queue: this.#options.queue,
|
|
362
|
+
store: this.#options.store,
|
|
363
|
+
log: this.#options.log,
|
|
364
|
+
// The client's clock, never `Date.now()`: a cursor's `at` is what decides a delta resume
|
|
365
|
+
// against a re-snapshot, so the frame path reads the same clock every other path does.
|
|
366
|
+
now: () => this.#clock.now().getTime(),
|
|
367
|
+
setUpdate: (buildId) => this.#setUpdate(buildId),
|
|
368
|
+
scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
|
|
369
|
+
closeSocket: (code, reason) => this.#socket?.close(code, reason),
|
|
370
|
+
notifyQueueChange: () => this.#notifyQueueChange(),
|
|
371
|
+
detach: (work) => this.#detach(work),
|
|
372
|
+
};
|
|
390
373
|
}
|
|
391
374
|
|
|
392
|
-
/**
|
|
375
|
+
/** The opening frame, and the heartbeat's. One shape, because it makes one claim: I am here. */
|
|
376
|
+
#hello(): Frame {
|
|
377
|
+
return {
|
|
378
|
+
type: 'hello',
|
|
379
|
+
v: PROTOCOL_VERSION,
|
|
380
|
+
buildId: this.#options.buildId,
|
|
381
|
+
sessionId: null,
|
|
382
|
+
actorId: this.#options.actorId ?? null,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* One liveness pass, and it buys exactly two things. `hello` provokes an answer on any socket,
|
|
388
|
+
* which is the only way a browser learns a half-open one is dead — nothing else ever will, since
|
|
389
|
+
* a half-open socket fires no `onClose`. Re-sending each topic is the node's own presence
|
|
390
|
+
* heartbeat: subscribing IS being in the room, so a client that stopped repeating it is swept
|
|
391
|
+
* out of every room it is still receiving from.
|
|
392
|
+
*
|
|
393
|
+
* It is NOT how a deploy is noticed. `socket.skewed` compares the build id the upgrade recorded
|
|
394
|
+
* against this node's, both fixed for the socket's whole life, so every `hello` on one socket
|
|
395
|
+
* gets the same answer forever; `update-available` reaches a client on the socket it opens
|
|
396
|
+
* against the *new* node, which is a reconnect and never a beat.
|
|
397
|
+
*/
|
|
398
|
+
#beat(): void {
|
|
399
|
+
this.#send(this.#hello());
|
|
400
|
+
for (const name of this.#topics.names()) this.#send(topicSubscribeFrame(name, 'add'));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Nothing has come back for two heartbeat windows. A half-open socket fires no `onClose` — that
|
|
405
|
+
* is what makes it half-open — so this client is the only thing that can end it. It is dropped
|
|
406
|
+
* here rather than awaited: a browser `close()` on a black-holed connection can sit in CLOSING
|
|
407
|
+
* until the TCP close handshake times out, and the reconnect must not wait that out.
|
|
408
|
+
*/
|
|
409
|
+
#silent(): void {
|
|
410
|
+
const socket = this.#socket;
|
|
411
|
+
this.#socket = null;
|
|
412
|
+
this.#offline();
|
|
413
|
+
socket?.close(HEARTBEAT_TIMEOUT_CODE, 'heartbeat timeout');
|
|
414
|
+
if (this.#reconnectTimer === null) this.#scheduleReconnect(null);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Honours a server-assigned delay when there is one; otherwise jittered exponential backoff.
|
|
419
|
+
* Publishing `reconnectAt` is the render half — arming the timer is the half that makes the
|
|
420
|
+
* client come back, and `connect()` is the only thing it calls.
|
|
421
|
+
*/
|
|
393
422
|
#scheduleReconnect(serverDelayMs: number | null): void {
|
|
423
|
+
if (this.#closed) return;
|
|
424
|
+
this.#cancelReconnect();
|
|
394
425
|
const rng = this.#options.rng ?? Math.random;
|
|
395
426
|
const delay =
|
|
396
427
|
serverDelayMs ?? backoffDelay(this.#attempt, this.#options.backoff ?? defaultBackoff, rng);
|
|
397
428
|
this.#attempt += 1;
|
|
398
429
|
this.#setReconnectAt(this.#clock.now().getTime() + delay);
|
|
430
|
+
const schedule = this.#options.scheduler ?? timeoutScheduler;
|
|
431
|
+
this.#reconnectTimer = schedule(() => {
|
|
432
|
+
// Cleared before dialling, not after: the attempt's own close must be free to arm the next
|
|
433
|
+
// one. `reconnectAt` stays put until the socket opens, so a countdown does not blink to null.
|
|
434
|
+
this.#reconnectTimer = null;
|
|
435
|
+
if (this.#closed) return;
|
|
436
|
+
try {
|
|
437
|
+
this.connect();
|
|
438
|
+
} catch (error) {
|
|
439
|
+
// A socket constructor may refuse (mixed content, a URL the page may not open), and one
|
|
440
|
+
// refusal ending the chain is the same outage as never arming — so the next attempt is put
|
|
441
|
+
// on first. Reported, never rethrown: nothing awaits a timer, so a throw out of one is an
|
|
442
|
+
// uncaught exception that can kill the process that was going to retry. Only this path
|
|
443
|
+
// changes — a `connect()` the app called itself still throws to it, and still arms nothing.
|
|
444
|
+
this.#scheduleReconnect(null);
|
|
445
|
+
this.#onError(error);
|
|
446
|
+
}
|
|
447
|
+
}, delay);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#cancelReconnect(): void {
|
|
451
|
+
const cancel = this.#reconnectTimer;
|
|
452
|
+
this.#reconnectTimer = null;
|
|
453
|
+
cancel?.();
|
|
399
454
|
}
|
|
400
455
|
|
|
401
456
|
#send(frame: Frame): void {
|
|
402
457
|
this.#socket?.send(encode(frame));
|
|
403
458
|
}
|
|
404
459
|
|
|
405
|
-
|
|
406
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Work nobody awaits: the drain `onOpen` runs, a queue write from a socket that just died. It
|
|
462
|
+
* bottoms out in `QueueStore.save()` — OPFS or IndexedDB, both allowed to reject — and an
|
|
463
|
+
* unhandled rejection in a tab is `window.onerror`, in Bun a dead process. `onError` is the seam
|
|
464
|
+
* the reconnect timer already reports through; it is never `logger`, which writes stderr.
|
|
465
|
+
*/
|
|
466
|
+
#detach(work: Promise<unknown>): void {
|
|
467
|
+
void work.catch(this.#onError);
|
|
407
468
|
}
|
|
408
|
-
}
|
|
409
469
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
let next = rows;
|
|
413
|
-
for (const patch of patches) {
|
|
414
|
-
if (patch.op === 'delete') {
|
|
415
|
-
next = next.filter((row) => row.id !== patch.id);
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
if (patch.row === null) continue;
|
|
419
|
-
const index = next.findIndex((row) => row.id === patch.id);
|
|
420
|
-
const current = index >= 0 ? next[index] : undefined;
|
|
421
|
-
const merged: Row = { ...(current ?? {}), ...patch.row, id: patch.id };
|
|
422
|
-
if (index >= 0) {
|
|
423
|
-
const copy = [...next];
|
|
424
|
-
copy[index] = merged;
|
|
425
|
-
next = copy;
|
|
426
|
-
} else if (patch.index !== undefined) {
|
|
427
|
-
const copy = [...next];
|
|
428
|
-
copy.splice(patch.index, 0, merged);
|
|
429
|
-
next = copy;
|
|
430
|
-
} else {
|
|
431
|
-
next = [...next, merged];
|
|
432
|
-
}
|
|
470
|
+
#notifyQueueChange(): void {
|
|
471
|
+
for (const listener of this.#queueListeners) listener();
|
|
433
472
|
}
|
|
434
|
-
return next;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function memberJson(member: PresenceMember): JsonValue {
|
|
438
|
-
return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
|
|
439
473
|
}
|