@ultimat3/realtime 23.0.0 → 24.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 +75 -81
- package/README.md +107 -17
- package/package.json +4 -4
- package/src/advisory-lock.ts +57 -0
- package/src/boot.ts +12 -7
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +2 -2
- package/src/changefeed.ts +39 -1
- package/src/channel-authz.ts +43 -12
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +34 -4
- package/src/channel-records.ts +33 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +143 -44
- package/src/client-channels.ts +10 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +7 -1
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +1 -0
- package/src/cursor.ts +11 -0
- package/src/errors.ts +70 -19
- package/src/fanout.ts +13 -0
- package/src/index.ts +1 -1
- package/src/live-definition.ts +22 -7
- package/src/live-fanout.ts +43 -7
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +36 -37
- package/src/nats-client.ts +11 -2
- package/src/nats-transport.ts +22 -0
- package/src/offline-queue.ts +48 -5
- package/src/page-errors.ts +17 -0
- package/src/page-outbox.ts +121 -50
- package/src/pg-advisory-lock.ts +45 -1
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +119 -21
- 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 +44 -45
- package/src/pg-socket.ts +6 -4
- package/src/pg-tls.ts +18 -0
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/presence.ts +85 -8
- package/src/query-window.ts +16 -4
- package/src/record-persister.ts +7 -2
- package/src/replication-errors.ts +8 -4
- package/src/replicator-envelope.ts +110 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +319 -186
- package/src/server.ts +10 -6
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/subscriber-gate.ts +11 -1
- package/src/sync-auth.ts +47 -2
- package/src/sync-frames.ts +73 -20
- package/src/sync-node-bounds.ts +15 -0
- package/src/sync-node-contract.ts +20 -2
- package/src/sync-node.ts +92 -17
- package/src/sync-origin.ts +26 -11
- package/src/sync-protocol.ts +13 -3
- package/src/sync-upgrade.ts +76 -8
- package/src/use-mutation.ts +33 -5
- package/src/wire-channel.ts +13 -4
package/src/channel.ts
CHANGED
|
@@ -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,6 +108,7 @@ export class ChannelHub {
|
|
|
100
108
|
'maxTopicsPerNode',
|
|
101
109
|
options.maxTopicsPerNode ?? DEFAULT_MAX_TOPICS_PER_NODE,
|
|
102
110
|
);
|
|
111
|
+
this.#latched = new DenialLatch(this.#maxTopicsPerSocket, 'maxTopicsPerSocket');
|
|
103
112
|
this.#ctx = options.ctx ?? createContext();
|
|
104
113
|
this.#logs = new ChannelLogs(options.sockets, options.ringSize);
|
|
105
114
|
this.#only =
|
|
@@ -123,31 +132,93 @@ 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.
|
|
217
|
+
*/
|
|
218
|
+
invalidate(): number {
|
|
219
|
+
return this.#logs.invalidate();
|
|
220
|
+
}
|
|
221
|
+
|
|
151
222
|
/**
|
|
152
223
|
* One committed change from the feed, turned into `records` frames on every declared channel it
|
|
153
224
|
* touches and delivered on THIS node. Called for every change the node receives — the same
|
|
@@ -178,6 +249,9 @@ export class ChannelHub {
|
|
|
178
249
|
* a topic a caller spelled; every `Topic` comes from a declaration.
|
|
179
250
|
*/
|
|
180
251
|
async emit(name: Topic, event: JsonObject): Promise<void> {
|
|
252
|
+
// A topic this node holds open on a channel declared without `events` carries none: a
|
|
253
|
+
// presence leave for it was an event on a channel whose members were promised records only.
|
|
254
|
+
if (this.#logs.target(name)?.channel.events === false) return;
|
|
181
255
|
const frame: ChannelEventsFrame = { type: 'events', v: PROTOCOL_VERSION, channel: name, event };
|
|
182
256
|
await this.#transport.publish(`${CHANNEL_SUBJECT_PREFIX}.${name}`, JSON.stringify(frame));
|
|
183
257
|
}
|
|
@@ -189,12 +263,6 @@ export class ChannelHub {
|
|
|
189
263
|
return this.#logs.target(name);
|
|
190
264
|
}
|
|
191
265
|
|
|
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
266
|
/** Sockets this node will deliver `name` to. The metric the fanout reads. */
|
|
199
267
|
subscriberCount(name: Topic): number {
|
|
200
268
|
return this.#sockets.subscriberCount(name);
|
|
@@ -222,7 +290,8 @@ export class ChannelHub {
|
|
|
222
290
|
async #join(socket: SyncSocket, name: Topic, authorize: () => Promise<void>): Promise<void> {
|
|
223
291
|
if (socket.topics.has(name)) return;
|
|
224
292
|
const claimed = this.#claimed.get(socket) ?? 0;
|
|
225
|
-
|
|
293
|
+
const held = socket.topics.size + (this.#suspended.get(socket)?.size ?? 0);
|
|
294
|
+
if (held + claimed >= this.#maxTopicsPerSocket) {
|
|
226
295
|
throw new SubscriptionLimitError({
|
|
227
296
|
scope: 'socket',
|
|
228
297
|
id: socket.id,
|
|
@@ -263,51 +332,81 @@ export class ChannelHub {
|
|
|
263
332
|
// fanout reads are one fact, and two call sites for one fact is the drift that makes an index
|
|
264
333
|
// wrong. The registry owns it because it is the only thing that sees a socket die.
|
|
265
334
|
this.#sockets.joinTopic(socket, name);
|
|
335
|
+
// The socket closed while this subscribe was parked: its teardown has already walked its
|
|
336
|
+
// topics, so a seat taken now is a bridge pinned with no member and nothing left to release it.
|
|
337
|
+
if (socket.closed) this.unsubscribe(socket, name);
|
|
266
338
|
}
|
|
267
339
|
|
|
268
340
|
unsubscribe(socket: SyncSocket, name: Topic): void {
|
|
269
|
-
|
|
270
|
-
|
|
341
|
+
const suspended = this.#suspended.get(socket)?.delete(name) === true;
|
|
342
|
+
if (!suspended && !socket.topics.has(name)) return;
|
|
343
|
+
if (!suspended) this.#sockets.leaveTopic(socket, name);
|
|
271
344
|
this.#release(name);
|
|
272
345
|
}
|
|
273
346
|
|
|
347
|
+
/** Every topic this socket holds a seat on — the delivered ones and the suspended ones. */
|
|
348
|
+
topicsOf(socket: SyncSocket): readonly Topic[] {
|
|
349
|
+
return [...socket.topics, ...(this.#suspended.get(socket) ?? [])] as Topic[];
|
|
350
|
+
}
|
|
351
|
+
|
|
274
352
|
/**
|
|
275
353
|
* Called when a socket's session changes (login, logout, role change, token refresh).
|
|
276
354
|
*
|
|
277
|
-
* A denial drops the topic
|
|
278
|
-
*
|
|
279
|
-
* outage
|
|
280
|
-
*
|
|
281
|
-
*
|
|
355
|
+
* A denial drops the topic. Anything else is not a decision — a guard is app code and may reach
|
|
356
|
+
* a database, and reading a store that timed out as a revoked grant dropped every topic on every
|
|
357
|
+
* re-authenticated socket during one outage. It is not a pass either: the seat is kept and
|
|
358
|
+
* SUSPENDED, so nothing is delivered on it until a later pass (the next re-auth, or the client's
|
|
359
|
+
* own beat) decides. Returns the topics dropped — denials and nothing else.
|
|
282
360
|
*/
|
|
283
361
|
async onActorChange(socket: SyncSocket, actor: Actor | null): Promise<readonly Topic[]> {
|
|
284
362
|
socket.actor = actor;
|
|
285
363
|
// A new session re-decides everything, the latched denials included.
|
|
286
|
-
this.#latched.
|
|
364
|
+
this.#latched.clear(socket);
|
|
287
365
|
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
|
-
}
|
|
366
|
+
for (const name of this.topicsOf(socket)) {
|
|
367
|
+
const target = this.#logs.target(name);
|
|
368
|
+
if (target === undefined) continue;
|
|
369
|
+
if ((await this.#decide(socket, name, target, actor)) === 'denied') dropped.push(name);
|
|
307
370
|
}
|
|
308
371
|
return dropped;
|
|
309
372
|
}
|
|
310
373
|
|
|
374
|
+
/** One seat, re-decided: dropped on a denial, suspended on a failure, delivered on a pass. */
|
|
375
|
+
async #decide(
|
|
376
|
+
socket: SyncSocket,
|
|
377
|
+
name: Topic,
|
|
378
|
+
target: ChannelTopic,
|
|
379
|
+
actor: Actor | null,
|
|
380
|
+
): Promise<'allowed' | 'denied' | 'undecided'> {
|
|
381
|
+
try {
|
|
382
|
+
await authorizeChannel(target.channel, this.#ctx, actor, name, target.params);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (isPolicyDenial(error) || error instanceof TopicForbiddenError) {
|
|
385
|
+
this.unsubscribe(socket, name);
|
|
386
|
+
return 'denied';
|
|
387
|
+
}
|
|
388
|
+
this.#guardFailures += 1;
|
|
389
|
+
logger.warn('channel.guard_failed', {
|
|
390
|
+
topic: name,
|
|
391
|
+
socketId: socket.id,
|
|
392
|
+
error: renderThrowable(error),
|
|
393
|
+
});
|
|
394
|
+
if (socket.topics.has(name)) {
|
|
395
|
+
this.#sockets.leaveTopic(socket, name);
|
|
396
|
+
const suspended = this.#suspended.get(socket) ?? new Set<Topic>();
|
|
397
|
+
this.#suspended.set(socket, suspended.add(name));
|
|
398
|
+
}
|
|
399
|
+
return 'undecided';
|
|
400
|
+
}
|
|
401
|
+
// Still suspended, and still open: a socket that closed meanwhile gave the seat back already.
|
|
402
|
+
if (this.#suspended.get(socket)?.delete(name) === true) {
|
|
403
|
+
this.#sockets.joinTopic(socket, name);
|
|
404
|
+
// What the suspension withheld is unknown to the client, so a records channel is re-read.
|
|
405
|
+
this.#logs.resume(socket, name, undefined, true);
|
|
406
|
+
}
|
|
407
|
+
return 'allowed';
|
|
408
|
+
}
|
|
409
|
+
|
|
311
410
|
async close(): Promise<void> {
|
|
312
411
|
// Set BEFORE the table is walked, because the table is not the whole story: a reservation an
|
|
313
412
|
// 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);
|
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,8 @@ 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;
|
|
39
41
|
/** The node assigned this socket its own delay before closing it. */
|
|
40
42
|
scheduleReconnect(afterMs: number | null): void;
|
|
41
43
|
closeSocket(code: number, reason: string): void;
|
|
@@ -135,8 +137,12 @@ export function applyFrame(frame: Frame, target: ClientFrameTarget): void {
|
|
|
135
137
|
target.channels.gap(frame);
|
|
136
138
|
return;
|
|
137
139
|
case 'hello':
|
|
140
|
+
// The node's reply. It carries one instruction — how often to beat — and a node that names
|
|
141
|
+
// none (an older build) leaves the client on its own default.
|
|
142
|
+
if (frame.heartbeatMs !== undefined) target.followHeartbeat(frame.heartbeatMs);
|
|
143
|
+
return;
|
|
138
144
|
case 'subscribe':
|
|
139
|
-
//
|
|
145
|
+
// A client-authored frame: never received. Ignored rather than thrown, so a future
|
|
140
146
|
// bidirectional use of the same kind cannot break an old client.
|
|
141
147
|
return;
|
|
142
148
|
}
|
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
|
@@ -321,6 +321,7 @@ export class LiveClient {
|
|
|
321
321
|
// against a re-snapshot, so the frame path reads the same clock every other path does.
|
|
322
322
|
now: () => this.#clock.now().getTime(),
|
|
323
323
|
setUpdate: (buildId) => this.#setStatus({ update: buildId }),
|
|
324
|
+
followHeartbeat: (intervalMs) => this.#heartbeat.follow(intervalMs),
|
|
324
325
|
scheduleReconnect: (afterMs) => this.#scheduleReconnect(afterMs),
|
|
325
326
|
closeSocket: (code, reason) => this.#socket?.close(code, reason),
|
|
326
327
|
report: (error) => this.#onError(error),
|
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> =
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Realtime's X_* codes. Every throw in this package goes through one of these classes so
|
|
2
2
|
// the same string renders in the terminal, the browser overlay, and `--json`.
|
|
3
3
|
|
|
4
|
-
import { registerErrorCodes } from '@ultimat3/core';
|
|
4
|
+
import { isFixShellSafe, registerErrorCodes, renderFixShellArg } from '@ultimat3/core';
|
|
5
5
|
import { RealtimeError } from './realtime-error';
|
|
6
6
|
|
|
7
7
|
/** Codes this package declares and owns. */
|
|
@@ -36,6 +36,7 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
36
36
|
'X_REALTIME_TOPOLOGY',
|
|
37
37
|
'X_REPLICATION_TLS',
|
|
38
38
|
'X_SOCKET_ORIGIN_REFUSED',
|
|
39
|
+
'X_OFFLINE_QUEUE_ABANDONED',
|
|
39
40
|
] as const;
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -61,6 +62,24 @@ export const POLICY_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
|
61
62
|
'X_UNAUTHENTICATED',
|
|
62
63
|
]);
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* `@ultimat3/entity`'s tenant guard refusing a read FOR THIS ACTOR: it carries no org, names
|
|
67
|
+
* another one, or reached across tenants without the capability. Verdicts about who is asking, so
|
|
68
|
+
* a channel's row loader raising one is a denial. `X_TENANCY_UNSCOPED` is deliberately absent —
|
|
69
|
+
* that is a query written without a tenant predicate, which is the app's defect and no decision.
|
|
70
|
+
* Spelled by name, never matched by prefix: a code's meaning is its owner's, not its shape's.
|
|
71
|
+
*/
|
|
72
|
+
export const TENANCY_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
73
|
+
'X_TENANCY_ACTOR_ORG_REQUIRED',
|
|
74
|
+
'X_TENANCY_ACTOR_MISMATCH',
|
|
75
|
+
'X_TENANCY_CROSS_DENIED',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
/** True when the tenant guard decided against the actor — see `TENANCY_DENIAL_CODES`. */
|
|
79
|
+
export function isTenancyDenial(error: unknown): boolean {
|
|
80
|
+
return TENANCY_DENIAL_CODES.has(codeOf(error) ?? '');
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
/**
|
|
65
84
|
* The sync protocol's answer to "which of these is a 4xx". A denied topic, a subscription cap, a
|
|
66
85
|
* skewed protocol version and a cursor that fell out of the buffer are all conditions the CLIENT
|
|
@@ -146,6 +165,7 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
|
|
|
146
165
|
X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
|
|
147
166
|
X_REPLICATION_TLS: 'the replication connection failed TLS',
|
|
148
167
|
X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
|
|
168
|
+
X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
|
|
149
169
|
};
|
|
150
170
|
|
|
151
171
|
// One unconditional call, so a second package claiming one of realtime's codes throws
|
|
@@ -159,6 +179,7 @@ registerErrorCodes(
|
|
|
159
179
|
export {
|
|
160
180
|
CursorStaleError,
|
|
161
181
|
LocalStoreUnavailableError,
|
|
182
|
+
OfflineQueueAbandonedError,
|
|
162
183
|
ProtocolVersionError,
|
|
163
184
|
RealtimeUninstalledError,
|
|
164
185
|
RebaseConflictError,
|
|
@@ -300,17 +321,33 @@ export class TransportProtocolError extends RealtimeError {
|
|
|
300
321
|
}
|
|
301
322
|
|
|
302
323
|
/**
|
|
303
|
-
* A subscribable read projected a row with no `id`. Patches, cursors and the local store all
|
|
304
|
-
* address a row by `id`, so such a row cannot be delivered — and delivering it anyway
|
|
305
|
-
* subscription that looks correct until the first update nobody can apply.
|
|
324
|
+
* A subscribable read projected a row with no usable `id`. Patches, cursors and the local store all
|
|
325
|
+
* address a row by a text `id`, so such a row cannot be delivered — and delivering it anyway
|
|
326
|
+
* produces a subscription that looks correct until the first update nobody can apply.
|
|
327
|
+
*
|
|
328
|
+
* `idType` is set when the row HAS an id that is neither text nor a safe integer (the one numeric
|
|
329
|
+
* shape the change feed and the snapshot both stringify): "select the primary key" is then the
|
|
330
|
+
* wrong instruction, because the key is selected.
|
|
306
331
|
*/
|
|
307
332
|
export class LiveRowUnidentifiedError extends RealtimeError {
|
|
308
|
-
constructor(args: { query: string; keys: readonly string[] }) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
333
|
+
constructor(args: { query: string; keys: readonly string[]; idType?: string }) {
|
|
334
|
+
const columns = args.keys.join(', ') || 'none';
|
|
335
|
+
super(
|
|
336
|
+
args.idType === undefined
|
|
337
|
+
? {
|
|
338
|
+
code: 'X_LIVE_ROW_UNIDENTIFIED',
|
|
339
|
+
cause: `live query "${args.query}" returned a row with no id (columns: ${columns})`,
|
|
340
|
+
fix: `select the primary key in ${args.query}'s sql(), or drop live: true from it`,
|
|
341
|
+
}
|
|
342
|
+
: {
|
|
343
|
+
code: 'X_LIVE_ROW_UNIDENTIFIED',
|
|
344
|
+
cause: `live query "${args.query}" returned a row whose id is a ${args.idType}, not text or a safe integer — a live row is addressed by a text id, so project the key as text (a uuid or text column) or drop live: true from the query`,
|
|
345
|
+
// A name the shell would not read verbatim does not travel: the list names it instead.
|
|
346
|
+
fix: isFixShellSafe(args.query)
|
|
347
|
+
? `x queries describe ${renderFixShellArg(args.query, 'name')} --json`
|
|
348
|
+
: 'x queries list --json',
|
|
349
|
+
},
|
|
350
|
+
);
|
|
314
351
|
}
|
|
315
352
|
}
|
|
316
353
|
|
|
@@ -363,11 +400,21 @@ export class SocketUnauthenticatedError extends RealtimeError {
|
|
|
363
400
|
* the accept budget, so a hostile page costs neither.
|
|
364
401
|
*/
|
|
365
402
|
export class SocketOriginRefusedError extends RealtimeError {
|
|
366
|
-
constructor(args: { reason: string }) {
|
|
403
|
+
constructor(args: { reason: string; asked: string | null; admitted: readonly string[] }) {
|
|
404
|
+
// The asking origin is a request header: it travels only as the origin a URL parser read out
|
|
405
|
+
// of it AND only when the shell screen carries it verbatim, so neither the cause nor the
|
|
406
|
+
// pasted `export` can hold anything the client wrote beyond a scheme, a host and a port.
|
|
407
|
+
const parsed = URL.parse(args.asked ?? '')?.origin;
|
|
408
|
+
const asked = isFixShellSafe(parsed) ? parsed : null;
|
|
409
|
+
const { admitted } = args;
|
|
410
|
+
const list =
|
|
411
|
+
admitted.length === 0
|
|
412
|
+
? 'only the origin it was reached on (no APP_URL declared)'
|
|
413
|
+
: `only ${admitted.join(', ')}`;
|
|
367
414
|
super({
|
|
368
415
|
code: 'X_SOCKET_ORIGIN_REFUSED',
|
|
369
|
-
cause: `the websocket upgrade was refused: ${args.reason}`,
|
|
370
|
-
fix:
|
|
416
|
+
cause: `the websocket upgrade was refused: ${args.reason} — asked from ${asked ?? 'an origin that cannot be rendered'}; this node admits ${list}`,
|
|
417
|
+
fix: `export APP_URL=${asked ?? 'https://www.example.com'} # on the sync role, and only when that is the origin your pages are served on (or createSyncNode({ allowedOrigins }))`,
|
|
371
418
|
});
|
|
372
419
|
}
|
|
373
420
|
}
|
|
@@ -405,13 +452,17 @@ export class WindowReadTimeoutError extends RealtimeError {
|
|
|
405
452
|
}
|
|
406
453
|
}
|
|
407
454
|
|
|
408
|
-
/**
|
|
409
|
-
|
|
410
|
-
|
|
455
|
+
/**
|
|
456
|
+
* A grant's `refresh()` — the app's token service — did not answer inside its deadline. The grant
|
|
457
|
+
* is NOT renewed and NOT revoked: it stays expired and the next re-auth pass asks again. What the
|
|
458
|
+
* deadline buys is the rest of the pass, which one call that never settles used to park forever.
|
|
459
|
+
*/
|
|
460
|
+
export class GrantRefreshTimeoutError extends RealtimeError {
|
|
461
|
+
constructor(args: { socketId: string; afterMs: number }) {
|
|
411
462
|
super({
|
|
412
|
-
code: '
|
|
413
|
-
cause:
|
|
414
|
-
fix:
|
|
463
|
+
code: 'X_TIMEOUT',
|
|
464
|
+
cause: `refresh() for the grant of socket ${args.socketId} did not answer within ${args.afterMs}ms, so the re-auth pass moved on; the grant stays expired and is asked again on the next pass`,
|
|
465
|
+
fix: 'x doctor --json',
|
|
415
466
|
});
|
|
416
467
|
}
|
|
417
468
|
}
|
package/src/fanout.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface Transport {
|
|
|
36
36
|
publish(subject: string, payload: string): Promise<void>;
|
|
37
37
|
subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription>;
|
|
38
38
|
readonly shared: TransportSet;
|
|
39
|
+
/**
|
|
40
|
+
* Told each time the connection behind this transport comes back from a drop. Fanout is
|
|
41
|
+
* at-most-once, so whatever was published in between never arrived — and with no later message
|
|
42
|
+
* there is no sequence number to notice it by. A subscriber that keeps derived state (a `sync`
|
|
43
|
+
* node's windows) invalidates it here. Returns the unsubscribe. A transport with no connection
|
|
44
|
+
* to lose never calls the listener.
|
|
45
|
+
*/
|
|
46
|
+
onReconnect(listener: () => void): () => void;
|
|
39
47
|
close(): Promise<void>;
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -125,6 +133,11 @@ export class InProcessTransport implements Transport {
|
|
|
125
133
|
}
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
/** One process, no connection: there is nothing to come back from, so nothing is ever said. */
|
|
137
|
+
onReconnect(): () => void {
|
|
138
|
+
return () => undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
|
|
129
142
|
this.#assertOpen();
|
|
130
143
|
const handlers = this.#handlers.get(subject) ?? new Set<TransportHandler>();
|