@ultimat3/realtime 24.0.0 → 25.1.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- 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 +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- 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 +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- 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 +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- 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-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- 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 +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/sync-node.ts
CHANGED
|
@@ -10,21 +10,24 @@ import {
|
|
|
10
10
|
markReady,
|
|
11
11
|
reportError,
|
|
12
12
|
systemClock,
|
|
13
|
-
|
|
13
|
+
uuidV7,
|
|
14
14
|
} from '@ultimat3/core';
|
|
15
15
|
import type { Topic } from './channel';
|
|
16
16
|
import { ChannelSids } from './channel-sids';
|
|
17
17
|
import { detach } from './detach';
|
|
18
18
|
import { evictInChunks } from './drain-evictions';
|
|
19
|
-
import { isClientFault
|
|
19
|
+
import { isClientFault } from './errors';
|
|
20
20
|
import type { TransportSubscription } from './fanout';
|
|
21
|
-
import {
|
|
21
|
+
import { PrincipalSockets } from './principal-sockets';
|
|
22
22
|
import { CHANGE_SUBJECT_ALL } from './replicator';
|
|
23
|
-
import {
|
|
24
|
-
import { CLOSE,
|
|
23
|
+
import { SeqGapDetector } from './replicator-envelope';
|
|
24
|
+
import { CLOSE, SocketRegistry, SyncSocket } from './socket';
|
|
25
|
+
import { DEFAULT_MAX_BUFFERED_BYTES } from './socket-defaults';
|
|
25
26
|
import { idleSweepPeriodMs } from './socket-idle';
|
|
27
|
+
import { actorChangeHandler } from './sync-actor-change';
|
|
26
28
|
import { GrantBook, sweepGrants } from './sync-auth';
|
|
27
|
-
import {
|
|
29
|
+
import { changeHandler, ProducerKinds, reconnectHandler } from './sync-bus-handlers';
|
|
30
|
+
import { ackRefOf, frameRouter } from './sync-frames';
|
|
28
31
|
import {
|
|
29
32
|
clientHeartbeatMs,
|
|
30
33
|
drainGraceMs,
|
|
@@ -49,7 +52,7 @@ export type { SyncNode, SyncNodeOptions, SyncWs } from './sync-node-contract';
|
|
|
49
52
|
/** Declared with the upgrade that builds it — this file only ever reads one. */
|
|
50
53
|
export type { UpgradeTarget, WsData } from './sync-upgrade';
|
|
51
54
|
|
|
52
|
-
export function
|
|
55
|
+
export function syncNode(options: SyncNodeOptions): SyncNode {
|
|
53
56
|
const sockets =
|
|
54
57
|
options.sockets ??
|
|
55
58
|
new SocketRegistry({
|
|
@@ -66,12 +69,22 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
66
69
|
const path = options.path ?? '/_x/sync';
|
|
67
70
|
const presence = options.presence;
|
|
68
71
|
const grants = new GrantBook();
|
|
72
|
+
const principals = new PrincipalSockets(options.maxSocketsPerActor);
|
|
69
73
|
const gaps = new SeqGapDetector();
|
|
74
|
+
// Kept across restarts of the bus subscription: a table's producer kind is a fact of the fleet.
|
|
75
|
+
const producers = new ProducerKinds();
|
|
70
76
|
const channelSids = new ChannelSids();
|
|
71
77
|
let reconnects: (() => void) | null = null;
|
|
72
78
|
/** The re-auth pass in flight, shared by every tick that lands while it runs. */
|
|
73
79
|
let reauthPass: Promise<void> | null = null;
|
|
74
80
|
let ready = false;
|
|
81
|
+
/**
|
|
82
|
+
* The start in flight or done, and whether a stop/drain has run since. `start()` awaits the bus,
|
|
83
|
+
* and a stop inside that await found nothing to release: the subscription landed after it and
|
|
84
|
+
* the node came up `ready` and delivering after it was stopped. The `replicator.ts` pattern.
|
|
85
|
+
*/
|
|
86
|
+
let started: Promise<void> | null = null;
|
|
87
|
+
let generation = 0;
|
|
75
88
|
/** Resolved by `teardown` when the last socket leaves, for a drain that is waiting its grace. */
|
|
76
89
|
let lastSocketLeft: (() => void) | null = null;
|
|
77
90
|
let changes: TransportSubscription | null = null;
|
|
@@ -119,6 +132,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
119
132
|
for (const name of topics) options.hub.unsubscribe(socket, name);
|
|
120
133
|
sockets.remove(socket.id);
|
|
121
134
|
grants.delete(socket.id);
|
|
135
|
+
principals.release(socket.id);
|
|
122
136
|
if (sockets.count === 0) lastSocketLeft?.();
|
|
123
137
|
// A closed socket is a leave, said now rather than left to TTL: everyone else would otherwise
|
|
124
138
|
// keep rendering a member who is provably gone for the rest of its window. The write is on the
|
|
@@ -182,39 +196,20 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
182
196
|
return reauthPass;
|
|
183
197
|
};
|
|
184
198
|
|
|
199
|
+
const onActor = actorChangeHandler({
|
|
200
|
+
sockets,
|
|
201
|
+
hub: options.hub,
|
|
202
|
+
registry: options.registry,
|
|
203
|
+
channelSids,
|
|
204
|
+
presence,
|
|
205
|
+
});
|
|
206
|
+
|
|
185
207
|
const reauthPassOnce = async (): Promise<void> => {
|
|
186
208
|
await sweepGrants({
|
|
187
209
|
grants,
|
|
188
210
|
clock,
|
|
189
211
|
refreshDeadlineMs: options.grantRefreshDeadlineMs,
|
|
190
|
-
onActor
|
|
191
|
-
const socket = sockets.get(socketId);
|
|
192
|
-
if (!socket) return;
|
|
193
|
-
const rooms = new Set(options.hub.topicsOf(socket).filter(hasRoster));
|
|
194
|
-
// The hub sets `socket.actor` and drops the topics this actor may no longer read; the
|
|
195
|
-
// registry re-decides every live subscription (refusing each one it drops under its sid)
|
|
196
|
-
// and desyncs the survivors, so the next delivery re-snapshots them under the new
|
|
197
|
-
// authority rather than the old window.
|
|
198
|
-
for (const name of await options.hub.onActorChange(socket, actor)) {
|
|
199
|
-
// A dropped seat is SAID: unsaid, the client kept rendering the channel as live.
|
|
200
|
-
const sid = channelSids.sidOf(socket, name);
|
|
201
|
-
channelSids.delete(socket, name);
|
|
202
|
-
if (sid !== undefined) {
|
|
203
|
-
refuseSubscription(
|
|
204
|
-
socket,
|
|
205
|
-
sid,
|
|
206
|
-
new TopicForbiddenError({
|
|
207
|
-
topic: name,
|
|
208
|
-
actorId: socket.actorId,
|
|
209
|
-
reason: 'the session changed and the channel policy no longer admits it',
|
|
210
|
-
}),
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
if (presence && rooms.has(name))
|
|
214
|
-
detach(presence.leave(name, socket.id), 'presence.leave', name);
|
|
215
|
-
}
|
|
216
|
-
await options.registry.reauthorize(socket);
|
|
217
|
-
},
|
|
212
|
+
onActor,
|
|
218
213
|
onRevoked: (socketId) => {
|
|
219
214
|
const socket = sockets.get(socketId);
|
|
220
215
|
if (!socket) return;
|
|
@@ -236,7 +231,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
236
231
|
});
|
|
237
232
|
};
|
|
238
233
|
|
|
239
|
-
const routeFrame =
|
|
234
|
+
const routeFrame = frameRouter({
|
|
240
235
|
hub: options.hub,
|
|
241
236
|
registry: options.registry,
|
|
242
237
|
buildId: options.buildId,
|
|
@@ -245,84 +240,83 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
245
240
|
heartbeatMs: clientHeartbeatMs(presence?.heartbeatMs, sockets.idleTimeoutMs),
|
|
246
241
|
});
|
|
247
242
|
|
|
243
|
+
/** Everything `start()` does, fenced on the generation it began under — see `started`. */
|
|
244
|
+
const begin = async (): Promise<void> => {
|
|
245
|
+
const run = generation;
|
|
246
|
+
const bus = { registry: options.registry, hub: options.hub, gaps, producers };
|
|
247
|
+
const subscription = await options.transport.subscribe(CHANGE_SUBJECT_ALL, changeHandler(bus));
|
|
248
|
+
// A stop or drain ran inside that await: what it released did not include this yet.
|
|
249
|
+
if (run !== generation) {
|
|
250
|
+
subscription.unsubscribe();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
changes = subscription;
|
|
254
|
+
reconnects = options.transport.onReconnect(reconnectHandler(bus));
|
|
255
|
+
// One pass per heartbeat window: a member is swept only once it has actually missed its
|
|
256
|
+
// window, and the interval never holds the process open — shutdown is the drain's job.
|
|
257
|
+
if (presence) {
|
|
258
|
+
sweeping = setInterval(
|
|
259
|
+
() => detach(presence.sweepAll(), 'presence.sweep'),
|
|
260
|
+
presence.heartbeatMs,
|
|
261
|
+
);
|
|
262
|
+
sweeping.unref();
|
|
263
|
+
}
|
|
264
|
+
// The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
|
|
265
|
+
// whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
|
|
266
|
+
// topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
|
|
267
|
+
// 120s budget under it decided nothing.
|
|
268
|
+
idling = setInterval(() => {
|
|
269
|
+
for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
|
|
270
|
+
}, idleSweepPeriodMs(sockets.idleTimeoutMs));
|
|
271
|
+
idling.unref();
|
|
272
|
+
if (options.authenticate) {
|
|
273
|
+
reauthing = setInterval(
|
|
274
|
+
() => detach(reauthenticate(), 'sync.reauthenticate'),
|
|
275
|
+
bounds.reauthenticateIntervalMs,
|
|
276
|
+
);
|
|
277
|
+
reauthing.unref();
|
|
278
|
+
} else {
|
|
279
|
+
// Enforced where it can be: nothing here can invent a credential, so the one honest signal
|
|
280
|
+
// is that every policy on this node is about to be asked about `null`.
|
|
281
|
+
logger.warn('sync node has no authenticator: every socket is anonymous', {
|
|
282
|
+
buildId: options.buildId,
|
|
283
|
+
fix: 'pass authenticate to syncNode({ authenticate })',
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
ready = true;
|
|
287
|
+
markReady();
|
|
288
|
+
logger.info('sync node ready', { buildId: options.buildId, path });
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Ends the current generation and waits out a start in flight, so nothing it took outlives us. */
|
|
292
|
+
const halt = async (): Promise<void> => {
|
|
293
|
+
generation += 1;
|
|
294
|
+
ready = false;
|
|
295
|
+
const inFlight = started;
|
|
296
|
+
started = null;
|
|
297
|
+
if (inFlight !== null) await inFlight.catch(() => undefined);
|
|
298
|
+
};
|
|
299
|
+
|
|
248
300
|
return {
|
|
249
301
|
sockets,
|
|
250
302
|
path,
|
|
303
|
+
principalSockets: (principal) => principals.count(principal),
|
|
251
304
|
|
|
252
305
|
get ready(): boolean {
|
|
253
306
|
return ready;
|
|
254
307
|
},
|
|
255
308
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (gaps.observe(envelope)) {
|
|
265
|
-
const marked = options.registry.invalidate();
|
|
266
|
-
// Channels too: their seq is minted from what this node sees, so the hole is invisible
|
|
267
|
-
// to a ring that would otherwise replay across it as complete history.
|
|
268
|
-
const channelGaps = options.hub.invalidate();
|
|
269
|
-
logger.warn('live.change_gap', {
|
|
270
|
-
entity: envelope.change.entity,
|
|
271
|
-
desynced: marked,
|
|
272
|
-
channelGaps,
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
// Not awaited: the bus handler must return before the next change, and ordering is the
|
|
276
|
-
// registry's — one serial lane per query id. What this call site owes is the failure. An
|
|
277
|
-
// unhandled rejection here is a fanout that reached nobody, reported as a dead process.
|
|
278
|
-
detach(options.registry.deliver(envelope.change), 'live.deliver', envelope.change.entity);
|
|
279
|
-
// The same stream feeds the declared channels: a write names no channel (axiom 2), and the
|
|
280
|
-
// hub turns this change into `records` frames on every channel it touches.
|
|
281
|
-
options.hub.deliverChange(envelope.change);
|
|
282
|
-
});
|
|
283
|
-
// A bus that reconnected is changes this node never saw, and the gap detector only notices
|
|
284
|
-
// on the NEXT message: with no later write, every window and cursor here stayed behind and
|
|
285
|
-
// every new subscriber joined the stale window. Said by the transport, repaired now.
|
|
286
|
-
reconnects = options.transport.onReconnect(() => {
|
|
287
|
-
gaps.forget();
|
|
288
|
-
const marked = options.registry.invalidate();
|
|
289
|
-
const channelGaps = options.hub.invalidate();
|
|
290
|
-
logger.warn('live.bus_reconnected', { desynced: marked, channelGaps });
|
|
309
|
+
start(): Promise<void> {
|
|
310
|
+
// Memoised: two overlapping starts were two subscriptions, every change fanned out twice.
|
|
311
|
+
if (started !== null) return started;
|
|
312
|
+
// Cleared only if it is still THIS attempt's memo: an older start failing after a stop and
|
|
313
|
+
// a newer start must not wipe the newer one, or a third start subscribes a second time.
|
|
314
|
+
const attempt: Promise<void> = begin().catch((error: unknown) => {
|
|
315
|
+
if (started === attempt) started = null;
|
|
316
|
+
throw error;
|
|
291
317
|
});
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (presence) {
|
|
295
|
-
sweeping = setInterval(
|
|
296
|
-
() => detach(presence.sweepAll(), 'presence.sweep'),
|
|
297
|
-
presence.heartbeatMs,
|
|
298
|
-
);
|
|
299
|
-
sweeping.unref();
|
|
300
|
-
}
|
|
301
|
-
// The half-open connection Bun's own `idleTimeout` renews through its ping/pong: a client
|
|
302
|
-
// whose frame loop is wedged answers pings and keeps its grant, its subscriptions and its
|
|
303
|
-
// topic membership. `sweepIdle` was written for this and never called, so `touch()` and the
|
|
304
|
-
// 120s budget under it decided nothing.
|
|
305
|
-
idling = setInterval(() => {
|
|
306
|
-
for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
|
|
307
|
-
}, idleSweepPeriodMs(sockets.idleTimeoutMs));
|
|
308
|
-
idling.unref();
|
|
309
|
-
if (options.authenticate) {
|
|
310
|
-
reauthing = setInterval(
|
|
311
|
-
() => detach(reauthenticate(), 'sync.reauthenticate'),
|
|
312
|
-
bounds.reauthenticateIntervalMs,
|
|
313
|
-
);
|
|
314
|
-
reauthing.unref();
|
|
315
|
-
} else {
|
|
316
|
-
// Enforced where it can be: nothing here can invent a credential, so the one honest signal
|
|
317
|
-
// is that every policy on this node is about to be asked about `null`.
|
|
318
|
-
logger.warn('sync node has no authenticator: every socket is anonymous', {
|
|
319
|
-
buildId: options.buildId,
|
|
320
|
-
fix: 'pass authenticate to createSyncNode({ authenticate })',
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
ready = true;
|
|
324
|
-
markReady();
|
|
325
|
-
logger.info('sync node ready', { buildId: options.buildId, path });
|
|
318
|
+
started = attempt;
|
|
319
|
+
return attempt;
|
|
326
320
|
},
|
|
327
321
|
|
|
328
322
|
stopAccepting(): void {
|
|
@@ -330,8 +324,13 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
330
324
|
},
|
|
331
325
|
|
|
332
326
|
async stop(): Promise<void> {
|
|
333
|
-
|
|
327
|
+
// `halt()` ends the generation synchronously, before its first await; what the current
|
|
328
|
+
// start installed is released right then. An older start still in flight is fenced by its
|
|
329
|
+
// generation and drops what it subscribes late itself, and a start() issued after this line
|
|
330
|
+
// owns a fresh bus that the wait below must not touch.
|
|
331
|
+
const waiting = halt();
|
|
334
332
|
release();
|
|
333
|
+
await waiting;
|
|
335
334
|
},
|
|
336
335
|
|
|
337
336
|
async fetch(request: Request, server: UpgradeTarget): Promise<Response | undefined> {
|
|
@@ -346,15 +345,20 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
346
345
|
// is parked inside `authenticate`, which is the whole reason they are functions.
|
|
347
346
|
ready: () => ready,
|
|
348
347
|
socketCount: () => sockets.count,
|
|
349
|
-
newSocketId: () =>
|
|
348
|
+
newSocketId: () => uuidV7(),
|
|
350
349
|
authenticate: options.authenticate,
|
|
351
350
|
allowedOrigins: options.allowedOrigins,
|
|
352
351
|
admitReachedOrigin: options.admitReachedOrigin,
|
|
352
|
+
clientAddressOf: options.clientAddressOf,
|
|
353
353
|
healthDetailPeers: options.healthDetailPeers ?? DEFAULT_HEALTH_DETAIL_PEERS,
|
|
354
354
|
onGranted: (socketId, grant) => grants.set(socketId, grant),
|
|
355
355
|
// The other half of recording the grant before the upgrade: an upgrade that never took
|
|
356
356
|
// gets no `close` callback, so this is the only thing that can free its entry.
|
|
357
|
-
onUngranted: (socketId) =>
|
|
357
|
+
onUngranted: (socketId) => {
|
|
358
|
+
grants.delete(socketId);
|
|
359
|
+
principals.release(socketId);
|
|
360
|
+
},
|
|
361
|
+
principals,
|
|
358
362
|
},
|
|
359
363
|
request,
|
|
360
364
|
server,
|
|
@@ -384,6 +388,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
384
388
|
clientBuildId: ws.data.clientBuildId,
|
|
385
389
|
serverBuildId: options.buildId,
|
|
386
390
|
actor: grants.get(ws.data.socketId)?.actor ?? null,
|
|
391
|
+
clientAddress: ws.data.clientAddress ?? null,
|
|
387
392
|
clock,
|
|
388
393
|
...ceilings,
|
|
389
394
|
});
|
|
@@ -434,6 +439,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
434
439
|
// The socket is already gone, but a grant recorded for an upgrade whose `open` never ran
|
|
435
440
|
// is not — and nothing else would ever reach it.
|
|
436
441
|
grants.delete(ws.data.socketId);
|
|
442
|
+
principals.release(ws.data.socketId);
|
|
437
443
|
return;
|
|
438
444
|
}
|
|
439
445
|
teardown(socket);
|
|
@@ -441,7 +447,7 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
441
447
|
},
|
|
442
448
|
|
|
443
449
|
async drain(drainOptions = {}): Promise<readonly DrainedSocket[]> {
|
|
444
|
-
|
|
450
|
+
await halt();
|
|
445
451
|
const ids = [...sockets.all()].map((socket) => socket.id);
|
|
446
452
|
const spread = drainPlan(ids, {
|
|
447
453
|
spreadMs: bounds.drainSpreadMs,
|
package/src/sync-origin.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function upgradeOrigin(
|
|
|
33
33
|
origin,
|
|
34
34
|
secFetchSite: request.headers.get('sec-fetch-site'),
|
|
35
35
|
listed: (asked) => admitted.includes(asked),
|
|
36
|
-
listName: 'APP_URL or
|
|
36
|
+
listName: 'APP_URL or syncNode({ allowedOrigins })',
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
package/src/sync-protocol.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// patches, presence and refusals come down. A client write is HTTP (`useMutation`), never a frame —
|
|
3
3
|
// so there is one write path, with the action's authz, idempotency store and contract behind it.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isWriteDigest,
|
|
7
|
+
renderThrowable,
|
|
8
|
+
stringField,
|
|
9
|
+
WRITE_DIGEST_LENGTH,
|
|
10
|
+
} from '@ultimat3/core/page';
|
|
6
11
|
import type {
|
|
7
12
|
ChannelEventsFrame,
|
|
8
13
|
ChannelRecordsFrame,
|
|
@@ -11,7 +16,7 @@ import type {
|
|
|
11
16
|
} from './channel-wire';
|
|
12
17
|
import type { LiveCursor } from './cursor';
|
|
13
18
|
import {
|
|
14
|
-
|
|
19
|
+
isParsedJsonObject,
|
|
15
20
|
isRow,
|
|
16
21
|
type JsonObject,
|
|
17
22
|
type JsonValue,
|
|
@@ -37,6 +42,8 @@ export interface WireError {
|
|
|
37
42
|
readonly cause: string;
|
|
38
43
|
readonly fix: string;
|
|
39
44
|
readonly docs?: string;
|
|
45
|
+
/** When a retry can succeed — a rate-limited subscribe's refill. Whole seconds, positive. */
|
|
46
|
+
readonly retryAfterSeconds?: number;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export interface PresenceMember {
|
|
@@ -116,6 +123,12 @@ export interface SnapshotFrame {
|
|
|
116
123
|
* derives a key.
|
|
117
124
|
*/
|
|
118
125
|
readonly keys?: readonly string[];
|
|
126
|
+
/**
|
|
127
|
+
* The writes this server truth already holds, named as `PatchFrame.writes` names them — only
|
|
128
|
+
* writes behind a row in `rows`, so never one this subscriber may not see. Optional and additive:
|
|
129
|
+
* no `PROTOCOL_VERSION` bump, for the reason `entity` needs none.
|
|
130
|
+
*/
|
|
131
|
+
readonly writes?: readonly string[];
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
export interface PatchFrame {
|
|
@@ -124,6 +137,15 @@ export interface PatchFrame {
|
|
|
124
137
|
readonly sid: string;
|
|
125
138
|
readonly patches: readonly RowPatch[];
|
|
126
139
|
readonly lsn: string;
|
|
140
|
+
/**
|
|
141
|
+
* The writes that made these patches: `writeDigest` of each keyed request's idempotency key
|
|
142
|
+
* (`@ultimat3/core`), never the key, as `ChannelRecordsFrame.write` names one. A live patch names
|
|
143
|
+
* at most one; a delta resume names every write behind a patch it replays. Absent when no keyed
|
|
144
|
+
* request made any of them. The page that holds one pending settles it in the frame's own batch.
|
|
145
|
+
* Additive and optional: a decoder one deploy behind drops it, and a frame without it is read as
|
|
146
|
+
* it always was — so no `PROTOCOL_VERSION` bump.
|
|
147
|
+
*/
|
|
148
|
+
readonly writes?: readonly string[];
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
export interface AckFrame {
|
|
@@ -192,7 +214,7 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
192
214
|
} catch {
|
|
193
215
|
throw fail('frame is not JSON');
|
|
194
216
|
}
|
|
195
|
-
if (!
|
|
217
|
+
if (!isParsedJsonObject(parsed)) throw fail('frame is not an object');
|
|
196
218
|
const version = parsed['v'];
|
|
197
219
|
if (version !== PROTOCOL_VERSION) {
|
|
198
220
|
throw new ProtocolVersionError({ got: version, expected: PROTOCOL_VERSION });
|
|
@@ -228,7 +250,9 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
228
250
|
cursor: cursor(parsed['cursor']),
|
|
229
251
|
} as const;
|
|
230
252
|
const entity = nullableStr(parsed, 'entity');
|
|
231
|
-
const
|
|
253
|
+
const writes = writesOf(parsed, 'snapshot.writes');
|
|
254
|
+
const named = writes.length === 0 ? base : { ...base, writes };
|
|
255
|
+
const scoped = entity === null ? named : { ...named, entity };
|
|
232
256
|
if (parsed['keys'] === undefined) return scoped;
|
|
233
257
|
const keys = list(parsed, 'keys', FRAME_LIMITS.rows).map((key) => {
|
|
234
258
|
if (typeof key !== 'string') throw fail('snapshot.keys must hold strings');
|
|
@@ -237,14 +261,17 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
237
261
|
if (keys.length !== base.rows.length) throw fail('snapshot.keys must pair with rows');
|
|
238
262
|
return { ...scoped, keys };
|
|
239
263
|
}
|
|
240
|
-
case 'patch':
|
|
241
|
-
|
|
264
|
+
case 'patch': {
|
|
265
|
+
const base = {
|
|
242
266
|
type: 'patch',
|
|
243
267
|
v: PROTOCOL_VERSION,
|
|
244
268
|
sid: str(parsed, 'sid'),
|
|
245
269
|
patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
|
|
246
270
|
lsn: str(parsed, 'lsn'),
|
|
247
|
-
};
|
|
271
|
+
} as const;
|
|
272
|
+
const writes = writesOf(parsed, 'patch.writes');
|
|
273
|
+
return writes.length === 0 ? base : { ...base, writes };
|
|
274
|
+
}
|
|
248
275
|
case 'ack':
|
|
249
276
|
return {
|
|
250
277
|
type: 'ack',
|
|
@@ -285,7 +312,37 @@ export function toWireError(error: unknown): WireError {
|
|
|
285
312
|
const cause = stringField(error, 'cause') ?? renderThrowable(error);
|
|
286
313
|
const fix = stringField(error, 'fix') ?? 'x doctor realtime';
|
|
287
314
|
const docs = stringField(error, 'docs');
|
|
288
|
-
|
|
315
|
+
const base = docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
316
|
+
const retryAfterSeconds = retryHintOf(error);
|
|
317
|
+
return retryAfterSeconds === undefined ? base : { ...base, retryAfterSeconds };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `meta.retryAfterSeconds`, the field `@ultimat3/http`'s `X_RATE_LIMITED` carries — read as totally
|
|
322
|
+
* as `stringField` reads the rest, because the throwable is app code's and its getters may throw.
|
|
323
|
+
*/
|
|
324
|
+
function retryHintOf(error: unknown): number | undefined {
|
|
325
|
+
try {
|
|
326
|
+
const meta: unknown = (error as { readonly meta?: unknown } | null)?.meta;
|
|
327
|
+
const held: unknown = isParsedJsonObject(meta) ? meta['retryAfterSeconds'] : undefined;
|
|
328
|
+
return typeof held === 'number' && Number.isFinite(held) && held > 0 ? held : undefined;
|
|
329
|
+
} catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `writes` on a `patch` or `snapshot`: digests only, capped at the patch ceiling (one write per
|
|
336
|
+
* patch at most; `snapshotFrame` caps what it sends to the same number). Absent, `null` and `[]`
|
|
337
|
+
* all name nothing, and read as the frame an older node sends.
|
|
338
|
+
*/
|
|
339
|
+
function writesOf(parsed: JsonObject, label: string): readonly string[] {
|
|
340
|
+
return list(parsed, 'writes', FRAME_LIMITS.patches, label).map((write) => {
|
|
341
|
+
if (!isWriteDigest(write)) {
|
|
342
|
+
throw fail(`${label} must hold ${WRITE_DIGEST_LENGTH}-character lowercase hex digests`);
|
|
343
|
+
}
|
|
344
|
+
return write;
|
|
345
|
+
});
|
|
289
346
|
}
|
|
290
347
|
|
|
291
348
|
function row(value: unknown): Row {
|
|
@@ -294,7 +351,7 @@ function row(value: unknown): Row {
|
|
|
294
351
|
}
|
|
295
352
|
|
|
296
353
|
function cursor(value: unknown): LiveCursor {
|
|
297
|
-
if (!
|
|
354
|
+
if (!isParsedJsonObject(value)) throw fail('cursor must be an object');
|
|
298
355
|
return {
|
|
299
356
|
qid: str(value, 'qid'),
|
|
300
357
|
lsn: str(value, 'lsn'),
|
|
@@ -307,7 +364,7 @@ function cursor(value: unknown): LiveCursor {
|
|
|
307
364
|
}
|
|
308
365
|
|
|
309
366
|
function patch(value: unknown): RowPatch {
|
|
310
|
-
if (!
|
|
367
|
+
if (!isParsedJsonObject(value)) throw fail('patch must be an object');
|
|
311
368
|
const base = {
|
|
312
369
|
op: pick(value, 'op', ['insert', 'update', 'delete'] as const),
|
|
313
370
|
id: str(value, 'id'),
|
|
@@ -320,7 +377,7 @@ function patch(value: unknown): RowPatch {
|
|
|
320
377
|
}
|
|
321
378
|
|
|
322
379
|
function target(value: unknown): SubscribeTarget {
|
|
323
|
-
if (!
|
|
380
|
+
if (!isParsedJsonObject(value)) throw fail('subscribe.target must be an object');
|
|
324
381
|
const kind = pick(value, 'kind', ['query', 'channel'] as const);
|
|
325
382
|
if (kind === 'channel') return channelTarget(value);
|
|
326
383
|
return {
|
|
@@ -334,7 +391,10 @@ function target(value: unknown): SubscribeTarget {
|
|
|
334
391
|
|
|
335
392
|
function wireError(value: unknown): WireError | null {
|
|
336
393
|
if (value === null || value === undefined) return null;
|
|
337
|
-
if (!
|
|
394
|
+
if (!isParsedJsonObject(value)) throw fail('ack.error must be an object or null');
|
|
338
395
|
const base = { code: str(value, 'code'), cause: str(value, 'cause'), fix: str(value, 'fix') };
|
|
339
|
-
|
|
396
|
+
const documented = value['docs'] === undefined ? base : { ...base, docs: str(value, 'docs') };
|
|
397
|
+
return value['retryAfterSeconds'] === undefined
|
|
398
|
+
? documented
|
|
399
|
+
: { ...documented, retryAfterSeconds: num(value, 'retryAfterSeconds') };
|
|
340
400
|
}
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -12,9 +12,12 @@ import {
|
|
|
12
12
|
} from '@ultimat3/core';
|
|
13
13
|
import {
|
|
14
14
|
SocketAuthUnavailableError,
|
|
15
|
+
SocketLimitError,
|
|
15
16
|
SocketOriginRefusedError,
|
|
16
17
|
SocketUnauthenticatedError,
|
|
17
18
|
} from './errors';
|
|
19
|
+
import type { PrincipalSockets } from './principal-sockets';
|
|
20
|
+
import { principalFor } from './subscription-book';
|
|
18
21
|
import type { SyncAuthenticator, SyncGrant } from './sync-auth';
|
|
19
22
|
import { upgradeOrigin } from './sync-origin';
|
|
20
23
|
import { toWireError } from './sync-protocol';
|
|
@@ -33,6 +36,11 @@ export interface WsData {
|
|
|
33
36
|
* forever for having sent no query.
|
|
34
37
|
*/
|
|
35
38
|
readonly clientBuildId: string;
|
|
39
|
+
/**
|
|
40
|
+
* The caller's address, resolved here once (`clientAddressOf`); `null` when nothing can say.
|
|
41
|
+
* Optional only for a socket a host builds without this upgrade (a test harness).
|
|
42
|
+
*/
|
|
43
|
+
readonly clientAddress?: string | null;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
|
|
@@ -66,6 +74,14 @@ export interface UpgradeDeps {
|
|
|
66
74
|
* (`sync-origin.ts`).
|
|
67
75
|
*/
|
|
68
76
|
readonly allowedOrigins?: readonly string[] | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Who the caller is, for keying an anonymous reader's rate limit: the deployment's own rule —
|
|
79
|
+
* `@ultimat3/http`'s `clientAddress()` under its `TRUSTED_PROXY_HOPS`, injected by the boot so
|
|
80
|
+
* this package reads no proxy header itself. Absent, the socket's address is the caller.
|
|
81
|
+
*/
|
|
82
|
+
readonly clientAddressOf?:
|
|
83
|
+
| ((request: Request, socketAddress: string | null) => string | null)
|
|
84
|
+
| undefined;
|
|
69
85
|
/** The `Host`-derived origin admitted beside a declared list too — `x dev` only. */
|
|
70
86
|
readonly admitReachedOrigin?: boolean | undefined;
|
|
71
87
|
/**
|
|
@@ -89,6 +105,11 @@ export interface UpgradeDeps {
|
|
|
89
105
|
* same "reserve, then release" shape `channel.ts` uses for a topic slot.
|
|
90
106
|
*/
|
|
91
107
|
onUngranted(socketId: string): void;
|
|
108
|
+
/**
|
|
109
|
+
* One principal's sockets on this node, keyed as the live-query principal is. Admitted right
|
|
110
|
+
* before `server.upgrade`; `onUngranted` and the socket's close give the slot back.
|
|
111
|
+
*/
|
|
112
|
+
readonly principals?: PrincipalSockets | undefined;
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
/**
|
|
@@ -173,7 +194,18 @@ export async function handleUpgrade(
|
|
|
173
194
|
socketId: deps.newSocketId(),
|
|
174
195
|
// The node's own id is "not skewed until the hello says so", never "current forever".
|
|
175
196
|
clientBuildId: url.searchParams.get('build') ?? deps.buildId,
|
|
197
|
+
clientAddress: callerAddress(deps, request, server),
|
|
176
198
|
};
|
|
199
|
+
// Beside the count recheck and for its reason: no await between this and `server.upgrade`, so a
|
|
200
|
+
// herd of one principal parked in `authenticate` cannot all pass one count.
|
|
201
|
+
const principal = principalFor(grant?.actor ?? null, data.clientAddress ?? null);
|
|
202
|
+
if (deps.principals !== undefined && !deps.principals.admit(data.socketId, principal)) {
|
|
203
|
+
refund();
|
|
204
|
+
return wireErrorResponse(
|
|
205
|
+
429,
|
|
206
|
+
new SocketLimitError({ principal, limit: deps.principals.limitFor(principal) }),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
177
209
|
// Before the upgrade, never after: `server.upgrade` runs `websocket.open` synchronously and does
|
|
178
210
|
// not return until it has, so a grant recorded on the next line is one the socket was already
|
|
179
211
|
// built without.
|
|
@@ -238,6 +270,13 @@ function health(
|
|
|
238
270
|
});
|
|
239
271
|
}
|
|
240
272
|
|
|
273
|
+
function callerAddress(deps: UpgradeDeps, request: Request, server: UpgradeTarget): string | null {
|
|
274
|
+
const socketAddress = server.requestIP(request)?.address ?? null;
|
|
275
|
+
return deps.clientAddressOf === undefined
|
|
276
|
+
? socketAddress
|
|
277
|
+
: deps.clientAddressOf(request, socketAddress);
|
|
278
|
+
}
|
|
279
|
+
|
|
241
280
|
/** Load shedding with a delay attached: refusing without one just moves the herd next door. */
|
|
242
281
|
function shed(deps: UpgradeDeps): Response {
|
|
243
282
|
return new Response('retry', {
|
package/src/type-pins.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { AsyncState, Row } from '@ultimat3/core';
|
|
7
7
|
import type { LiveHandle, Unsubscribe } from './client-contract';
|
|
8
8
|
import type { QueryAccessor, QueryRef } from './use-query';
|
|
9
|
-
import type { RecordAccessor } from './use-record';
|
|
9
|
+
import type { RecordAccessor, useRecords } from './use-record';
|
|
10
10
|
|
|
11
11
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
12
12
|
type Assert<T extends true> = T;
|
|
@@ -31,6 +31,14 @@ export type _RecordAnswersAsyncState = Assert<
|
|
|
31
31
|
Equals<ReturnType<RecordAccessor<FeedRow>>, AsyncState<FeedRow | undefined>>
|
|
32
32
|
>;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* `useRecords` takes its selection EXPLICITLY — a key list, or `{}` / `{ where, order }` for the
|
|
36
|
+
* whole type. A default turned a call that forgot its keys into "every record of the type".
|
|
37
|
+
*/
|
|
38
|
+
export type _UseRecordsSelectionRequired = Assert<
|
|
39
|
+
Equals<Parameters<typeof useRecords>['length'], 2>
|
|
40
|
+
>;
|
|
41
|
+
|
|
34
42
|
/**
|
|
35
43
|
* A query ref carries no server field: a `Query` VALUE in an island drags its whole read path into
|
|
36
44
|
* the bundle, so the ref is a name and two declared facts. A `sql` key is refused.
|