@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.
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
@@ -3,7 +3,7 @@
3
3
  // registry owns the lanes and the entry table while this owns what happens inside one of them —
4
4
  // and because one file runs one job. The lane itself is never taken here: the caller is holding it.
5
5
 
6
- import type { Clock } from '@ultimat3/core';
6
+ import { type Clock, isWriteDigest } from '@ultimat3/core';
7
7
  import type { ChangeEvent } from './changefeed';
8
8
  import { advance, type LiveCursor, makeCursor, type ResumeSource } from './cursor';
9
9
  import type { Row, RowPatch } from './json';
@@ -12,6 +12,7 @@ import { applyToWindow, bridgeChange, type Projection, projectionOf } from './ma
12
12
  import { type QueryEntry, refillWindowInLane } from './query-window';
13
13
  import { type Subscriber, type SubscriberGate, windowIndex } from './subscriber-gate';
14
14
  import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
15
+ import { FRAME_LIMITS } from './wire-version';
15
16
 
16
17
  export interface FanoutDeps {
17
18
  readonly gate: SubscriberGate;
@@ -47,6 +48,12 @@ export async function fanoutChange(
47
48
  return { sent: 0, stale: 0 };
48
49
  }
49
50
  if (change.op === 'truncate') return await truncated(deps, entry, change);
51
+ // Screened, not trusted: a frame carrying anything but a digest is refused WHOLE by the decoder,
52
+ // so a malformed label would cost every subscriber this change rather than one page its echo.
53
+ const write = isWriteDigest(change.write) ? change.write : null;
54
+ const writes = write === null ? [] : [write];
55
+ // What a re-snapshot out of rows that hold this change may name: the write, by the row it made.
56
+ const named = namedBy(entry, change, write);
50
57
  const reread = entry.stale;
51
58
  if (reread) {
52
59
  await refillWindowInLane(entry);
@@ -62,7 +69,7 @@ export async function fanoutChange(
62
69
  // there is nothing to patch. Returning before the loop below was the defect: the guard after
63
70
  // this refused the change as stale and every desynced subscriber went without the change AND
64
71
  // without its snapshot — a run console frozen on its first event after one `updateWhere`.
65
- if (change.lsn <= entry.lsn) return { sent: await resnapshotAll(deps, entry), stale: 0 };
72
+ if (change.lsn <= entry.lsn) return { sent: await resnapshotAll(deps, entry, named), stale: 0 };
66
73
  }
67
74
  // The consume-side twin of the replicator's own duplicate guard, which had none. `entry.lsn =
68
75
  // change.lsn` was unconditional, so a change the window already holds — a redelivery, or one
@@ -70,7 +77,8 @@ export async function fanoutChange(
70
77
  // to it and asked them to fold state they had already folded over newer rows.
71
78
  if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
72
79
  const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
73
- // Matching nothing ends the fanout, but never before a re-read's marks are answered.
80
+ // Matching nothing ends the fanout, but never before a re-read's marks are answered. Named
81
+ // nothing: that read began before this change, so it is not truth that holds its write.
74
82
  if (!bridged) return { sent: reread ? await resnapshotAll(deps, entry) : 0, stale: 0 };
75
83
  // Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
76
84
  const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
@@ -84,8 +92,10 @@ export async function fanoutChange(
84
92
  const guessed = result.refill || partial;
85
93
  if (guessed) entry.stale = true;
86
94
  // The retained window holds the pre-policy patch; resume re-filters it per subscriber. A partial
87
- // row is never retained: replayed, it is the same missing column on a resuming client.
88
- if (!partial) for (const patch of result.patches) deps.source.append(entry.qid, patch);
95
+ // row is never retained: replayed, it is the same missing column on a resuming client. Each
96
+ // retained patch keeps the write that made it, so a delta resume can name it too.
97
+ if (!partial)
98
+ for (const patch of result.patches) deps.source.append(entry.qid, retain(patch, write));
89
99
  // And the ring says so now, not at the re-read: a resume in between got a delta lacking it.
90
100
  else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
91
101
 
@@ -109,7 +119,8 @@ export async function fanoutChange(
109
119
  // healthy socket. A marked subscriber is re-snapshotted out of the shared window instead, at
110
120
  // the cost of one frame and no DB read, and only then is the mark cleared.
111
121
  if (subscription.socket.desynced.has(subscription.sid)) {
112
- if (await resnapshot(deps, entry, subscription)) sent += 1;
122
+ // Out of the window this change was just folded into, so it holds the change's write.
123
+ if (await resnapshot(deps, entry, subscription, named)) sent += 1;
113
124
  continue;
114
125
  }
115
126
  const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
@@ -131,13 +142,7 @@ export async function fanoutChange(
131
142
  continue;
132
143
  }
133
144
  if (allowed.length === 0) continue;
134
- const frame: Frame = {
135
- type: 'patch',
136
- v: PROTOCOL_VERSION,
137
- sid: subscription.sid,
138
- patches: allowed,
139
- lsn: change.lsn,
140
- };
145
+ const frame = patchFrame(subscription.sid, allowed, change.lsn, writes);
141
146
  if (subscription.socket.send(frame)) {
142
147
  subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
143
148
  sent += 1;
@@ -185,7 +190,7 @@ async function truncated(
185
190
  entry: QueryEntry,
186
191
  change: ChangeEvent,
187
192
  ): Promise<FanoutResult> {
188
- if (!entry.shape.entities.includes(change.entity)) return { sent: 0, stale: 0 };
193
+ if (!entry.shape.entities.includes(change.table)) return { sent: 0, stale: 0 };
189
194
  await refillWindowInLane(entry);
190
195
  if (change.lsn > entry.lsn) entry.lsn = change.lsn;
191
196
  deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
@@ -196,11 +201,15 @@ async function truncated(
196
201
  }
197
202
 
198
203
  /** Each marked subscriber, re-snapshotted out of the window just read. Frames that left. */
199
- async function resnapshotAll(deps: FanoutDeps, entry: QueryEntry): Promise<number> {
204
+ async function resnapshotAll(
205
+ deps: FanoutDeps,
206
+ entry: QueryEntry,
207
+ named: readonly NamedWrite[] = [],
208
+ ): Promise<number> {
200
209
  let sent = 0;
201
210
  for (const subscription of entry.subscribers.values()) {
202
211
  if (!subscription.socket.desynced.has(subscription.sid)) continue;
203
- if (await resnapshot(deps, entry, subscription)) sent += 1;
212
+ if (await resnapshot(deps, entry, subscription, named)) sent += 1;
204
213
  }
205
214
  return sent;
206
215
  }
@@ -231,6 +240,7 @@ async function resnapshot(
231
240
  deps: FanoutDeps,
232
241
  entry: QueryEntry,
233
242
  subscription: LiveSubscription,
243
+ named: readonly NamedWrite[],
234
244
  ): Promise<boolean> {
235
245
  const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
236
246
  let rows: readonly Row[];
@@ -242,31 +252,58 @@ async function resnapshot(
242
252
  return false;
243
253
  }
244
254
  const cursor = makeCursor(entry.qid, entry.lsn, rows, deps.clock.now().getTime());
245
- if (!subscription.socket.send(snapshotFrame(entry, subscription.sid, rows, cursor))) return false;
255
+ const frame = snapshotFrame(entry, subscription.sid, rows, cursor, named);
256
+ if (!subscription.socket.send(frame)) return false;
246
257
  subscription.cursor = cursor;
247
258
  subscription.socket.clearDesynced(subscription.sid);
248
259
  return true;
249
260
  }
250
261
 
262
+ /** A write this truth may hold, and the RECORD key of the row it made — what it is shown by. */
263
+ export interface NamedWrite {
264
+ readonly write: string;
265
+ readonly key: string;
266
+ }
267
+
251
268
  /**
252
- * The one place a snapshot frame is built, so the identity scope and the record keys cannot be
253
- * told to one caller only. `keys` rides only when some key differs from its row's `id` — an entity
254
- * keyed by `id` sends the frame it always sent.
269
+ * The one place a snapshot frame is built, so the identity scope, the record keys and the writes
270
+ * cannot be told to one caller only. `keys` rides only when some key differs from its row's `id`
271
+ * — an entity keyed by `id` sends the frame it always sent. A write in `named` is sent only when
272
+ * the row it made is among `rows`, which are already filtered for THIS subscriber: a write behind
273
+ * a row it may not see is another actor's, never named to it.
255
274
  */
256
275
  export function snapshotFrame(
257
276
  entry: QueryEntry,
258
277
  sid: string,
259
278
  rows: readonly Row[],
260
279
  cursor: LiveCursor,
280
+ named: readonly NamedWrite[] = [],
261
281
  ): Frame {
262
- const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
263
- const scoped = entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
264
282
  const keyOf = entry.rowKey;
265
- if (keyOf === null) return scoped;
266
- const keys = rows.map((row) => keyOf(row));
283
+ const keys = rows.map((row) => (keyOf === null ? row.id : keyOf(row)));
284
+ const shown = new Set(keys);
285
+ const writes = new Set<string>();
286
+ for (const { write, key } of named) if (shown.has(key)) writes.add(write);
287
+ const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
288
+ // Capped where the decoder caps it: a frame over the ceiling is refused whole, and a write left
289
+ // unnamed only settles on its own HTTP answer.
290
+ const listed = [...writes].slice(0, FRAME_LIMITS.patches);
291
+ const withWrites = listed.length === 0 ? base : { ...base, writes: listed };
292
+ const scoped = entry.rowEntity === null ? withWrites : { ...withWrites, entity: entry.rowEntity };
267
293
  return keys.every((key, index) => key === rows[index]?.id) ? scoped : { ...scoped, keys };
268
294
  }
269
295
 
296
+ /** The change's write, by the record key of the row it made; nothing for an unkeyed change. */
297
+ function namedBy(
298
+ entry: QueryEntry,
299
+ change: ChangeEvent,
300
+ write: string | null,
301
+ ): readonly NamedWrite[] {
302
+ const whole = change.after ?? change.before;
303
+ if (write === null || whole === null) return [];
304
+ return [{ write, key: entry.rowKey === null ? whole.id : entry.rowKey(whole) }];
305
+ }
306
+
270
307
  /**
271
308
  * A patch's record key, from the change's WHOLE row — an update patch carries only the changed
272
309
  * columns, and a key needs every primary-key column. Only stamped where it differs from `id`.
@@ -282,3 +319,23 @@ function keyPatches(
282
319
  const key = keyOf(whole);
283
320
  return patches.map((patch) => (key === patch.id ? patch : { ...patch, key }));
284
321
  }
322
+
323
+ /** The patch the ring retains: the pre-policy patch plus the write that made its change. */
324
+ function retain(patch: RowPatch, write: string | null): RowPatch {
325
+ return write === null ? patch : { ...patch, write };
326
+ }
327
+
328
+ /**
329
+ * The one place a patch frame is built, so a live fanout and a delta resume cannot spell it two
330
+ * ways. `writes` rides only when some keyed write made a patch here: an unkeyed change sends the
331
+ * frame it always sent.
332
+ */
333
+ export function patchFrame(
334
+ sid: string,
335
+ patches: readonly RowPatch[],
336
+ lsn: string,
337
+ writes: readonly string[],
338
+ ): Frame {
339
+ const base = { type: 'patch', v: PROTOCOL_VERSION, sid, patches, lsn } as const;
340
+ return writes.length === 0 ? base : { ...base, writes };
341
+ }
@@ -12,6 +12,12 @@ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
12
12
  readonly clock?: Clock;
13
13
  readonly maxPerSocket?: number;
14
14
  readonly maxPerTenant?: number;
15
+ /**
16
+ * Live subscriptions one actor — or one anonymous client address — may hold on this node across
17
+ * all its sockets. Defaults to `DEFAULT_MAX_PER_ACTOR` (1,000); the boot passes the app's
18
+ * `realtime.maxSubscriptionsPerActor`.
19
+ */
20
+ readonly maxPerActor?: number;
15
21
  readonly tenantOf?: (actor: Actor | null) => string | null;
16
22
  /**
17
23
  * Distinct `(query, input)` pairs this node will hold at once. A `qid` derives from
package/src/live-query.ts CHANGED
@@ -6,20 +6,22 @@
6
6
  // policy is never sent to that actor — it arrives as a `delete` if they hold it, and is dropped
7
7
  // otherwise.
8
8
 
9
- import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core';
9
+ import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core';
10
10
  import { queryHash } from '@ultimat3/query';
11
11
  import type { ChangeEvent } from './changefeed';
12
12
  import { type LiveCursor, makeCursor } from './cursor';
13
- import { isPolicyDenial, LiveQueryUnknownError, SubscriptionLimitError } from './errors';
13
+ import { LiveQueryUnknownError, SubscriptionLimitError } from './errors';
14
14
  import type { JsonValue } from './json';
15
15
  import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
16
- import { type FanoutDeps, fanoutChange, snapshotFrame } from './live-fanout';
16
+ import { deliverChange } from './live-deliver';
17
+ import { type FanoutDeps, snapshotFrame } from './live-fanout';
17
18
  import { floorAfterRead } from './live-floor';
18
19
  import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
19
- import { refuseSubscription } from './live-refusal';
20
+ import { reauthorizeSocket } from './live-reauth';
20
21
  import { resumeOnto } from './live-resume';
22
+ import { type Charge, type SubscribeArgs, spendOnce } from './live-spend';
21
23
  import { liveTenantOf, windowId } from './live-tenant';
22
- import { createEntry, fillWindow, type QueryEntry } from './query-window';
24
+ import { fillWindow, type QueryEntry, queryEntry } from './query-window';
23
25
  import type { SyncSocket } from './socket';
24
26
  import { type Subscriber, SubscriberGate } from './subscriber-gate';
25
27
  import { SubscriptionBook, subscriptionKey } from './subscription-book';
@@ -43,7 +45,7 @@ export class LiveQueryRegistry {
43
45
  * This node's mark for a window read before it held any position: unique to the process, and
44
46
  * `!` sorts below every hex digit, so every real change is above it.
45
47
  */
46
- readonly #origin = `!${uuid()}`;
48
+ readonly #origin = `!${uuidV7()}`;
47
49
  #staleChanges = 0;
48
50
 
49
51
  constructor(options: LiveQueryRegistryOptions) {
@@ -85,9 +87,9 @@ export class LiveQueryRegistry {
85
87
  * ever ask for a re-snapshot. The repair lands on the next change to each query — which is the
86
88
  * event that proves the query is moving at all.
87
89
  *
88
- * `entity` narrows it to the windows that read that entity: a filtered write
89
- * (`updateWhere`/`deleteWhere`) names its entity but no row, and staling every window on the node
90
- * for it re-read every live query in the process on each bulk write to any table.
90
+ * `entity` — the RELATION, as on `ChangeEvent.table` — narrows it to the windows that read it:
91
+ * a filtered write (`updateWhere`/`deleteWhere`) names its table but no row, and staling every
92
+ * window on the node for it re-read every live query in the process on each bulk write.
91
93
  */
92
94
  invalidate(entity?: string): number {
93
95
  let marked = 0;
@@ -124,19 +126,21 @@ export class LiveQueryRegistry {
124
126
  * Subscribe or resume. Returns the frame this subscriber needs: a `snapshot` on a cold start or a
125
127
  * blown reconnect budget, a `patch` when the cursor is inside the retained window.
126
128
  */
127
- async subscribe(args: {
128
- socket: SyncSocket;
129
- name: string;
130
- input: JsonValue;
131
- sid?: string;
132
- cursor?: LiveCursor | null;
133
- }): Promise<{ subscription: LiveSubscription; frame: Frame }> {
129
+ subscribe(args: SubscribeArgs): Promise<{ subscription: LiveSubscription; frame: Frame }> {
130
+ return this.#subscribe(args, { due: true });
131
+ }
132
+
133
+ /** `charge.due` false: a re-seat, whose subscription was paid for when it was first made. */
134
+ async #subscribe(
135
+ args: SubscribeArgs,
136
+ charge: Charge,
137
+ ): Promise<{ subscription: LiveSubscription; frame: Frame }> {
134
138
  const definition = this.#definitions.get(args.name);
135
139
  // A name this node never registered, and not a protocol skew: the frame parsed, the version
136
140
  // matched, and one string in it names nothing. Reporting it as `X_PROTOCOL_VERSION` handed the
137
141
  // client "x build && redeploy the client" for a typo no rebuild changes.
138
142
  if (!definition) throw new LiveQueryUnknownError({ name: args.name });
139
- const sid = args.sid ?? uuid();
143
+ const sid = args.sid ?? uuidV7();
140
144
  // Everything this subscribe can be refused for, decided in one synchronous step BEFORE the
141
145
  // first await — the caps and the sid both. Read at the top and acted on three awaits later,
142
146
  // they were bypassed by the ordinary case: one WebSocket write carrying N subscribe frames,
@@ -148,7 +152,7 @@ export class LiveQueryRegistry {
148
152
  // re-decides only what is ATTACHED. So a subscription seated for an actor the socket no
149
153
  // longer carries is taken back and served again under the one it does.
150
154
  const actor = args.socket.actor;
151
- const served = await this.#subscribeReserved(definition, sid, args);
155
+ const served = await this.#subscribeReserved(definition, sid, args, charge);
152
156
  if (args.socket.actor === actor) return served;
153
157
  this.unsubscribe(args.socket.id, sid);
154
158
  }
@@ -168,19 +172,22 @@ export class LiveQueryRegistry {
168
172
  input: JsonValue;
169
173
  cursor?: LiveCursor | null;
170
174
  },
175
+ charge: Charge,
171
176
  ): Promise<{ subscription: LiveSubscription; frame: Frame }> {
172
177
  await definition.authorize?.({ actor: args.socket.actor, input: args.input });
173
- // After this subscriber's own decision, never before it: resolving a shape for a caller who
174
- // may not subscribe is work an unauthorized client gets to schedule.
175
- await definition.prepare?.(args.input);
176
-
177
178
  // The window is the subscriber's TENANT's: two orgs on one `(query, input)` are two entries,
178
179
  // two reads and two retained rings, so no shared row ever stands between them and a policy.
179
180
  const tenant = liveTenantOf(args.socket.actor);
180
181
  const qid = windowId(queryHash(args.name, args.input), tenant);
182
+ // After this subscriber's own decision, never before it: resolving a shape for a caller who
183
+ // may not subscribe is work an unauthorized client gets to schedule.
184
+ await definition.prepare?.(args.input);
185
+
181
186
  const entry = this.#entryFor(qid, definition, args.input, tenant);
182
187
  try {
183
- return await this.#serve(entry, sid, args);
188
+ // Charged where a read happens, never from the cursor's say-so (`live-spend.ts`).
189
+ const pay = () => spendOnce(definition, args.socket, charge, this.#clock);
190
+ return await this.#serve(entry, sid, args, pay);
184
191
  } catch (error) {
185
192
  // The entry was born above, before anything could fill it. Everything below can throw — the
186
193
  // snapshot, the resume, the window's own read deadline — and `unsubscribe` is the only other
@@ -200,6 +207,7 @@ export class LiveQueryRegistry {
200
207
  input: JsonValue;
201
208
  cursor?: LiveCursor | null;
202
209
  },
210
+ pay: () => Promise<void>,
203
211
  ): Promise<{ subscription: LiveSubscription; frame: Frame }> {
204
212
  const qid = entry.qid;
205
213
  const now = this.#clock.now().getTime();
@@ -219,7 +227,11 @@ export class LiveQueryRegistry {
219
227
  budget: this.#options.budget,
220
228
  clock: this.#clock,
221
229
  gate: this.#gate,
222
- read: () => this.#read(entry, who),
230
+ read: async () => {
231
+ await pay();
232
+ return await this.#read(entry, who);
233
+ },
234
+ beforeFill: pay,
223
235
  },
224
236
  entry,
225
237
  who,
@@ -230,6 +242,7 @@ export class LiveQueryRegistry {
230
242
  return { subscription, frame: resumed.frame };
231
243
  }
232
244
 
245
+ await pay();
233
246
  const fresh = await this.#read(entry, { sid, actor: args.socket.actor });
234
247
  const cursor = makeCursor(qid, fresh.lsn, fresh.rows, now);
235
248
  const subscription = this.#attachUnlessGone(entry, args.socket, sid, cursor);
@@ -278,106 +291,34 @@ export class LiveQueryRegistry {
278
291
  for (const subscription of this.#book.ofSocket(socketId)) {
279
292
  this.unsubscribe(socketId, subscription.sid);
280
293
  }
294
+ // Held or not: a socket re-authed while holding nothing is remembered by nothing else.
295
+ this.#book.forgetSocket(socketId);
281
296
  }
282
297
 
283
- /**
284
- * Actor changed mid-connection (login, logout, role change): re-run subscribe-time authz and drop
285
- * what is no longer allowed. Survivors are marked desynced so the next flush re-snapshots them
286
- * under the new actor's row policy. Returns the sids that were dropped — a denial and nothing
287
- * else, so a caller may tell the client "you may no longer see this" and be right.
288
- *
289
- * A survivor whose actor now belongs to ANOTHER tenant cannot stay where it is: its window is the
290
- * old org's. It is re-seated — dropped, subscribed again under the new tenant, and sent that
291
- * window's snapshot under the same sid — or, when that cannot complete, refused on the socket
292
- * under the sid (`refuseSubscription`), never kept on the old org's window.
293
- */
294
- async reauthorize(socket: SyncSocket): Promise<readonly string[]> {
295
- const dropped: string[] = [];
296
- // The actor changed, so what tenant this socket's subscriptions count against may have too.
297
- // Told here rather than derived per lookup: the per-tenant cap is an index now, and an index
298
- // nobody updates is a count that drifts from the book for the rest of the process.
299
- this.#book.retenant(socket);
300
- for (const subscription of this.#book.ofSocket(socket.id)) {
301
- let failed: { readonly error: unknown } | undefined;
302
- try {
303
- await subscription.definition.authorize?.({
304
- actor: socket.actor,
305
- input: subscription.input,
306
- });
307
- } catch (error) {
308
- if (isPolicyDenial(error)) {
309
- this.unsubscribe(socket.id, subscription.sid);
310
- // Said under the sid, as a refused subscribe is: unsaid, the client kept the rows on
311
- // screen in state `live` for a subscription this node no longer serves.
312
- refuseSubscription(socket, subscription.sid, error);
313
- dropped.push(subscription.sid);
314
- continue;
315
- }
316
- // Not a decision — the gate never reached one. Destroying the subscription would report a
317
- // database timeout as a revoked grant, and a client does not resubscribe to a denial. It
318
- // survives, desynced: nothing is delivered from the window built under the old actor, and
319
- // the row gate still decides every row under the new one, from the same policy `authorize`
320
- // consults. The failure is counted and reported rather than silently absorbed.
321
- this.#gate.failedAuthorize(
322
- subscription.qid,
323
- { sid: subscription.sid, actor: socket.actor },
324
- error,
325
- );
326
- failed = { error };
327
- }
328
- if (await this.#reseat(socket, subscription, failed)) continue;
329
- socket.markDesynced(subscription.sid);
330
- }
331
- return dropped;
298
+ /** Sockets the spanning caps still remember a key for. A leak is this number not falling. */
299
+ get trackedSockets(): number {
300
+ return this.#book.trackedSockets;
332
301
  }
333
302
 
334
- /**
335
- * Move one subscription onto its actor's CURRENT tenant's window. `false` when it is already
336
- * there. A re-seat that cannot complete leaves the subscription dropped — never attached to the
337
- * window of an org its actor has left — and REFUSED on the socket under its sid, the frame a
338
- * refused subscribe gets, so the client's window renders `failed` instead of going quiet.
339
- *
340
- * `failed` is the `authorize` this pass already saw fail: it is not asked again — the store that
341
- * just timed out is the one `subscribe` would ask — and it is not counted twice.
342
- */
343
- async #reseat(
344
- socket: SyncSocket,
345
- subscription: LiveSubscription,
346
- failed: { readonly error: unknown } | undefined,
347
- ): Promise<boolean> {
348
- const { sid, input, definition } = subscription;
349
- const wanted = windowId(queryHash(definition.name, input), liveTenantOf(socket.actor));
350
- if (wanted === subscription.qid) return false;
351
- this.unsubscribe(socket.id, sid);
352
- if (failed !== undefined) {
353
- refuseSubscription(socket, sid, failed.error);
354
- return true;
355
- }
356
- try {
357
- const { frame } = await this.subscribe({ socket, name: definition.name, input, sid });
358
- if (!socket.send(frame)) socket.markDesynced(sid);
359
- } catch (error) {
360
- this.#gate.failedAuthorize(wanted, { sid, actor: socket.actor }, error);
361
- refuseSubscription(socket, sid, error);
362
- }
363
- return true;
303
+ /** Live subscriptions one principal (`actor:<id>` / `address:<ip>`) holds on this node. */
304
+ actorCount(principal: string): number {
305
+ return this.#book.actorCount(principal);
306
+ }
307
+
308
+ /** Actor changed mid-connection — re-decided per subscription; `live-reauth.ts` owns how. */
309
+ reauthorize(socket: SyncSocket): Promise<readonly string[]> {
310
+ return reauthorizeSocket(
311
+ {
312
+ book: this.#book,
313
+ gate: this.#gate,
314
+ unsubscribe: (socketId, sid) => this.unsubscribe(socketId, sid),
315
+ // A re-seat is the subscription it already was, so it is never charged again.
316
+ resubscribe: (args) => this.#subscribe(args, { due: false }),
317
+ },
318
+ socket,
319
+ );
364
320
  }
365
321
 
366
- /**
367
- * Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
368
- * number of frames sent — the metric the reconnect benchmark watches.
369
- *
370
- * Each entry's turn is taken in that entry's lane. Nothing upstream orders this: `sync` fires
371
- * `void registry.deliver(change)` straight off the bus subscription, so two changes arriving back
372
- * to back would otherwise interleave inside one query id — lsn 2 delivered before lsn 1, the
373
- * subscriber's cursor rewound to 1, and every gate deciding against whichever window won.
374
- *
375
- * Every lane is *entered* before any of them is awaited, and nothing inside a fanout takes a
376
- * second lane, so holding all of them at once cannot be a cycle. That is what makes the ordering
377
- * claim true: two deliveries queue onto each query id in call order, serialized per query id and
378
- * never per node — awaiting one entry before entering the next made one slow policy pass the
379
- * whole node's pace, and let a lane that threw skip every entry behind it with nobody desynced.
380
- */
381
322
  /**
382
323
  * The newest change position this registry has been handed. What a node's snapshot may claim
383
324
  * (`liveQueryDefinition`'s `lsn`): a read begun after it holds at least that change, and every
@@ -387,34 +328,12 @@ export class LiveQueryRegistry {
387
328
  return this.#lastLsn;
388
329
  }
389
330
 
331
+ /** Fan one change out, every query id in its own lane — `live-deliver.ts` owns why. */
390
332
  async deliver(change: ChangeEvent): Promise<number> {
391
333
  if (change.lsn > this.#lastLsn) this.#lastLsn = change.lsn;
392
- const lanes = [...this.#entries.values()].map(async (entry) => {
393
- try {
394
- const result = await entry.lock.run(() => fanoutChange(this.#fanout, entry, change));
395
- this.#staleChanges += result.stale;
396
- return result.sent;
397
- } catch (error) {
398
- // The window advanced under a fanout that did not finish, so every subscriber of this one
399
- // query id now holds a cursor below the change and no later flush would correct them:
400
- // desynced here, re-snapshotted on the next one. Silent divergence is the whole reason
401
- // `markDesynced` exists, and skipping this is how a failure became one.
402
- for (const subscription of entry.subscribers.values()) {
403
- subscription.socket.markDesynced(subscription.sid);
404
- }
405
- throw error;
406
- }
334
+ return await deliverChange(this.#entries.values(), this.#fanout, change, (stale) => {
335
+ this.#staleChanges += stale;
407
336
  });
408
- // `allSettled`, so one lane's rejection neither cancels the others nor goes unhandled. The
409
- // first failure still reaches the caller — `sync` logs it — but it costs one query id.
410
- let sent = 0;
411
- let failure: { readonly error: unknown } | null = null;
412
- for (const lane of await Promise.allSettled(lanes)) {
413
- if (lane.status === 'fulfilled') sent += lane.value;
414
- else failure ??= { error: lane.reason };
415
- }
416
- if (failure !== null) throw failure.error;
417
- return sent;
418
337
  }
419
338
 
420
339
  #attachUnlessGone(
@@ -438,6 +357,9 @@ export class LiveQueryRegistry {
438
357
  sid: string,
439
358
  cursor: LiveCursor,
440
359
  ): LiveSubscription {
360
+ // Before anything is written: a re-auth during the read may have moved this socket to a
361
+ // principal that never had room for it.
362
+ this.#book.assertAttachable(socket, sid);
441
363
  const subscription: LiveSubscription = {
442
364
  sid,
443
365
  qid: entry.qid,
@@ -474,7 +396,7 @@ export class LiveQueryRegistry {
474
396
  knob: 'maxEntries',
475
397
  });
476
398
  }
477
- const created = createEntry(qid, definition, input, definition.matcher(input), {
399
+ const created = queryEntry(qid, definition, input, definition.matcher(input), {
478
400
  readDeadlineMs: this.#options.readDeadlineMs,
479
401
  schedule: this.#options.schedule,
480
402
  tenant,