@ultimat3/realtime 23.0.0 → 25.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/channel.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
type Actor,
|
|
8
|
+
assertCoded,
|
|
8
9
|
type Ctx,
|
|
9
|
-
|
|
10
|
+
ctxOf,
|
|
10
11
|
finiteOption,
|
|
11
|
-
invariant,
|
|
12
12
|
logger,
|
|
13
13
|
renderThrowable,
|
|
14
14
|
} from '@ultimat3/core';
|
|
@@ -16,7 +16,8 @@ import type { ChangeEvent } from './changefeed';
|
|
|
16
16
|
import { authorizeChannel } from './channel-authz';
|
|
17
17
|
import { type Bridge, unsubscribeWhenOpen } from './channel-bridge';
|
|
18
18
|
import type { Channel, Topic } from './channel-decl';
|
|
19
|
-
import {
|
|
19
|
+
import { DenialLatch } from './channel-latch';
|
|
20
|
+
import { ChannelLogs, type ChannelTopic } from './channel-logs';
|
|
20
21
|
import { getChannel, registeredChannels } from './channel-registry';
|
|
21
22
|
import type { ChannelEventsFrame, ChannelSubscribeTarget } from './channel-wire';
|
|
22
23
|
import {
|
|
@@ -52,6 +53,12 @@ export interface ChannelHubOptions {
|
|
|
52
53
|
readonly ringSize?: number;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** The longest value one channel param may carry — a uuid is 36. */
|
|
57
|
+
export const MAX_CHANNEL_PARAM_LENGTH = 128;
|
|
58
|
+
|
|
59
|
+
/** `#settle`'s "nobody has decided this seat yet": distinct from `null`, which is an actor. */
|
|
60
|
+
const UNDECIDED = Symbol('undecided');
|
|
61
|
+
|
|
55
62
|
/** Distinct topics one node bridges before `X_SUBSCRIPTION_LIMIT`. */
|
|
56
63
|
export const DEFAULT_MAX_TOPICS_PER_NODE = 10_000;
|
|
57
64
|
|
|
@@ -80,12 +87,13 @@ export class ChannelHub {
|
|
|
80
87
|
readonly #only: ReadonlyMap<string, Channel> | null;
|
|
81
88
|
readonly #logs: ChannelLogs;
|
|
82
89
|
readonly #ctx: Ctx;
|
|
90
|
+
/** Topics a socket's policy refused, until its actor changes (`channel-latch.ts`). */
|
|
91
|
+
readonly #latched: DenialLatch;
|
|
83
92
|
/**
|
|
84
|
-
* Topics
|
|
85
|
-
*
|
|
86
|
-
* THAT channel is refused, every other one on the socket keeps flowing.
|
|
93
|
+
* Topics whose guard could not DECIDE on a re-auth. Not a denial, so the seat and its bridge
|
|
94
|
+
* reference are kept; not a pass either, so nothing is delivered until one succeeds.
|
|
87
95
|
*/
|
|
88
|
-
readonly #
|
|
96
|
+
readonly #suspended = new WeakMap<SyncSocket, Set<Topic>>();
|
|
89
97
|
|
|
90
98
|
constructor(options: ChannelHubOptions) {
|
|
91
99
|
this.#transport = options.transport;
|
|
@@ -100,7 +108,8 @@ export class ChannelHub {
|
|
|
100
108
|
'maxTopicsPerNode',
|
|
101
109
|
options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE,
|
|
102
110
|
);
|
|
103
|
-
this.#
|
|
111
|
+
this.#latched = new DenialLatch(this.#maxTopicsPerSocket, 'maxTopicsPerSocket');
|
|
112
|
+
this.#ctx = options.ctx ?? ctxOf();
|
|
104
113
|
this.#logs = new ChannelLogs(options.sockets, options.ringSize);
|
|
105
114
|
this.#only =
|
|
106
115
|
options.channels === undefined ? null : new Map(options.channels.map((c) => [c.name, c]));
|
|
@@ -123,31 +132,94 @@ export class ChannelHub {
|
|
|
123
132
|
}
|
|
124
133
|
const params: Record<string, string> = {};
|
|
125
134
|
for (const param of declared.params) {
|
|
126
|
-
|
|
135
|
+
const value = Object.hasOwn(target.params, param) ? (target.params[param] ?? '') : '';
|
|
136
|
+
// A topic is retained (the bridge table, the latch, the client's sid), and `topic()` checks
|
|
137
|
+
// a segment's alphabet, never its size — so a param is the client's to grow only this far.
|
|
138
|
+
if (value.length > MAX_CHANNEL_PARAM_LENGTH) {
|
|
139
|
+
throw new TopicForbiddenError({
|
|
140
|
+
topic: target.channel,
|
|
141
|
+
actorId: socket.actorId,
|
|
142
|
+
reason: `param "${param}" is ${value.length} characters; the limit is ${MAX_CHANNEL_PARAM_LENGTH}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
params[param] = value;
|
|
127
146
|
}
|
|
128
147
|
const name = declared.topic(params);
|
|
129
|
-
if (this.#latched.
|
|
148
|
+
if (this.#latched.has(socket, name)) {
|
|
130
149
|
throw new TopicForbiddenError({
|
|
131
150
|
topic: name,
|
|
132
151
|
actorId: socket.actorId,
|
|
133
152
|
reason: 'denied earlier on this connection; it is re-decided when the session changes',
|
|
134
153
|
});
|
|
135
154
|
}
|
|
155
|
+
this.#latched.assertRoom(socket);
|
|
156
|
+
const seat: ChannelTopic = { channel: declared, params };
|
|
157
|
+
// A suspended seat asked for again (the client's beat) is the next pass, never a second seat.
|
|
158
|
+
if (this.#suspended.get(socket)?.has(name) === true) {
|
|
159
|
+
await this.#settle(socket, name, seat, UNDECIDED);
|
|
160
|
+
return name;
|
|
161
|
+
}
|
|
136
162
|
// Asked before the join seats it: a repeated `add` (the presence beat) is not a fresh seat.
|
|
137
163
|
const fresh = !socket.topics.has(name);
|
|
164
|
+
// Captured, because the guard awaits and a re-auth may land under it: the verdict below is
|
|
165
|
+
// about THIS actor, and `onActorChange` cannot re-decide a seat that does not exist yet.
|
|
166
|
+
const actor = socket.actor;
|
|
138
167
|
await this.#join(socket, name, async () => {
|
|
139
168
|
try {
|
|
140
|
-
await authorizeChannel(declared, this.#ctx,
|
|
169
|
+
await authorizeChannel(declared, this.#ctx, actor, name, params);
|
|
141
170
|
} catch (error) {
|
|
142
|
-
if (error instanceof TopicForbiddenError
|
|
171
|
+
if (error instanceof TopicForbiddenError && socket.actor === actor) {
|
|
172
|
+
this.#latched.add(socket, name);
|
|
173
|
+
}
|
|
143
174
|
throw error;
|
|
144
175
|
}
|
|
145
176
|
});
|
|
146
|
-
|
|
177
|
+
// The socket died while the guard or the bridge was answering: `#join` gave the seat back, so
|
|
178
|
+
// there is no member to open a ring for or to resume.
|
|
179
|
+
if (!socket.topics.has(name)) return name;
|
|
180
|
+
this.#logs.open(name, seat);
|
|
181
|
+
await this.#settle(socket, name, seat, actor);
|
|
182
|
+
// Dropped is a throw; suspended is a seat nothing is delivered on, the resume included.
|
|
183
|
+
if (!socket.topics.has(name)) return name;
|
|
147
184
|
this.#logs.resume(socket, name, target.since, fresh);
|
|
148
185
|
return name;
|
|
149
186
|
}
|
|
150
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Decide a seat under the socket's CURRENT actor until the actor it was decided for is still the
|
|
190
|
+
* one on the socket. A seat granted to the actor a re-auth has since replaced was kept until the
|
|
191
|
+
* next grant expiry; a re-ask on a suspended seat ignored a denial and answered success.
|
|
192
|
+
*/
|
|
193
|
+
async #settle(
|
|
194
|
+
socket: SyncSocket,
|
|
195
|
+
name: Topic,
|
|
196
|
+
seat: ChannelTopic,
|
|
197
|
+
decidedFor: Actor | null | typeof UNDECIDED,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
let actor = decidedFor;
|
|
200
|
+
while (actor === UNDECIDED || socket.actor !== actor) {
|
|
201
|
+
actor = socket.actor;
|
|
202
|
+
if ((await this.#decide(socket, name, seat, actor)) !== 'denied') continue;
|
|
203
|
+
if (socket.actor === actor) this.#latched.add(socket, name);
|
|
204
|
+
throw new TopicForbiddenError({
|
|
205
|
+
topic: name,
|
|
206
|
+
actorId: socket.actorId,
|
|
207
|
+
reason: `channel "${seat.channel.name}" policy denied the subscribe`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Every open records topic is presumed to have missed a change: a new epoch, and every member
|
|
214
|
+
* told `replay-gap`. The `sync` node calls it beside `LiveQueryRegistry.invalidate()` — seq is
|
|
215
|
+
* minted here from the changes this node SEES, so a change the bus dropped leaves a hole the
|
|
216
|
+
* ring cannot detect and would replay as complete history. Given an `entity`, only the topics
|
|
217
|
+
* carrying it: what a bulk write the in-process replicator could not shape as rows stales.
|
|
218
|
+
*/
|
|
219
|
+
invalidate(entity?: string): number {
|
|
220
|
+
return this.#logs.invalidate(entity);
|
|
221
|
+
}
|
|
222
|
+
|
|
151
223
|
/**
|
|
152
224
|
* One committed change from the feed, turned into `records` frames on every declared channel it
|
|
153
225
|
* touches and delivered on THIS node. Called for every change the node receives — the same
|
|
@@ -163,7 +235,7 @@ export class ChannelHub {
|
|
|
163
235
|
params: Readonly<Record<K, string>>,
|
|
164
236
|
event: JsonObject,
|
|
165
237
|
): Promise<void> {
|
|
166
|
-
|
|
238
|
+
assertCoded(
|
|
167
239
|
declared.events,
|
|
168
240
|
'X_CHANNEL_DECLARATION_INVALID',
|
|
169
241
|
`channel("${declared.name}") declares no events, so nothing may publish one on it`,
|
|
@@ -178,6 +250,9 @@ export class ChannelHub {
|
|
|
178
250
|
* a topic a caller spelled; every `Topic` comes from a declaration.
|
|
179
251
|
*/
|
|
180
252
|
async emit(name: Topic, event: JsonObject): Promise<void> {
|
|
253
|
+
// A topic this node holds open on a channel declared without `events` carries none: a
|
|
254
|
+
// presence leave for it was an event on a channel whose members were promised records only.
|
|
255
|
+
if (this.#logs.target(name)?.channel.events === false) return;
|
|
181
256
|
const frame: ChannelEventsFrame = { type: 'events', v: PROTOCOL_VERSION, channel: name, event };
|
|
182
257
|
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, JSON.stringify(frame));
|
|
183
258
|
}
|
|
@@ -189,12 +264,6 @@ export class ChannelHub {
|
|
|
189
264
|
return this.#logs.target(name);
|
|
190
265
|
}
|
|
191
266
|
|
|
192
|
-
#latch(socket: SyncSocket, name: string): void {
|
|
193
|
-
const latched = this.#latched.get(socket) ?? new Set<string>();
|
|
194
|
-
latched.add(name);
|
|
195
|
-
this.#latched.set(socket, latched);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
267
|
/** Sockets this node will deliver `name` to. The metric the fanout reads. */
|
|
199
268
|
subscriberCount(name: Topic): number {
|
|
200
269
|
return this.#sockets.subscriberCount(name);
|
|
@@ -222,7 +291,8 @@ export class ChannelHub {
|
|
|
222
291
|
async #join(socket: SyncSocket, name: Topic, authorize: () => Promise<void>): Promise<void> {
|
|
223
292
|
if (socket.topics.has(name)) return;
|
|
224
293
|
const claimed = this.#claimed.get(socket) ?? 0;
|
|
225
|
-
|
|
294
|
+
const held = socket.topics.size + (this.#suspended.get(socket)?.size ?? 0);
|
|
295
|
+
if (held + claimed >= this.#maxTopicsPerSocket) {
|
|
226
296
|
throw new SubscriptionLimitError({
|
|
227
297
|
scope: 'socket',
|
|
228
298
|
id: socket.id,
|
|
@@ -263,51 +333,81 @@ export class ChannelHub {
|
|
|
263
333
|
// fanout reads are one fact, and two call sites for one fact is the drift that makes an index
|
|
264
334
|
// wrong. The registry owns it because it is the only thing that sees a socket die.
|
|
265
335
|
this.#sockets.joinTopic(socket, name);
|
|
336
|
+
// The socket closed while this subscribe was parked: its teardown has already walked its
|
|
337
|
+
// topics, so a seat taken now is a bridge pinned with no member and nothing left to release it.
|
|
338
|
+
if (socket.closed) this.unsubscribe(socket, name);
|
|
266
339
|
}
|
|
267
340
|
|
|
268
341
|
unsubscribe(socket: SyncSocket, name: Topic): void {
|
|
269
|
-
|
|
270
|
-
|
|
342
|
+
const suspended = this.#suspended.get(socket)?.delete(name) === true;
|
|
343
|
+
if (!suspended && !socket.topics.has(name)) return;
|
|
344
|
+
if (!suspended) this.#sockets.leaveTopic(socket, name);
|
|
271
345
|
this.#release(name);
|
|
272
346
|
}
|
|
273
347
|
|
|
348
|
+
/** Every topic this socket holds a seat on — the delivered ones and the suspended ones. */
|
|
349
|
+
topicsOf(socket: SyncSocket): readonly Topic[] {
|
|
350
|
+
return [...socket.topics, ...(this.#suspended.get(socket) ?? [])] as Topic[];
|
|
351
|
+
}
|
|
352
|
+
|
|
274
353
|
/**
|
|
275
354
|
* Called when a socket's session changes (login, logout, role change, token refresh).
|
|
276
355
|
*
|
|
277
|
-
* A denial drops the topic
|
|
278
|
-
*
|
|
279
|
-
* outage
|
|
280
|
-
*
|
|
281
|
-
*
|
|
356
|
+
* A denial drops the topic. Anything else is not a decision — a guard is app code and may reach
|
|
357
|
+
* a database, and reading a store that timed out as a revoked grant dropped every topic on every
|
|
358
|
+
* re-authenticated socket during one outage. It is not a pass either: the seat is kept and
|
|
359
|
+
* SUSPENDED, so nothing is delivered on it until a later pass (the next re-auth, or the client's
|
|
360
|
+
* own beat) decides. Returns the topics dropped — denials and nothing else.
|
|
282
361
|
*/
|
|
283
362
|
async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
|
|
284
363
|
socket.actor = actor;
|
|
285
364
|
// A new session re-decides everything, the latched denials included.
|
|
286
|
-
this.#latched.
|
|
365
|
+
this.#latched.clear(socket);
|
|
287
366
|
const dropped: Topic[] = [];
|
|
288
|
-
for (const name of
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
|
|
293
|
-
}
|
|
294
|
-
} catch (error) {
|
|
295
|
-
if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
|
|
296
|
-
this.unsubscribe(socket, name);
|
|
297
|
-
dropped.push(name);
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
this.#guardFailures += 1;
|
|
301
|
-
logger.warn('channel.guard_failed', {
|
|
302
|
-
topic: name,
|
|
303
|
-
socketId: socket.id,
|
|
304
|
-
error: renderThrowable(error),
|
|
305
|
-
});
|
|
306
|
-
}
|
|
367
|
+
for (const name of this.topicsOf(socket)) {
|
|
368
|
+
const target = this.#logs.target(name);
|
|
369
|
+
if (target === undefined) continue;
|
|
370
|
+
if ((await this.#decide(socket, name, target, actor)) === 'denied') dropped.push(name);
|
|
307
371
|
}
|
|
308
372
|
return dropped;
|
|
309
373
|
}
|
|
310
374
|
|
|
375
|
+
/** One seat, re-decided: dropped on a denial, suspended on a failure, delivered on a pass. */
|
|
376
|
+
async #decide(
|
|
377
|
+
socket: SyncSocket,
|
|
378
|
+
name: Topic,
|
|
379
|
+
target: ChannelTopic,
|
|
380
|
+
actor: Actor | null,
|
|
381
|
+
): Promise<'allowed' | 'denied' | 'undecided'> {
|
|
382
|
+
try {
|
|
383
|
+
await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
|
|
386
|
+
this.unsubscribe(socket, name);
|
|
387
|
+
return 'denied';
|
|
388
|
+
}
|
|
389
|
+
this.#guardFailures += 1;
|
|
390
|
+
logger.warn('channel.guard_failed', {
|
|
391
|
+
topic: name,
|
|
392
|
+
socketId: socket.id,
|
|
393
|
+
error: renderThrowable(error),
|
|
394
|
+
});
|
|
395
|
+
if (socket.topics.has(name)) {
|
|
396
|
+
this.#sockets.leaveTopic(socket, name);
|
|
397
|
+
const suspended = this.#suspended.get(socket) ?? new Set<Topic>();
|
|
398
|
+
this.#suspended.set(socket, suspended.add(name));
|
|
399
|
+
}
|
|
400
|
+
return 'undecided';
|
|
401
|
+
}
|
|
402
|
+
// Still suspended, and still open: a socket that closed meanwhile gave the seat back already.
|
|
403
|
+
if (this.#suspended.get(socket)?.delete(name) === true) {
|
|
404
|
+
this.#sockets.joinTopic(socket, name);
|
|
405
|
+
// What the suspension withheld is unknown to the client, so a records channel is re-read.
|
|
406
|
+
this.#logs.resume(socket, name, undefined, true);
|
|
407
|
+
}
|
|
408
|
+
return 'allowed';
|
|
409
|
+
}
|
|
410
|
+
|
|
311
411
|
async close(): Promise<void> {
|
|
312
412
|
// Set BEFORE the table is walked, because the table is not the whole story: a reservation an
|
|
313
413
|
// in-flight `subscribe` has not opened yet is `sub === null`, so `unsubscribeWhenOpen` does
|
package/src/client-channels.ts
CHANGED
|
@@ -208,6 +208,10 @@ export class ChannelBook {
|
|
|
208
208
|
event(frame: ChannelEventsFrame): void {
|
|
209
209
|
const entry = this.#byTopic.get(frame.channel);
|
|
210
210
|
const presence = readPresence(frame.event);
|
|
211
|
+
// The node delivers an event only to a member it has seated, and for an events-only channel
|
|
212
|
+
// (no rows, so no `records` and no `replay-gap`) the roster is the one answer a subscribe
|
|
213
|
+
// gets. Only from `joining`: a read in flight, a refusal and a dead socket are not undone.
|
|
214
|
+
if (entry?.state === 'joining') this.#set(entry, 'live');
|
|
211
215
|
for (const holder of entry?.holders ?? []) {
|
|
212
216
|
if (presence !== null) holder.onPresence?.(presence);
|
|
213
217
|
else holder.onEvent?.(frame.event);
|
|
@@ -230,7 +234,12 @@ export class ChannelBook {
|
|
|
230
234
|
const contiguous = entry.contiguous ?? frame.seq - 1;
|
|
231
235
|
// A duplicate is dropped; a replayed seq filling a hole is new. A numeric hole on its own is
|
|
232
236
|
// never a gap — a row this socket may not see is skipped for it; only `replay-gap` is.
|
|
233
|
-
if (frame.seq <= contiguous || entry.above.has(frame.seq))
|
|
237
|
+
if (frame.seq <= contiguous || entry.above.has(frame.seq)) {
|
|
238
|
+
// Still a frame of THIS epoch on THIS seat — and the whole of the node's answer to a resume
|
|
239
|
+
// that was already current (`ChannelLogs.resume`): nothing to apply, and no longer joining.
|
|
240
|
+
if (entry.state !== 'live') this.#set(entry, 'live');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
234
243
|
const store = this.#deps.store;
|
|
235
244
|
store.batch(() => {
|
|
236
245
|
for (const [type, rows] of Object.entries(frame.adopt ?? {})) store.adopt(type, rows);
|
|
@@ -283,6 +292,8 @@ export class ChannelBook {
|
|
|
283
292
|
entry.disarm?.();
|
|
284
293
|
entry.disarm = null;
|
|
285
294
|
this.#set(entry, 'catching-up');
|
|
295
|
+
// Names no write (#507 pt 2: owner O-507, default none) — a pending write this read already
|
|
296
|
+
// reflects settles on its own HTTP answer. Where a catch-up names its writes, it goes here.
|
|
286
297
|
this.#deps.catchUp(entry.catchUp, entry.params).then(
|
|
287
298
|
() => this.#drain(entry),
|
|
288
299
|
(error: unknown) => {
|
package/src/client-contract.ts
CHANGED
|
@@ -63,7 +63,10 @@ export interface LiveClientOptions {
|
|
|
63
63
|
readonly clock?: Clock;
|
|
64
64
|
/** How a pending reconnect is armed. Defaults to `setTimeout`; tests fire theirs by hand. */
|
|
65
65
|
readonly scheduler?: Scheduler;
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* How often a live socket re-announces itself, in ms, until the node names its own beat on its
|
|
68
|
+
* `hello` (`hello.heartbeatMs`). `0` disables the pass, whatever the node says. Default 10s.
|
|
69
|
+
*/
|
|
67
70
|
readonly heartbeatMs?: number;
|
|
68
71
|
/** Where a failure nobody awaits is reported. Defaults to `console.error`. */
|
|
69
72
|
readonly onError?: (error: unknown) => void;
|
package/src/client-frames.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface ClientFrameTarget {
|
|
|
36
36
|
now(): number;
|
|
37
37
|
/** A newer build is live; the app decides when to reload. */
|
|
38
38
|
setUpdate(buildId: string | null): void;
|
|
39
|
+
/** The node named the beat this socket must keep (`hello.heartbeatMs`). */
|
|
40
|
+
followHeartbeat(intervalMs: number): void;
|
|
41
|
+
/** A refused registration the node said may succeed later: subscribe it again after `afterMs`. */
|
|
42
|
+
retryLater(registration: Registration, afterMs: number): void;
|
|
39
43
|
/** The node assigned this socket its own delay before closing it. */
|
|
40
44
|
scheduleReconnect(afterMs: number | null): void;
|
|
41
45
|
closeSocket(code: number, reason: string): void;
|
|
@@ -75,7 +79,8 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
75
79
|
registration.cursor = frame.cursor;
|
|
76
80
|
registration.state = 'live';
|
|
77
81
|
registration.error = undefined;
|
|
78
|
-
|
|
82
|
+
const { entity, rows, keys, writes } = frame;
|
|
83
|
+
target.windows.snapshot(registration, entity ?? null, rows, keys, writes);
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
81
86
|
case 'patch': {
|
|
@@ -91,7 +96,8 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
91
96
|
registration.cursor = next;
|
|
92
97
|
}
|
|
93
98
|
registration.state = 'live';
|
|
94
|
-
|
|
99
|
+
// The writes the frame names settle in the window's own batch, as a `records` frame's do.
|
|
100
|
+
target.windows.patch(registration, frame.patches, frame.writes);
|
|
95
101
|
return;
|
|
96
102
|
}
|
|
97
103
|
// A patch names a live registration or nothing: a channel's rows ride `records` frames.
|
|
@@ -110,6 +116,9 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
110
116
|
registration.state = 'failed';
|
|
111
117
|
registration.error = refusal;
|
|
112
118
|
registration.notify();
|
|
119
|
+
// A refusal that states when it may succeed (a rate limit's refill) is not final.
|
|
120
|
+
const after = frame.error.retryAfterSeconds;
|
|
121
|
+
if (after !== undefined) target.retryLater(registration, after * 1000);
|
|
113
122
|
return;
|
|
114
123
|
}
|
|
115
124
|
case 'reconnect': {
|
|
@@ -135,8 +144,12 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
135
144
|
target.channels.gap(frame);
|
|
136
145
|
return;
|
|
137
146
|
case 'hello':
|
|
147
|
+
// The node's reply. It carries one instruction — how often to beat — and a node that names
|
|
148
|
+
// none (an older build) leaves the client on its own default.
|
|
149
|
+
if (frame.heartbeatMs !== undefined) target.followHeartbeat(frame.heartbeatMs);
|
|
150
|
+
return;
|
|
138
151
|
case 'subscribe':
|
|
139
|
-
//
|
|
152
|
+
// A client-authored frame: never received. Ignored rather than thrown, so a future
|
|
140
153
|
// bidirectional use of the same kind cannot break an old client.
|
|
141
154
|
return;
|
|
142
155
|
}
|
package/src/client-heartbeat.ts
CHANGED
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
import type { Scheduler } from './thundering-herd';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* A third of the node's default presence ttl (`DEFAULT_PRESENCE_TTL_MS`, 30 s), which is what the
|
|
9
|
+
* node derives for itself (`PresenceRegistry.heartbeatMs` is `ttlMs / 3`): two beats land inside
|
|
10
|
+
* every ttl, so ONE lost beat is never a false leave. It was 15 s — half the ttl, where the beat
|
|
11
|
+
* after a lost one lands on the expiry itself. `client-heartbeat.test.ts` holds the two equal;
|
|
12
|
+
* this file cannot import the server's constant (`transport-env.ts` links `nats`).
|
|
12
13
|
*/
|
|
13
|
-
export const DEFAULT_HEARTBEAT_MS =
|
|
14
|
+
export const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
/** The node floors the beat it names at 1 s (`clientHeartbeatMs`); less than that is not its word. */
|
|
17
|
+
export const MIN_HEARTBEAT_MS = 1_000;
|
|
14
18
|
|
|
15
19
|
export interface HeartbeatOptions {
|
|
16
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Until the node names its own (`follow`). `0` (or less) disables the pass entirely, whatever
|
|
22
|
+
* the node says — the shape a test that owns the clock wants.
|
|
23
|
+
*/
|
|
17
24
|
readonly intervalMs: number;
|
|
18
25
|
readonly schedule: Scheduler;
|
|
19
26
|
readonly now: () => number;
|
|
@@ -32,19 +39,42 @@ export class Heartbeat {
|
|
|
32
39
|
readonly #options: HeartbeatOptions;
|
|
33
40
|
#cancel: (() => void) | null = null;
|
|
34
41
|
#lastSeen = 0;
|
|
42
|
+
/** The beat in force on THIS socket: the option's, until the node's `hello` names another. */
|
|
43
|
+
#intervalMs: number;
|
|
35
44
|
|
|
36
45
|
constructor(options: HeartbeatOptions) {
|
|
37
46
|
this.#options = options;
|
|
47
|
+
this.#intervalMs = options.intervalMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get intervalMs(): number {
|
|
51
|
+
return this.#intervalMs;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/** The socket is up. `now` seeds the silence window, so the first tick judges this connection. */
|
|
41
55
|
start(now: number): void {
|
|
42
56
|
this.stop();
|
|
43
|
-
|
|
57
|
+
// A new socket may be another node's: its beat is not known until its `hello` says so.
|
|
58
|
+
this.#intervalMs = this.#options.intervalMs;
|
|
59
|
+
if (this.#intervalMs <= 0) return;
|
|
44
60
|
this.#lastSeen = now;
|
|
45
61
|
this.#arm();
|
|
46
62
|
}
|
|
47
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The node named the beat (`hello.heartbeatMs`): a third of ITS presence ttl, bounded by ITS idle
|
|
66
|
+
* budget — two numbers a browser cannot read, and the reason a fixed client beat was wrong for
|
|
67
|
+
* every app that set either. Re-armed at once, so a shorter beat does not wait out the long one.
|
|
68
|
+
*/
|
|
69
|
+
follow(intervalMs: number): void {
|
|
70
|
+
if (this.#options.intervalMs <= 0) return;
|
|
71
|
+
if (intervalMs < MIN_HEARTBEAT_MS || intervalMs === this.#intervalMs) return;
|
|
72
|
+
this.#intervalMs = intervalMs;
|
|
73
|
+
if (this.#cancel === null) return;
|
|
74
|
+
this.stop();
|
|
75
|
+
this.#arm();
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
/** A frame arrived. Anything counts: the point is that bytes still cross in this direction. */
|
|
49
79
|
saw(now: number): void {
|
|
50
80
|
this.#lastSeen = now;
|
|
@@ -60,7 +90,7 @@ export class Heartbeat {
|
|
|
60
90
|
this.#cancel = this.#options.schedule(() => {
|
|
61
91
|
this.#cancel = null;
|
|
62
92
|
this.#tick();
|
|
63
|
-
}, this.#
|
|
93
|
+
}, this.#intervalMs);
|
|
64
94
|
}
|
|
65
95
|
|
|
66
96
|
#tick(): void {
|
|
@@ -68,7 +98,7 @@ export class Heartbeat {
|
|
|
68
98
|
// Two windows, not one: a beat and the answer to it share the window they were sent in, so a
|
|
69
99
|
// single quiet interval is a slow round trip and not a dead socket. Nothing is re-armed after
|
|
70
100
|
// a silence — `onSilence` drops the socket, and the next `start()` is the next connection's.
|
|
71
|
-
if (now - this.#lastSeen > this.#
|
|
101
|
+
if (now - this.#lastSeen > this.#intervalMs * 2) {
|
|
72
102
|
this.#options.onSilence();
|
|
73
103
|
return;
|
|
74
104
|
}
|
package/src/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// signal belongs to one bundle's solid-js, so everything here is a plain read plus a listener.
|
|
5
5
|
// Read-only: the socket carries no writes (`useMutation` is HTTP).
|
|
6
6
|
|
|
7
|
-
import { type Clock, finiteOption, systemClock,
|
|
7
|
+
import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core/page';
|
|
8
8
|
import {
|
|
9
9
|
ChannelBook,
|
|
10
10
|
type ChannelHandlers,
|
|
@@ -49,6 +49,8 @@ export class LiveClient {
|
|
|
49
49
|
readonly #clock: Clock;
|
|
50
50
|
readonly #onError: (error: unknown) => void;
|
|
51
51
|
readonly #registrations = new Map<string, Registration>();
|
|
52
|
+
/** A refused registration's armed re-subscribe, by sid — the node said when it may succeed. */
|
|
53
|
+
readonly #retries = new Map<string, () => void>();
|
|
52
54
|
readonly #windows: RowWindows;
|
|
53
55
|
readonly #channels: ChannelBook;
|
|
54
56
|
readonly #heartbeat: Heartbeat;
|
|
@@ -194,6 +196,9 @@ export class LiveClient {
|
|
|
194
196
|
*/
|
|
195
197
|
#offline(): void {
|
|
196
198
|
this.#heartbeat.stop();
|
|
199
|
+
// The reopen replays every registration, so a timer left armed would send one twice.
|
|
200
|
+
for (const cancel of this.#retries.values()) cancel();
|
|
201
|
+
this.#retries.clear();
|
|
197
202
|
this.#setStatus({ connected: false });
|
|
198
203
|
// Told once, not two ways: a `useConnection().offline` that flips while a live window still
|
|
199
204
|
// reads 'live' is one dead socket rendered as two states. A refused one stays refused.
|
|
@@ -230,7 +235,7 @@ export class LiveClient {
|
|
|
230
235
|
query: LiveQueryRef,
|
|
231
236
|
input: JsonValue,
|
|
232
237
|
): LiveHandle<R> {
|
|
233
|
-
const sid =
|
|
238
|
+
const sid = uuidV7();
|
|
234
239
|
const listeners = new Set<() => void>();
|
|
235
240
|
const registration: Registration = {
|
|
236
241
|
sid,
|
|
@@ -254,6 +259,8 @@ export class LiveClient {
|
|
|
254
259
|
if (!open) return;
|
|
255
260
|
open = false;
|
|
256
261
|
this.#registrations.delete(sid);
|
|
262
|
+
this.#retries.get(sid)?.();
|
|
263
|
+
this.#retries.delete(sid);
|
|
257
264
|
close();
|
|
258
265
|
listeners.clear();
|
|
259
266
|
this.#send({
|
|
@@ -321,12 +328,33 @@ export class LiveClient {
|
|
|
321
328
|
// against a re-snapshot, so the frame path reads the same clock every other path does.
|
|
322
329
|
now: () => this.#clock.now().getTime(),
|
|
323
330
|
setUpdate: (buildId) => this.#setStatus({ update: buildId }),
|
|
331
|
+
followHeartbeat: (intervalMs) => this.#heartbeat.follow(intervalMs),
|
|
324
332
|
scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
|
|
333
|
+
retryLater: (registration, afterMs) => this.#retryLater(registration, afterMs),
|
|
325
334
|
closeSocket: (code, reason) => this.#socket?.close(code, reason),
|
|
326
335
|
report: (error) => this.#onError(error),
|
|
327
336
|
};
|
|
328
337
|
}
|
|
329
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Ask again once the node's stated delay has passed — a rate-limited subscribe, whose bucket
|
|
341
|
+
* refills. Only while the registration is still held, still refused and the socket still up:
|
|
342
|
+
* anything else and the reopen's replay, or the unsubscribe, already decided its fate.
|
|
343
|
+
*/
|
|
344
|
+
#retryLater(registration: Registration, afterMs: number): void {
|
|
345
|
+
this.#retries.get(registration.sid)?.();
|
|
346
|
+
const cancel = (this.#options.scheduler ?? timeoutScheduler)(() => {
|
|
347
|
+
this.#retries.delete(registration.sid);
|
|
348
|
+
if (this.#registrations.get(registration.sid) !== registration) return;
|
|
349
|
+
if (!this.#connected || registration.state !== 'failed') return;
|
|
350
|
+
registration.state = 'loading';
|
|
351
|
+
registration.error = undefined;
|
|
352
|
+
registration.notify();
|
|
353
|
+
this.#sendSubscribe(registration);
|
|
354
|
+
}, afterMs);
|
|
355
|
+
this.#retries.set(registration.sid, cancel);
|
|
356
|
+
}
|
|
357
|
+
|
|
330
358
|
/** The opening frame, and the heartbeat's. One shape, because it makes one claim: I am here. */
|
|
331
359
|
#hello(): Frame {
|
|
332
360
|
return {
|
package/src/cursor.ts
CHANGED
|
@@ -76,6 +76,17 @@ export interface ResumeSource {
|
|
|
76
76
|
* the entry, which is the only moment anything knows the window is unreachable.
|
|
77
77
|
*/
|
|
78
78
|
forget?(qid: string): void;
|
|
79
|
+
/**
|
|
80
|
+
* The window behind `qid` was read at `lsn`, so what is retained for it is complete from there
|
|
81
|
+
* and from nowhere earlier; `exclusive` refuses a cursor exactly at it (a forced re-read), `sole`
|
|
82
|
+
* answers that cursor and no other below the first patch (a read with no position behind it).
|
|
83
|
+
* Optional for `forget`'s reason. When it is called, and why, is one rule: `change-buffer.ts`.
|
|
84
|
+
*/
|
|
85
|
+
floorAt?(
|
|
86
|
+
qid: string,
|
|
87
|
+
lsn: string,
|
|
88
|
+
options?: { readonly exclusive?: boolean; readonly sole?: boolean },
|
|
89
|
+
): void;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
export type ResumeResult<R extends Row = Row> =
|