@ultimat3/realtime 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -9,8 +9,14 @@
9
9
  // every time. Collapsing the second onto the first is privilege escalation with a cache hit rate.
10
10
 
11
11
  import type { Ctx } from '@ultimat3/core';
12
- import { finiteOption, runWithContext, serviceActor, withChildContext } from '@ultimat3/core';
13
- import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
12
+ import {
13
+ anonymousActor,
14
+ finiteOption,
15
+ runWithContext,
16
+ serviceActor,
17
+ withChildContext,
18
+ } from '@ultimat3/core';
19
+ import { type AnyQuery, queryHash, queryName, spendQueryLimit } from '@ultimat3/query';
14
20
  import { LiveRowUnidentifiedError } from './errors';
15
21
  import { isRow, type JsonValue, type Row } from './json';
16
22
  import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
@@ -27,8 +33,10 @@ export interface LiveDefinitionOptions {
27
33
  */
28
34
  readonly ctx: Ctx;
29
35
  /**
30
- * Where the shared window sits in the change stream, asked at snapshot time. Without a feed
31
- * position a reconnect can only re-snapshot, so a node with a replicator should pass its lsn.
36
+ * Where the shared window sits in the change stream, asked at snapshot time — the node's newest
37
+ * received change (`registry.lastLsn`). Omitted, or answering `''` before the first change, the
38
+ * snapshot claims no position and the retained ring is floored by its first patch instead: a
39
+ * cursor minted before that patch re-snapshots, one that received it resumes as a delta.
32
40
  */
33
41
  readonly lsn?: () => string;
34
42
  /** Pins the reconnect epoch in tests; the server derives it from the build. */
@@ -165,6 +173,9 @@ export function liveQueryDefinition(
165
173
  // The position is taken BEFORE the rows: the rows are then at least that new, so the claim
166
174
  // is true. Taken after, a commit landing mid-read was claimed and missing — and every change
167
175
  // up to it is dropped downstream as already folded.
176
+ // `''` when the node holds no position — no `lsn` option, or no change received yet. Never
177
+ // a made-up one: a position the node never held becomes a ring floor every foreign cursor
178
+ // is "inside", and the client that resumed there kept rows this node never patched.
168
179
  const lsn = options.lsn?.() ?? '';
169
180
  return { rows: await readFor(options.ctx, tenant, () => window.read()), lsn };
170
181
  },
@@ -177,6 +188,10 @@ export function liveQueryDefinition(
177
188
  // memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
178
189
  // delivery, and there is no key here an actor could share with another actor.
179
190
  authorize: authorizeWithPolicy(target.policy, { query: name, ctx: options.ctx }),
191
+ // The read's own `rateLimit:` bucket, per subscriber — the one HTTP and MCP spend. A socket
192
+ // with no actor is the anonymous reader, keyed by its address as an unauthenticated HTTP read is.
193
+ spend: ({ actor, clientAddress, nowMs }) =>
194
+ spendQueryLimit(target, { actor: actor ?? anonymousActor(), clientAddress, nowMs }),
180
195
  visible: visibleWithPolicy(target.policy, { query: name, ctx: options.ctx }),
181
196
  };
182
197
  }
@@ -191,15 +206,25 @@ function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
191
206
  }
192
207
 
193
208
  /**
194
- * Rows crossing the wire are addressed by `id` — patches, cursors and the local store all key on
195
- * it. A projection without one is refused here rather than delivered as a row nobody can patch:
196
- * the alternative is a subscription that appears to work until the first update.
209
+ * Rows crossing the wire are addressed by a TEXT `id` — patches, cursors and the local store all
210
+ * key on it. A safe-integer id is stringified, the one normalisation the change feed applies to the
211
+ * same row (`pg-replication.ts`'s `toRow`), so a snapshot row and its later patch name one identity.
212
+ * Anything else is refused here rather than delivered as a row nobody can patch.
197
213
  */
198
214
  function rowsOf(query: string, rows: readonly object[]): readonly Row[] {
199
215
  const out: Row[] = [];
200
216
  for (const row of rows) {
201
- if (!isRow(row)) throw new LiveRowUnidentifiedError({ query, keys: Object.keys(row) });
202
- out.push(row);
217
+ const id: unknown = (row as { id?: unknown }).id;
218
+ const candidate =
219
+ typeof id === 'number' && Number.isSafeInteger(id) ? { ...row, id: String(id) } : row;
220
+ if (!isRow(candidate)) {
221
+ throw new LiveRowUnidentifiedError({
222
+ query,
223
+ keys: Object.keys(row),
224
+ ...(id === undefined || id === null ? {} : { idType: typeof id }),
225
+ });
226
+ }
227
+ out.push(candidate);
203
228
  }
204
229
  return out;
205
230
  }
@@ -0,0 +1,55 @@
1
+ // One change, fanned out over every live window on the node. Split from `live-query.ts` at its
2
+ // size ceiling: the registry owns who holds which window, this owns the ordering of one delivery.
3
+
4
+ import type { ChangeEvent } from './changefeed';
5
+ import { type FanoutDeps, fanoutChange } from './live-fanout';
6
+ import type { QueryEntry } from './query-window';
7
+
8
+ /**
9
+ * Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
10
+ * number of frames sent — the metric the reconnect benchmark watches.
11
+ *
12
+ * Each entry's turn is taken in that entry's lane. Nothing upstream orders this: `sync` fires
13
+ * `void registry.deliver(change)` straight off the bus subscription, so two changes arriving back
14
+ * to back would otherwise interleave inside one query id — lsn 2 delivered before lsn 1, the
15
+ * subscriber's cursor rewound to 1, and every gate deciding against whichever window won.
16
+ *
17
+ * Every lane is *entered* before any of them is awaited, and nothing inside a fanout takes a
18
+ * second lane, so holding all of them at once cannot be a cycle. That is what makes the ordering
19
+ * claim true: two deliveries queue onto each query id in call order, serialized per query id and
20
+ * never per node — awaiting one entry before entering the next made one slow policy pass the
21
+ * whole node's pace, and let a lane that threw skip every entry behind it with nobody desynced.
22
+ */
23
+ export async function deliverChange(
24
+ entries: Iterable<QueryEntry>,
25
+ fanout: FanoutDeps,
26
+ change: ChangeEvent,
27
+ onStale: (count: number) => void,
28
+ ): Promise<number> {
29
+ const lanes = [...entries].map(async (entry) => {
30
+ try {
31
+ const result = await entry.lock.run(() => fanoutChange(fanout, entry, change));
32
+ onStale(result.stale);
33
+ return result.sent;
34
+ } catch (error) {
35
+ // The window advanced under a fanout that did not finish, so every subscriber of this one
36
+ // query id now holds a cursor below the change and no later flush would correct them:
37
+ // desynced here, re-snapshotted on the next one. Silent divergence is the whole reason
38
+ // `markDesynced` exists, and skipping this is how a failure became one.
39
+ for (const subscription of entry.subscribers.values()) {
40
+ subscription.socket.markDesynced(subscription.sid);
41
+ }
42
+ throw error;
43
+ }
44
+ });
45
+ // `allSettled`, so one lane's rejection neither cancels the others nor goes unhandled. The
46
+ // first failure still reaches the caller — `sync` logs it — but it costs one query id.
47
+ let sent = 0;
48
+ let failure: { readonly error: unknown } | null = null;
49
+ for (const lane of await Promise.allSettled(lanes)) {
50
+ if (lane.status === 'fulfilled') sent += lane.value;
51
+ else failure ??= { error: lane.reason };
52
+ }
53
+ if (failure !== null) throw failure.error;
54
+ return sent;
55
+ }
@@ -3,15 +3,16 @@
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';
10
10
  import type { LiveSubscription } from './live-contract';
11
- import { applyToWindow, bridgeChange } from './matcher-bridge';
11
+ import { applyToWindow, bridgeChange, type Projection, projectionOf } from './matcher-bridge';
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,9 +48,17 @@ 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);
60
+ // What was retained before this read has a hole exactly where the staleness came from.
61
+ deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
53
62
  // Every subscriber was fed from the rows this read just replaced, so each is owed a snapshot —
54
63
  // not only the ones `invalidate()` marked: a read that failed or timed out stales the window
55
64
  // and marks nobody.
@@ -60,7 +69,7 @@ export async function fanoutChange(
60
69
  // there is nothing to patch. Returning before the loop below was the defect: the guard after
61
70
  // this refused the change as stale and every desynced subscriber went without the change AND
62
71
  // without its snapshot — a run console frozen on its first event after one `updateWhere`.
63
- 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 };
64
73
  }
65
74
  // The consume-side twin of the replicator's own duplicate guard, which had none. `entry.lsn =
66
75
  // change.lsn` was unconditional, so a change the window already holds — a redelivery, or one
@@ -68,23 +77,33 @@ export async function fanoutChange(
68
77
  // to it and asked them to fold state they had already folded over newer rows.
69
78
  if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
70
79
  const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
71
- // 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.
72
82
  if (!bridged) return { sent: reread ? await resnapshotAll(deps, entry) : 0, stale: 0 };
73
83
  // Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
74
84
  const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
85
+ // Asked of the window as it stood BEFORE this change: the partial row must not teach it a shape.
86
+ const partial = lacksOmitted(change, result.patches, projectionOf(entry.rows));
75
87
  entry.lsn = change.lsn;
76
88
  entry.rows = applyToWindow(entry.rows, result.patches);
77
- // The window lost its tail, so what it holds is a guess — the next delivery re-reads it rather
78
- // than patching a guess, and every subscriber below is re-snapshotted out of what that returns.
79
- if (result.refill) entry.stale = true;
80
- // The retained window holds the pre-policy patch; resume re-filters it per subscriber.
81
- for (const patch of result.patches) deps.source.append(entry.qid, patch);
89
+ // The window lost its tail — or adopted a row the change could not carry whole — so what it
90
+ // holds is a guess: the next delivery re-reads it rather than patching a guess, and every
91
+ // subscriber below is re-snapshotted out of what that returns.
92
+ const guessed = result.refill || partial;
93
+ if (guessed) entry.stale = true;
94
+ // The retained window holds the pre-policy patch; resume re-filters it per subscriber. A partial
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));
99
+ // And the ring says so now, not at the re-read: a resume in between got a delta lacking it.
100
+ else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
82
101
 
83
102
  let sent = 0;
84
103
  // Indexed once for every subscriber below, never searched per subscriber per patch.
85
104
  const index = windowIndex(entry.rows);
86
105
  for (const subscription of entry.subscribers.values()) {
87
- if (result.refill) {
106
+ if (guessed) {
88
107
  // The window lost its tail: guessing is how a sync engine silently diverges. Checked BEFORE
89
108
  // the mark, because a repair reads `entry.rows` — which this fanout has just declared a
90
109
  // guess — and then CLEARS the mark. A subscriber already diverged would be recorded as
@@ -100,7 +119,8 @@ export async function fanoutChange(
100
119
  // healthy socket. A marked subscriber is re-snapshotted out of the shared window instead, at
101
120
  // the cost of one frame and no DB read, and only then is the mark cleared.
102
121
  if (subscription.socket.desynced.has(subscription.sid)) {
103
- 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;
104
124
  continue;
105
125
  }
106
126
  const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
@@ -122,13 +142,7 @@ export async function fanoutChange(
122
142
  continue;
123
143
  }
124
144
  if (allowed.length === 0) continue;
125
- const frame: Frame = {
126
- type: 'patch',
127
- v: PROTOCOL_VERSION,
128
- sid: subscription.sid,
129
- patches: allowed,
130
- lsn: change.lsn,
131
- };
145
+ const frame = patchFrame(subscription.sid, allowed, change.lsn, writes);
132
146
  if (subscription.socket.send(frame)) {
133
147
  subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
134
148
  sent += 1;
@@ -139,6 +153,32 @@ export async function fanoutChange(
139
153
  return { sent, stale: 0 };
140
154
  }
141
155
 
156
+ /**
157
+ * Whether a row ENTERS the window without a column the change did not carry
158
+ * (`ChangeEvent.omitted`: an unchanged out-of-line value Postgres logged no bytes for). An update
159
+ * patch is safe — it merges onto a window row that already holds the column — but an add adopts
160
+ * the row as the whole row, into the window, every later snapshot and the retained ring. An
161
+ * unread projection (an empty window) cannot rule a column out, so every omitted one counts.
162
+ */
163
+ function lacksOmitted(
164
+ change: ChangeEvent,
165
+ patches: readonly RowPatch[],
166
+ projection: Projection,
167
+ ): boolean {
168
+ const omitted = change.omitted;
169
+ if (omitted === undefined || omitted.length === 0) return false;
170
+ return patches.some(
171
+ (patch) =>
172
+ patch.op === 'insert' &&
173
+ patch.row !== null &&
174
+ omitted.some(
175
+ (column) =>
176
+ (projection === undefined || projection.has(column)) &&
177
+ !Object.hasOwn(patch.row ?? {}, column),
178
+ ),
179
+ );
180
+ }
181
+
142
182
  /**
143
183
  * Every row of a relation this window reads is gone. There is nothing to patch from — a truncate
144
184
  * names no row — so the window is re-read now, in the lane, and every subscriber is re-snapshotted
@@ -150,9 +190,10 @@ async function truncated(
150
190
  entry: QueryEntry,
151
191
  change: ChangeEvent,
152
192
  ): Promise<FanoutResult> {
153
- 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 };
154
194
  await refillWindowInLane(entry);
155
195
  if (change.lsn > entry.lsn) entry.lsn = change.lsn;
196
+ deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
156
197
  for (const subscription of entry.subscribers.values()) {
157
198
  subscription.socket.markDesynced(subscription.sid);
158
199
  }
@@ -160,11 +201,15 @@ async function truncated(
160
201
  }
161
202
 
162
203
  /** Each marked subscriber, re-snapshotted out of the window just read. Frames that left. */
163
- 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> {
164
209
  let sent = 0;
165
210
  for (const subscription of entry.subscribers.values()) {
166
211
  if (!subscription.socket.desynced.has(subscription.sid)) continue;
167
- if (await resnapshot(deps, entry, subscription)) sent += 1;
212
+ if (await resnapshot(deps, entry, subscription, named)) sent += 1;
168
213
  }
169
214
  return sent;
170
215
  }
@@ -195,6 +240,7 @@ async function resnapshot(
195
240
  deps: FanoutDeps,
196
241
  entry: QueryEntry,
197
242
  subscription: LiveSubscription,
243
+ named: readonly NamedWrite[],
198
244
  ): Promise<boolean> {
199
245
  const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
200
246
  let rows: readonly Row[];
@@ -206,31 +252,58 @@ async function resnapshot(
206
252
  return false;
207
253
  }
208
254
  const cursor = makeCursor(entry.qid, entry.lsn, rows, deps.clock.now().getTime());
209
- 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;
210
257
  subscription.cursor = cursor;
211
258
  subscription.socket.clearDesynced(subscription.sid);
212
259
  return true;
213
260
  }
214
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
+
215
268
  /**
216
- * The one place a snapshot frame is built, so the identity scope and the record keys cannot be
217
- * told to one caller only. `keys` rides only when some key differs from its row's `id` — an entity
218
- * 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.
219
274
  */
220
275
  export function snapshotFrame(
221
276
  entry: QueryEntry,
222
277
  sid: string,
223
278
  rows: readonly Row[],
224
279
  cursor: LiveCursor,
280
+ named: readonly NamedWrite[] = [],
225
281
  ): Frame {
226
- const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
227
- const scoped = entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
228
282
  const keyOf = entry.rowKey;
229
- if (keyOf === null) return scoped;
230
- 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 };
231
293
  return keys.every((key, index) => key === rows[index]?.id) ? scoped : { ...scoped, keys };
232
294
  }
233
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
+
234
307
  /**
235
308
  * A patch's record key, from the change's WHOLE row — an update patch carries only the changed
236
309
  * columns, and a key needs every primary-key column. Only stamped where it differs from `id`.
@@ -246,3 +319,23 @@ function keyPatches(
246
319
  const key = keyOf(whole);
247
320
  return patches.map((patch) => (key === patch.id ? patch : { ...patch, key }));
248
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
+ }
@@ -0,0 +1,35 @@
1
+ // What a window read that just returned owes the retained ring, and the position the subscriber it
2
+ // serves is told. The rule itself is `change-buffer.ts`'s (`Ring`); this is its one caller for a
3
+ // read, split from `live-query.ts` at that file's size ceiling.
4
+
5
+ import type { ResumeSource } from './cursor';
6
+ import type { FilledWindow, QueryEntry } from './query-window';
7
+
8
+ export interface NodePosition {
9
+ /** The newest change this node has received; `''` before the first. */
10
+ readonly lastLsn: string;
11
+ /** This node's own mark for a read with no position behind it. Sorts below every real lsn. */
12
+ readonly origin: string;
13
+ }
14
+
15
+ /**
16
+ * Synchronous on purpose: the caller awaits a policy pass next, and the position has to be the
17
+ * one these ROWS were read at — read after that await, a change folded in meanwhile would be
18
+ * claimed by a snapshot that does not hold it.
19
+ */
20
+ export function floorAfterRead(
21
+ source: ResumeSource,
22
+ entry: QueryEntry,
23
+ window: FilledWindow,
24
+ node: NodePosition,
25
+ ): string {
26
+ // Served out of a window that was already filled: its patch history is still true.
27
+ if (window.landed === null) return window.lsn;
28
+ // A read with no position behind it is marked with this node's own origin, so the cursor it
29
+ // mints is one no other node could have issued.
30
+ const lsn = window.lsn === '' ? node.origin : window.lsn;
31
+ if (entry.lsn === '') entry.lsn = lsn;
32
+ if (window.landed === 'forced') source.floorAt?.(entry.qid, lsn, { exclusive: true });
33
+ else source.floorAt?.(entry.qid, lsn, { sole: node.lastLsn === '' || window.lsn === '' });
34
+ return lsn;
35
+ }
@@ -0,0 +1,44 @@
1
+ // What a `LiveQueryRegistry` is built from, and the one ceiling it defaults. Split from
2
+ // `live-query.ts` at that file's size ceiling: the registry owns behaviour, this owns its knobs.
3
+
4
+ import type { Actor, Clock } from '@ultimat3/core';
5
+ import type { ReconnectBudget, ResumeSource } from './cursor';
6
+ import type { SubscriberGateOptions } from './subscriber-gate';
7
+ import type { Scheduler } from './thundering-herd';
8
+
9
+ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
10
+ readonly source: ResumeSource;
11
+ readonly budget?: ReconnectBudget;
12
+ readonly clock?: Clock;
13
+ readonly maxPerSocket?: number;
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;
21
+ readonly tenantOf?: (actor: Actor | null) => string | null;
22
+ /**
23
+ * Distinct `(query, input)` pairs this node will hold at once. A `qid` derives from
24
+ * client-chosen input, so without a ceiling one socket mints entries — a matcher, a row window,
25
+ * a `WindowLock` and a fanout target each — until the process dies.
26
+ */
27
+ readonly maxEntries?: number;
28
+ /**
29
+ * How long one entry's SHARED snapshot read may hold its slot. Defaults to
30
+ * `DEFAULT_READ_DEADLINE_MS`. Without it a `definition.snapshot` that never settles pinned the
31
+ * slot for the life of the process and every later cold subscriber joined a promise nothing
32
+ * would resolve — one wedged read taking every future subscriber of that query id with it.
33
+ */
34
+ readonly readDeadlineMs?: number;
35
+ /** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
36
+ readonly schedule?: Scheduler;
37
+ }
38
+
39
+ /**
40
+ * Live `(query, input)` pairs one node holds. Reached, the next NEW pair is refused with
41
+ * `X_SUBSCRIPTION_LIMIT`; subscribing to a pair that already exists keeps working, because the
42
+ * cost this bounds is the entry, not the subscriber.
43
+ */
44
+ export const DEFAULT_MAX_ENTRIES = 10_000;