@ultimat3/realtime 24.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.
Files changed (88) hide show
  1. package/CLAUDE.md +61 -63
  2. package/README.md +131 -46
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +10 -5
  5. package/src/boot.ts +14 -9
  6. package/src/changefeed-env.ts +8 -8
  7. package/src/changefeed.ts +42 -18
  8. package/src/channel-authz.ts +7 -7
  9. package/src/channel-describe.ts +1 -1
  10. package/src/channel-logs.ts +16 -25
  11. package/src/channel-presence.ts +3 -3
  12. package/src/channel-records.ts +3 -3
  13. package/src/channel-ref.ts +2 -2
  14. package/src/channel-registry.ts +2 -2
  15. package/src/channel.ts +8 -7
  16. package/src/client-channels.ts +2 -0
  17. package/src/client-frames.ts +9 -2
  18. package/src/client.ts +29 -2
  19. package/src/error-titles.ts +91 -0
  20. package/src/errors.ts +47 -90
  21. package/src/first-paint-hold.ts +49 -0
  22. package/src/idb-types.ts +2 -2
  23. package/src/index.ts +24 -6
  24. package/src/json.ts +16 -3
  25. package/src/live-contract.ts +14 -0
  26. package/src/live-definition.ts +12 -2
  27. package/src/live-deliver.ts +55 -0
  28. package/src/live-fanout.ts +81 -24
  29. package/src/live-query-options.ts +6 -0
  30. package/src/live-query.ts +66 -144
  31. package/src/live-reauth.ts +127 -0
  32. package/src/live-replicator.ts +28 -8
  33. package/src/live-resume.ts +44 -8
  34. package/src/live-rows.ts +32 -3
  35. package/src/live-spend.ts +50 -0
  36. package/src/local-store-idb.ts +5 -0
  37. package/src/matcher-bridge.ts +11 -5
  38. package/src/nats-client.ts +26 -4
  39. package/src/nats-lib-client.ts +36 -7
  40. package/src/nats-subscriptions.ts +63 -0
  41. package/src/nats-transport.ts +127 -15
  42. package/src/offline-queue.ts +5 -0
  43. package/src/page-errors.ts +8 -9
  44. package/src/page-outbox.ts +5 -5
  45. package/src/page-runtime-wait.ts +72 -0
  46. package/src/page-runtime.ts +32 -0
  47. package/src/page-socket.ts +7 -16
  48. package/src/page-store.ts +83 -16
  49. package/src/pg-advisory-lock.ts +9 -4
  50. package/src/pg-connection.ts +7 -1
  51. package/src/pg-replication.ts +6 -6
  52. package/src/pg-socket.ts +20 -3
  53. package/src/pg-values.ts +29 -14
  54. package/src/policy-gate.ts +7 -7
  55. package/src/presence.ts +2 -2
  56. package/src/principal-sockets.ts +75 -0
  57. package/src/query-window.ts +1 -1
  58. package/src/record-publisher.ts +178 -0
  59. package/src/record-store.ts +25 -4
  60. package/src/record-synced.ts +43 -1
  61. package/src/replication-errors.ts +3 -3
  62. package/src/replicator-envelope.ts +61 -11
  63. package/src/replicator-row.ts +1 -1
  64. package/src/replicator.ts +12 -23
  65. package/src/server.ts +36 -18
  66. package/src/socket-defaults.ts +20 -0
  67. package/src/socket.ts +14 -19
  68. package/src/subscription-book.ts +129 -71
  69. package/src/subscription-count.ts +151 -0
  70. package/src/sync-actor-change.ts +56 -0
  71. package/src/sync-bus-handlers.ts +131 -0
  72. package/src/sync-frames.ts +1 -1
  73. package/src/sync-node-bounds.ts +1 -1
  74. package/src/sync-node-contract.ts +16 -2
  75. package/src/sync-node.ts +115 -109
  76. package/src/sync-origin.ts +1 -1
  77. package/src/sync-protocol.ts +73 -13
  78. package/src/sync-upgrade.ts +39 -0
  79. package/src/type-pins.ts +9 -1
  80. package/src/use-channel.ts +19 -14
  81. package/src/use-connection.ts +3 -3
  82. package/src/use-mutation.ts +9 -16
  83. package/src/use-query.ts +11 -11
  84. package/src/use-record.ts +89 -10
  85. package/src/wire-channel.ts +5 -5
  86. package/src/wire-read.ts +2 -2
  87. package/src/idb-fake.ts +0 -133
  88. 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
- uuid,
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, TopicForbiddenError } from './errors';
19
+ import { isClientFault } from './errors';
20
20
  import type { TransportSubscription } from './fanout';
21
- import { refuseSubscription } from './live-refusal';
21
+ import { PrincipalSockets } from './principal-sockets';
22
22
  import { CHANGE_SUBJECT_ALL } from './replicator';
23
- import { parseEnvelope, SeqGapDetector } from './replicator-envelope';
24
- import { CLOSE, DEFAULT_MAX_BUFFERED_BYTES, SocketRegistry, SyncSocket } from './socket';
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 { ackRefOf, createFrameRouter } from './sync-frames';
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 createSyncNode(options: SyncNodeOptions): SyncNode {
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: async (socketId, actor) => {
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 = createFrameRouter({
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
- async start(): Promise<void> {
257
- changes = await options.transport.subscribe(CHANGE_SUBJECT_ALL, (payload) => {
258
- const envelope = parseEnvelope(payload);
259
- if (!envelope) return;
260
- // Fanout is at-most-once over core NATS, so a reconnect is changes this node never saw.
261
- // Nothing downstream could notice: no window's lsn moved, so no cursor moved, so nothing
262
- // ever asked for a re-snapshot. A gap invalidates every window here instead, and the
263
- // subscribers are re-served on the next change to each query.
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
- // One pass per heartbeat window: a member is swept only once it has actually missed its
293
- // window, and the interval never holds the process open — shutdown is the drain's job.
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
- ready = false;
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: () => uuid(),
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) => grants.delete(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
- ready = false;
450
+ await halt();
445
451
  const ids = [...sockets.all()].map((socket) => socket.id);
446
452
  const spread = drainPlan(ids, {
447
453
  spreadMs: bounds.drainSpreadMs,
@@ -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 createSyncNode({ allowedOrigins })',
36
+ listName: 'APP_URL or syncNode({ allowedOrigins })',
37
37
  });
38
38
  }
39
39
 
@@ -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 { renderThrowable, stringField } from '@ultimat3/core/page';
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
- isJsonObject,
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 (!isJsonObject(parsed)) throw fail('frame is not an object');
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 scoped = entity === null ? base : { ...base, entity };
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
- return {
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
- return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
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 (!isJsonObject(value)) throw fail('cursor must be an object');
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 (!isJsonObject(value)) throw fail('patch must be an object');
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 (!isJsonObject(value)) throw fail('subscribe.target must be an object');
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 (!isJsonObject(value)) throw fail('ack.error must be an object or null');
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
- return value['docs'] === undefined ? base : { ...base, docs: str(value, 'docs') };
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
  }
@@ -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.