@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/channel.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// append-only stream, so tier 1 needs no frame of its own. That is why climbing the ladder is a
|
|
5
5
|
// config change: the client's frame handler is the same code at every rung.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { type Actor, logger, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import { formatLsn } from './changefeed';
|
|
9
|
-
import { SubscriptionLimitError, TopicForbiddenError } from './errors';
|
|
9
|
+
import { isPolicyDenial, SubscriptionLimitError, TopicForbiddenError } from './errors';
|
|
10
10
|
import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
|
|
11
11
|
import type { JsonObject } from './json';
|
|
12
12
|
import type { SocketRegistry, SyncSocket } from './socket';
|
|
@@ -46,6 +46,30 @@ export interface ChannelHubOptions {
|
|
|
46
46
|
readonly transport: Transport;
|
|
47
47
|
readonly sockets: SocketRegistry;
|
|
48
48
|
readonly maxTopicsPerSocket?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Distinct topics this node will bridge at once. Each one is a live transport subscription, and
|
|
51
|
+
* `topic()` admits any `[A-Za-z0-9_-]+` segment — so even a guard as tight as `org.<myorg>.>`
|
|
52
|
+
* admits unbounded distinct names inside one tenant, and a per-socket cap bounds nothing.
|
|
53
|
+
*/
|
|
54
|
+
readonly maxTopicsPerNode?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
|
|
58
|
+
export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
|
|
62
|
+
* into the table before it is awaited: looked up before the await and written after it, two sockets
|
|
63
|
+
* reaching one topic at once opened two transport subscriptions — the second replacing the first in
|
|
64
|
+
* the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
|
|
65
|
+
* anything else, delivering every message on that topic a second time for the life of the process.
|
|
66
|
+
*
|
|
67
|
+
* `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
|
|
68
|
+
* runs, so the reservation has to exist before there is anything to reserve it with.
|
|
69
|
+
*/
|
|
70
|
+
interface Bridge {
|
|
71
|
+
sub: Promise<TransportSubscription> | null;
|
|
72
|
+
refs: number;
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
/**
|
|
@@ -56,14 +80,43 @@ export class ChannelHub {
|
|
|
56
80
|
readonly #transport: Transport;
|
|
57
81
|
readonly #sockets: SocketRegistry;
|
|
58
82
|
readonly #guards: Array<{ pattern: string; guard: TopicGuard }> = [];
|
|
59
|
-
readonly #bridges = new Map<string,
|
|
83
|
+
readonly #bridges = new Map<string, Bridge>();
|
|
84
|
+
/**
|
|
85
|
+
* Topics this socket has asked for and not yet joined. Weakly keyed, so it needs no teardown
|
|
86
|
+
* path of its own: a socket that dies mid-subscribe takes its claims with it.
|
|
87
|
+
*/
|
|
88
|
+
readonly #claimed = new WeakMap<SyncSocket, number>();
|
|
60
89
|
readonly #maxTopicsPerSocket: number;
|
|
90
|
+
readonly #maxTopicsPerNode: number;
|
|
91
|
+
#guardFailures = 0;
|
|
61
92
|
#sequence = 0n;
|
|
93
|
+
/** Set by `close()`. Read by `#open`, which is the only thing that can reach a late subscription. */
|
|
94
|
+
#closed = false;
|
|
62
95
|
|
|
63
96
|
constructor(options: ChannelHubOptions) {
|
|
64
97
|
this.#transport = options.transport;
|
|
65
98
|
this.#sockets = options.sockets;
|
|
66
99
|
this.#maxTopicsPerSocket = options.maxTopicsPerSocket ?? 64;
|
|
100
|
+
this.#maxTopicsPerNode = options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Sockets this node will deliver `name` to. The metric the fanout reads. */
|
|
104
|
+
subscriberCount(name: Topic): number {
|
|
105
|
+
return this.#sockets.subscriberCount(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Distinct topics bridged from this node — one live transport subscription each. */
|
|
109
|
+
get topicCount(): number {
|
|
110
|
+
return this.#bridges.size;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `channel.guard_failed` for this node: guards that raised instead of deciding, during a re-auth.
|
|
115
|
+
* Never a denial — the same split `LiveQueryRegistry.reauthorize` makes one layer up, and an
|
|
116
|
+
* alert fires on one of them.
|
|
117
|
+
*/
|
|
118
|
+
get guardFailures(): number {
|
|
119
|
+
return this.#guardFailures;
|
|
67
120
|
}
|
|
68
121
|
|
|
69
122
|
/** `pattern` uses NATS wildcards: `org.*.cursors`, `org.>`. First registered match wins. */
|
|
@@ -72,36 +125,87 @@ export class ChannelHub {
|
|
|
72
125
|
return this;
|
|
73
126
|
}
|
|
74
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Both caps and the node's bridge slot are taken SYNCHRONOUSLY, before the guard is awaited: read
|
|
130
|
+
* at the top and acted on after two awaits, one WebSocket write carrying N subscribe frames
|
|
131
|
+
* passed each of them N times, and `maxTopicsPerSocket`/`maxTopicsPerNode` bounded nothing.
|
|
132
|
+
*/
|
|
75
133
|
async subscribe(socket: SyncSocket, name: Topic): Promise<void> {
|
|
76
134
|
if (socket.topics.has(name)) return;
|
|
77
|
-
|
|
135
|
+
const claimed = this.#claimed.get(socket) ?? 0;
|
|
136
|
+
if (socket.topics.size + claimed >= this.#maxTopicsPerSocket) {
|
|
78
137
|
throw new SubscriptionLimitError({
|
|
79
138
|
scope: 'socket',
|
|
80
139
|
id: socket.id,
|
|
81
140
|
limit: this.#maxTopicsPerSocket,
|
|
141
|
+
// Named, never defaulted: the default for this scope is `maxPerSocket`, which is
|
|
142
|
+
// `LiveQueryRegistry`'s cap on live subscriptions — a different ceiling in a different
|
|
143
|
+
// constructor, so an operator following this fix line would have moved the wrong number.
|
|
144
|
+
knob: 'maxTopicsPerSocket',
|
|
82
145
|
});
|
|
83
146
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
147
|
+
// Refused before the guard runs and before a transport subscription is opened: a node that is
|
|
148
|
+
// out of topics has nothing to decide, and the answer must not depend on who asked.
|
|
149
|
+
const bridge = this.#reserve(name);
|
|
150
|
+
this.#claimed.set(socket, claimed + 1);
|
|
151
|
+
try {
|
|
152
|
+
await this.#authorize(socket.actor, name);
|
|
153
|
+
await this.#open(name, bridge);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// The slot this subscribe took, given back on the one path that will never fill it.
|
|
156
|
+
this.#release(name);
|
|
157
|
+
throw error;
|
|
158
|
+
} finally {
|
|
159
|
+
const held = this.#claimed.get(socket) ?? 1;
|
|
160
|
+
if (held <= 1) this.#claimed.delete(socket);
|
|
161
|
+
else this.#claimed.set(socket, held - 1);
|
|
162
|
+
}
|
|
163
|
+
// A concurrent subscribe for this same socket and topic got there first: it holds the one
|
|
164
|
+
// membership this socket's close will give back, so the reference taken above has to go now or
|
|
165
|
+
// it is a bridge nothing will ever release.
|
|
166
|
+
if (socket.topics.has(name)) {
|
|
167
|
+
this.#release(name);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Through the registry, never `socket.subscribeTopic` directly: membership and the index the
|
|
171
|
+
// fanout reads are one fact, and two call sites for one fact is the drift that makes an index
|
|
172
|
+
// wrong. The registry owns it because it is the only thing that sees a socket die.
|
|
173
|
+
this.#sockets.joinTopic(socket, name);
|
|
87
174
|
}
|
|
88
175
|
|
|
89
176
|
unsubscribe(socket: SyncSocket, name: Topic): void {
|
|
90
177
|
if (!socket.topics.has(name)) return;
|
|
91
|
-
|
|
178
|
+
this.#sockets.leaveTopic(socket, name);
|
|
92
179
|
this.#release(name);
|
|
93
180
|
}
|
|
94
181
|
|
|
95
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Called when a socket's session changes (login, logout, role change, token refresh).
|
|
184
|
+
*
|
|
185
|
+
* A denial drops the topic; anything else keeps it. A guard is app code and may reach a database,
|
|
186
|
+
* so `catch { unsubscribe }` reported a store that timed out as a revoked grant — during one
|
|
187
|
+
* outage, every topic on every re-authenticated socket on the node, silently, with the client
|
|
188
|
+
* never told to resubscribe. The same split `LiveQueryRegistry.reauthorize` already makes, and
|
|
189
|
+
* for the same reason: a failure is not a decision.
|
|
190
|
+
*/
|
|
96
191
|
async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
|
|
97
192
|
socket.actor = actor;
|
|
98
193
|
const dropped: Topic[] = [];
|
|
99
194
|
for (const name of [...socket.topics] as Topic[]) {
|
|
100
195
|
try {
|
|
101
196
|
await this.#authorize(actor, name);
|
|
102
|
-
} catch {
|
|
103
|
-
|
|
104
|
-
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
|
|
199
|
+
this.unsubscribe(socket, name);
|
|
200
|
+
dropped.push(name);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
this.#guardFailures += 1;
|
|
204
|
+
logger.warn('channel.guard_failed', {
|
|
205
|
+
topic: name,
|
|
206
|
+
socketId: socket.id,
|
|
207
|
+
error: renderThrowable(error),
|
|
208
|
+
});
|
|
105
209
|
}
|
|
106
210
|
}
|
|
107
211
|
return dropped;
|
|
@@ -120,7 +224,14 @@ export class ChannelHub {
|
|
|
120
224
|
}
|
|
121
225
|
|
|
122
226
|
async close(): Promise<void> {
|
|
123
|
-
|
|
227
|
+
// Set BEFORE the table is walked, because the table is not the whole story: a reservation an
|
|
228
|
+
// in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
|
|
229
|
+
// nothing to it and `clear()` drops the entry. That open then lands on a `Bridge` nothing can
|
|
230
|
+
// name — `#release` looks the topic up, misses and returns — and its handler keeps calling
|
|
231
|
+
// `deliver` for the life of the process. The same orphan the `Bridge` comment describes, one
|
|
232
|
+
// state earlier, so the open that creates the subscription has to be the thing that closes it.
|
|
233
|
+
this.#closed = true;
|
|
234
|
+
for (const bridge of this.#bridges.values()) unsubscribeWhenOpen(bridge);
|
|
124
235
|
this.#bridges.clear();
|
|
125
236
|
}
|
|
126
237
|
|
|
@@ -147,17 +258,46 @@ export class ChannelHub {
|
|
|
147
258
|
}
|
|
148
259
|
}
|
|
149
260
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
261
|
+
/**
|
|
262
|
+
* The node's slot for this topic, taken synchronously. One bridge per topic per node, refcounted
|
|
263
|
+
* across sockets — and the refcount includes the subscribes still deciding, so the count the node
|
|
264
|
+
* cap reads is the count that will exist.
|
|
265
|
+
*/
|
|
266
|
+
#reserve(name: Topic): Bridge {
|
|
152
267
|
const existing = this.#bridges.get(name);
|
|
153
268
|
if (existing) {
|
|
154
269
|
existing.refs += 1;
|
|
155
|
-
return;
|
|
270
|
+
return existing;
|
|
271
|
+
}
|
|
272
|
+
if (this.#bridges.size >= this.#maxTopicsPerNode) {
|
|
273
|
+
throw new SubscriptionLimitError({
|
|
274
|
+
scope: 'node',
|
|
275
|
+
id: 'topics',
|
|
276
|
+
limit: this.#maxTopicsPerNode,
|
|
277
|
+
knob: 'maxTopicsPerNode',
|
|
278
|
+
});
|
|
156
279
|
}
|
|
157
|
-
const
|
|
280
|
+
const created: Bridge = { sub: null, refs: 1 };
|
|
281
|
+
this.#bridges.set(name, created);
|
|
282
|
+
return created;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Opens the reserved bridge once, and shares the in-flight open with everyone else waiting. */
|
|
286
|
+
async #open(name: Topic, bridge: Bridge): Promise<void> {
|
|
287
|
+
// Published into the bridge before it is awaited: that is what makes a second subscriber join
|
|
288
|
+
// this open instead of starting a second one the table can never reach again.
|
|
289
|
+
bridge.sub ??= this.#transport.subscribe(`${CHANNEL_SUBJECT_PREFIX}.${name}`, (payload) => {
|
|
158
290
|
this.#sockets.deliver(name, decode(payload));
|
|
159
291
|
});
|
|
160
|
-
|
|
292
|
+
await bridge.sub;
|
|
293
|
+
// The hub shut down while the transport was answering. `close()` either never saw this bridge
|
|
294
|
+
// or saw it with nothing to close, so this is the last reference to the subscription: it closes
|
|
295
|
+
// here or never. The entry goes with it, so a second post-close subscribe opens and closes its
|
|
296
|
+
// own rather than double-unsubscribing this one's handle.
|
|
297
|
+
if (this.#closed) {
|
|
298
|
+
unsubscribeWhenOpen(bridge);
|
|
299
|
+
if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
|
|
300
|
+
}
|
|
161
301
|
}
|
|
162
302
|
|
|
163
303
|
#release(name: Topic): void {
|
|
@@ -165,11 +305,26 @@ export class ChannelHub {
|
|
|
165
305
|
if (!bridge) return;
|
|
166
306
|
bridge.refs -= 1;
|
|
167
307
|
if (bridge.refs > 0) return;
|
|
168
|
-
bridge
|
|
308
|
+
unsubscribeWhenOpen(bridge);
|
|
169
309
|
this.#bridges.delete(name);
|
|
170
310
|
}
|
|
171
311
|
}
|
|
172
312
|
|
|
313
|
+
/**
|
|
314
|
+
* A bridge released while its subscription is still opening still has to be closed — the transport
|
|
315
|
+
* hands the handle back after the caller has gone, and dropping the promise would leave a live
|
|
316
|
+
* subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
|
|
317
|
+
* rejection was already answered to the subscriber that caused it.
|
|
318
|
+
*/
|
|
319
|
+
function unsubscribeWhenOpen(bridge: Bridge): void {
|
|
320
|
+
void bridge.sub?.then(
|
|
321
|
+
(sub) => {
|
|
322
|
+
sub.unsubscribe();
|
|
323
|
+
},
|
|
324
|
+
() => undefined,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
173
328
|
export function channelFrame(name: Topic, lsn: string, message: JsonObject): Frame {
|
|
174
329
|
return {
|
|
175
330
|
type: 'patch',
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// What a client IS, as types: the injected seams, the options, and the handles a subscription
|
|
2
|
+
// gives back. Declared apart from the client that implements them for the same reason
|
|
3
|
+
// `live-contract.ts` is — the hooks, the typed projection, the type pins and the mutation path all
|
|
4
|
+
// need these shapes, and none of them needs the connection lifecycle that runs underneath.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import type { LiveCursor } from './cursor';
|
|
8
|
+
import type { JsonValue, Row } from './json';
|
|
9
|
+
import type { LiveState } from './live-rows';
|
|
10
|
+
import type { LocalStore, LocalTx, TableMap } from './local-store';
|
|
11
|
+
import type { OfflineQueue } from './offline-queue';
|
|
12
|
+
import type { ConflictStrategy, RebaseLog } from './rebase';
|
|
13
|
+
import type { BackoffPolicy, Rng, Scheduler } from './thundering-herd';
|
|
14
|
+
|
|
15
|
+
/** Injected reactive primitive. `createSignal` from Solid satisfies this exactly. */
|
|
16
|
+
export type SignalFactory = <T>(initial: T) => [get: () => T, set: (next: T) => void];
|
|
17
|
+
|
|
18
|
+
/** Injected socket, so tests drive the protocol without a network. */
|
|
19
|
+
export interface ClientSocket {
|
|
20
|
+
send(data: string): void;
|
|
21
|
+
close(code?: number, reason?: string): void;
|
|
22
|
+
onOpen(handler: () => void): void;
|
|
23
|
+
onMessage(handler: (data: string) => void): void;
|
|
24
|
+
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
|
+
readonly bufferedAmount?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LiveHandle<R extends Row = Row> extends Disposable {
|
|
34
|
+
/** The reactive accessor. In an app this is the Solid signal `useLive` returns. */
|
|
35
|
+
readonly rows: () => readonly R[];
|
|
36
|
+
readonly state: () => LiveState;
|
|
37
|
+
readonly cursor: () => LiveCursor | null;
|
|
38
|
+
unsubscribe(): void;
|
|
39
|
+
/** The same call as `unsubscribe`, so `using sub = client.useLive(...)` just works. */
|
|
40
|
+
[Symbol.dispose](): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What `subscribe()` returns for a tier-1 topic: callable to unsubscribe, and `using`-able too. */
|
|
44
|
+
export type Unsubscribe = (() => void) & Disposable;
|
|
45
|
+
|
|
46
|
+
export interface LiveQueryRef {
|
|
47
|
+
readonly name: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MutatorRef<T extends TableMap = TableMap> {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
/** Optimistic twin. Pure — no I/O, no Date.now(), no Math.random(). */
|
|
53
|
+
local?: (tx: LocalTx<T>, input: JsonValue) => void;
|
|
54
|
+
readonly entity?: string;
|
|
55
|
+
readonly conflict?: ConflictStrategy;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LiveClientOptions<T extends TableMap = TableMap> {
|
|
59
|
+
readonly signal: SignalFactory;
|
|
60
|
+
/** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
|
|
61
|
+
readonly connect: () => ClientSocket;
|
|
62
|
+
readonly buildId: string;
|
|
63
|
+
readonly actorId?: string | null;
|
|
64
|
+
/** Tier 3 only. Without these, mutations are server-only and nothing is queued offline. */
|
|
65
|
+
readonly store?: LocalStore<T>;
|
|
66
|
+
readonly queue?: OfflineQueue;
|
|
67
|
+
readonly log?: RebaseLog<T>;
|
|
68
|
+
readonly backoff?: BackoffPolicy;
|
|
69
|
+
readonly rng?: Rng;
|
|
70
|
+
readonly clock?: Clock;
|
|
71
|
+
/** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
|
|
72
|
+
readonly scheduler?: Scheduler;
|
|
73
|
+
/**
|
|
74
|
+
* How often a live socket re-announces itself, in ms. `0` disables it. Defaults to
|
|
75
|
+
* `DEFAULT_HEARTBEAT_MS` — the same 15s as the server's `realtime.heartbeatMs`, which browser
|
|
76
|
+
* code cannot read, so it is an option here rather than a value shipped down the wire.
|
|
77
|
+
*/
|
|
78
|
+
readonly heartbeatMs?: number;
|
|
79
|
+
/** Where a dial failure inside the reconnect timer is reported. Defaults to `reportToConsole`. */
|
|
80
|
+
readonly onError?: (error: unknown) => void;
|
|
81
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// What a RECEIVED frame does to client state — the mirror of `sync-node.ts`'s inbound handler,
|
|
2
|
+
// and the only inbound surface `client.ts` exposes. `ClientFrameTarget` is the point: it names
|
|
3
|
+
// every piece of the client a frame may touch, so the blast radius of a new frame kind is a
|
|
4
|
+
// reviewable list rather than "whatever the router could reach through `this`".
|
|
5
|
+
|
|
6
|
+
import { advance } from './cursor';
|
|
7
|
+
import type { JsonObject, JsonValue } from './json';
|
|
8
|
+
import type { Registration, RowWindows } from './live-rows';
|
|
9
|
+
import type { LocalStore, TableMap } from './local-store';
|
|
10
|
+
import type { OfflineQueue } from './offline-queue';
|
|
11
|
+
import { type RebaseLog, reconcile, rollbackMutation } from './rebase';
|
|
12
|
+
import type { Frame, PresenceMember } from './sync-protocol';
|
|
13
|
+
|
|
14
|
+
/** Declared with the window it projects; re-exported here because the router is what writes it. */
|
|
15
|
+
export type { LiveState, Registration } from './live-rows';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Everything an inbound frame is allowed to reach. Narrow on purpose — a router that took the
|
|
19
|
+
* client itself could touch the reconnect timer, the socket and the outbound path, none of which
|
|
20
|
+
* a received frame has any business writing.
|
|
21
|
+
*/
|
|
22
|
+
export interface ClientFrameTarget<T extends TableMap = TableMap> {
|
|
23
|
+
registration(sid: string): Registration | undefined;
|
|
24
|
+
/** The projection every live window renders through. Rows live in its map, never on a frame. */
|
|
25
|
+
readonly windows: RowWindows;
|
|
26
|
+
topicHandlers(topic: string): ReadonlySet<(message: JsonObject) => void> | undefined;
|
|
27
|
+
readonly queue: OfflineQueue | undefined;
|
|
28
|
+
readonly store: LocalStore<T> | undefined;
|
|
29
|
+
readonly log: RebaseLog<T> | undefined;
|
|
30
|
+
/** The client's clock. A cursor carries `at`, and nothing here may read `Date.now()`. */
|
|
31
|
+
now(): number;
|
|
32
|
+
/** A newer build is live; the app decides when to reload. */
|
|
33
|
+
setUpdate(buildId: string | null): void;
|
|
34
|
+
/** The node assigned this socket its own delay before closing it. */
|
|
35
|
+
scheduleReconnect(afterMs: number | null): void;
|
|
36
|
+
closeSocket(code: number, reason: string): void;
|
|
37
|
+
notifyQueueChange(): void;
|
|
38
|
+
/** Where a promise nobody awaits reports its failure. The client's `onError`, never a swallow. */
|
|
39
|
+
detach(work: Promise<unknown>): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The server refused a mutation: undo its optimistic half. Tier 2 has neither a store nor a log,
|
|
44
|
+
* so there is nothing optimistic to undo and the queue entry is the whole record.
|
|
45
|
+
*/
|
|
46
|
+
function rollbackFailed<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
|
|
47
|
+
const store = target.store;
|
|
48
|
+
const log = target.log;
|
|
49
|
+
if (!store || !log) return;
|
|
50
|
+
// One batch for the whole undo: the rollback and every mutator replayed behind it are one
|
|
51
|
+
// frame's worth of change, so a live window holding those rows renders once.
|
|
52
|
+
store.identity.batch(() => {
|
|
53
|
+
rollbackMutation({ store, log, key });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The server took it, so the write is no longer optimistic: the journal goes (there is nothing to
|
|
59
|
+
* roll back TO any more — this write is what the server has) and the rebase entry goes with it, or
|
|
60
|
+
* every later reconcile replays a mutation the server already applied, over rows that have moved
|
|
61
|
+
* on. The row itself stays exactly as the twin left it — an accepted write does not flicker.
|
|
62
|
+
*
|
|
63
|
+
* Both calls are no-ops for a key nothing holds, which is what makes this safe as the tail of the
|
|
64
|
+
* `rebase` + `ack` pair: the rebase in front of it has already reconciled and dropped the same key.
|
|
65
|
+
*/
|
|
66
|
+
function commitAccepted<T extends TableMap>(key: string, target: ClientFrameTarget<T>): void {
|
|
67
|
+
target.store?.commit(key);
|
|
68
|
+
target.log?.drop(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Presence members cross the topic channel as plain JSON, like every other channel message. */
|
|
72
|
+
function memberJson(member: PresenceMember): JsonValue {
|
|
73
|
+
return { id: member.id, actorId: member.actorId, meta: member.meta, updatedAt: member.updatedAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function applyFrame<T extends TableMap>(frame: Frame, target: ClientFrameTarget<T>): void {
|
|
77
|
+
switch (frame.type) {
|
|
78
|
+
case 'snapshot': {
|
|
79
|
+
const registration = target.registration(frame.sid);
|
|
80
|
+
if (!registration) return;
|
|
81
|
+
// The entity is the server's, and it is what upgrades this window from its own private scope
|
|
82
|
+
// to the one every other query over the same entity shares.
|
|
83
|
+
target.windows.snapshot(registration, frame.entity ?? null, frame.rows);
|
|
84
|
+
registration.cursor = frame.cursor;
|
|
85
|
+
registration.setCursor(frame.cursor);
|
|
86
|
+
registration.setState('live');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
case 'patch': {
|
|
90
|
+
const registration = target.registration(frame.sid);
|
|
91
|
+
if (registration) {
|
|
92
|
+
target.windows.patch(registration, frame.patches);
|
|
93
|
+
// The cursor moves with the patches, not only with a snapshot. Left behind, `cursor.at`
|
|
94
|
+
// froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for
|
|
95
|
+
// every client connected longer than `maxLagMs` — the delta resume the retained change
|
|
96
|
+
// window exists for, dead exactly during the deploy storm it was built for. An empty lsn
|
|
97
|
+
// is a tier-1 channel frame's, so it never rewinds one.
|
|
98
|
+
if (registration.cursor && frame.lsn !== '') {
|
|
99
|
+
const next = advance(registration.cursor, frame.patches, frame.lsn, target.now());
|
|
100
|
+
registration.cursor = next;
|
|
101
|
+
registration.setCursor(next);
|
|
102
|
+
}
|
|
103
|
+
registration.setState('live');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// No registration: it is a tier-1 channel message on `sid = topic`.
|
|
107
|
+
const handlers = target.topicHandlers(frame.sid);
|
|
108
|
+
if (!handlers) return;
|
|
109
|
+
for (const patch of frame.patches) {
|
|
110
|
+
if (patch.row === null) continue;
|
|
111
|
+
for (const handler of handlers) handler(patch.row);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
case 'ack': {
|
|
116
|
+
const queue = target.queue;
|
|
117
|
+
// A refused mutation is not a mutation: its optimistic twin has to come off the screen, and
|
|
118
|
+
// its rebase entry has to leave the log, or a denied write stays rendered forever and every
|
|
119
|
+
// later reconcile replays it. `ref` is the mutation key — the same key the `mutate` frame
|
|
120
|
+
// carried — which is what makes both halves reachable from one frame.
|
|
121
|
+
if (frame.error) rollbackFailed(frame.ref, target);
|
|
122
|
+
else commitAccepted(frame.ref, target);
|
|
123
|
+
// `ack`/`fail` mutate the queue synchronously and persist asynchronously; chaining rather
|
|
124
|
+
// than notifying right after the call keeps this correct even if that ordering ever
|
|
125
|
+
// changes, and it still fires exactly once the persisted write actually lands.
|
|
126
|
+
const settled = frame.error ? queue?.fail(frame.ref, frame.error) : queue?.ack(frame.ref);
|
|
127
|
+
if (settled) target.detach(settled.then(() => target.notifyQueueChange()));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
case 'rebase': {
|
|
131
|
+
const store = target.store;
|
|
132
|
+
const log = target.log;
|
|
133
|
+
if (!store || !log) return;
|
|
134
|
+
// One batch for the whole reconcile — a rollback, server truth and every replayed mutator
|
|
135
|
+
// are one frame's worth of change, so a live window holding those rows renders once.
|
|
136
|
+
store.identity.batch(() => {
|
|
137
|
+
reconcile({
|
|
138
|
+
store,
|
|
139
|
+
log,
|
|
140
|
+
ack: {
|
|
141
|
+
key: frame.key,
|
|
142
|
+
entity: frame.entity,
|
|
143
|
+
id: frame.row?.id ?? frame.key,
|
|
144
|
+
row: frame.row,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case 'reconnect': {
|
|
151
|
+
// Order is load-bearing: arming first is what makes the close this triggers keep the delay
|
|
152
|
+
// the node assigned to *this* socket instead of falling back to a local backoff.
|
|
153
|
+
target.scheduleReconnect(frame.afterMs);
|
|
154
|
+
target.closeSocket(1001, frame.reason);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
case 'update-available': {
|
|
158
|
+
target.setUpdate(frame.buildId);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case 'presence': {
|
|
162
|
+
const handlers = target.topicHandlers(frame.topic);
|
|
163
|
+
if (!handlers) return;
|
|
164
|
+
const message: JsonObject = { op: frame.op, members: frame.members.map(memberJson) };
|
|
165
|
+
for (const handler of handlers) handler(message);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
case 'hello':
|
|
169
|
+
case 'subscribe':
|
|
170
|
+
case 'mutate':
|
|
171
|
+
// Client-authored frames: never received. Ignored rather than thrown, so a future
|
|
172
|
+
// bidirectional use of the same kind cannot break an old client.
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The client's liveness pass: re-announce this socket before the node forgets it, and notice a
|
|
2
|
+
// socket that has stopped answering. A policy (when to beat, when to give up), not a wire detail —
|
|
3
|
+
// which is why it is here and not inside `client.ts`'s connection lifecycle.
|
|
4
|
+
|
|
5
|
+
import type { Scheduler } from './thundering-herd';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The same 15s as `realtime.heartbeatMs` in `@ultimat3/core`'s config, restated rather than read:
|
|
9
|
+
* that value is server configuration and this is browser code, so the client cannot reach it. The
|
|
10
|
+
* two are kept equal on purpose — a node's presence TTL is sized against this interval.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
13
|
+
|
|
14
|
+
export interface HeartbeatOptions {
|
|
15
|
+
/** `0` (or less) disables the pass entirely — the shape a test that owns the clock wants. */
|
|
16
|
+
readonly intervalMs: number;
|
|
17
|
+
readonly schedule: Scheduler;
|
|
18
|
+
readonly now: () => number;
|
|
19
|
+
/** Re-announces this socket. Called only while the socket is still answering. */
|
|
20
|
+
readonly beat: () => void;
|
|
21
|
+
/** Two windows of silence: the socket is half-open and only this client can end it. */
|
|
22
|
+
readonly onSilence: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One armed tick at a time, re-armed by itself. It is deliberately NOT an interval: the reconnect
|
|
27
|
+
* timer is the same injected `Scheduler` seam, and a client is either beating on a live socket or
|
|
28
|
+
* backing off towards a new one — never both, so one armed timer is the whole mechanism.
|
|
29
|
+
*/
|
|
30
|
+
export class Heartbeat {
|
|
31
|
+
readonly #options: HeartbeatOptions;
|
|
32
|
+
#cancel: (() => void) | null = null;
|
|
33
|
+
#lastSeen = 0;
|
|
34
|
+
|
|
35
|
+
constructor(options: HeartbeatOptions) {
|
|
36
|
+
this.#options = options;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The socket is up. `now` seeds the silence window, so the first tick judges this connection. */
|
|
40
|
+
start(now: number): void {
|
|
41
|
+
this.stop();
|
|
42
|
+
if (this.#options.intervalMs <= 0) return;
|
|
43
|
+
this.#lastSeen = now;
|
|
44
|
+
this.#arm();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
|
|
48
|
+
saw(now: number): void {
|
|
49
|
+
this.#lastSeen = now;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stop(): void {
|
|
53
|
+
const cancel = this.#cancel;
|
|
54
|
+
this.#cancel = null;
|
|
55
|
+
cancel?.();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#arm(): void {
|
|
59
|
+
this.#cancel = this.#options.schedule(() => {
|
|
60
|
+
this.#cancel = null;
|
|
61
|
+
this.#tick();
|
|
62
|
+
}, this.#options.intervalMs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#tick(): void {
|
|
66
|
+
const now = this.#options.now();
|
|
67
|
+
// Two windows, not one: a beat and the answer to it share the window they were sent in, so a
|
|
68
|
+
// single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
|
|
69
|
+
// a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
|
|
70
|
+
if (now - this.#lastSeen > this.#options.intervalMs * 2) {
|
|
71
|
+
this.#options.onSilence();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.#options.beat();
|
|
75
|
+
this.#arm();
|
|
76
|
+
}
|
|
77
|
+
}
|