@ultimat3/realtime 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CLAUDE.md +89 -102
  2. package/README.md +140 -20
  3. package/package.json +4 -4
  4. package/src/advisory-lock.ts +57 -0
  5. package/src/boot.ts +12 -7
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +2 -2
  8. package/src/changefeed.ts +39 -1
  9. package/src/channel-authz.ts +43 -12
  10. package/src/channel-latch.ts +52 -0
  11. package/src/channel-logs.ts +34 -4
  12. package/src/channel-records.ts +33 -2
  13. package/src/channel-sids.ts +37 -0
  14. package/src/channel.ts +143 -44
  15. package/src/client-channels.ts +10 -1
  16. package/src/client-contract.ts +4 -1
  17. package/src/client-frames.ts +34 -4
  18. package/src/client-heartbeat.ts +39 -9
  19. package/src/client.ts +1 -0
  20. package/src/cursor.ts +11 -0
  21. package/src/errors.ts +70 -19
  22. package/src/fanout.ts +13 -0
  23. package/src/index.ts +1 -1
  24. package/src/live-contract.ts +6 -2
  25. package/src/live-definition.ts +46 -11
  26. package/src/live-fanout.ts +70 -11
  27. package/src/live-floor.ts +35 -0
  28. package/src/live-query-options.ts +38 -0
  29. package/src/live-query.ts +119 -91
  30. package/src/live-refusal.ts +21 -0
  31. package/src/live-replicator.ts +6 -6
  32. package/src/live-resume.ts +74 -0
  33. package/src/live-tenant.ts +25 -0
  34. package/src/nats-client.ts +11 -2
  35. package/src/nats-open.ts +9 -0
  36. package/src/nats-transport.ts +23 -1
  37. package/src/offline-queue.ts +48 -5
  38. package/src/page-errors.ts +17 -0
  39. package/src/page-outbox.ts +121 -50
  40. package/src/pg-advisory-lock.ts +45 -1
  41. package/src/pg-auth.ts +9 -4
  42. package/src/pg-bytes.ts +4 -1
  43. package/src/pg-connection.ts +119 -21
  44. package/src/pg-entity-row.ts +27 -2
  45. package/src/pg-identity-tables.ts +25 -0
  46. package/src/pg-preflight.ts +50 -4
  47. package/src/pg-replication-row.ts +82 -0
  48. package/src/pg-replication.ts +44 -45
  49. package/src/pg-socket.ts +6 -4
  50. package/src/pg-tls.ts +18 -0
  51. package/src/pg-wire.ts +5 -3
  52. package/src/pgoutput.ts +19 -9
  53. package/src/presence.ts +85 -8
  54. package/src/query-window.ts +28 -7
  55. package/src/record-persister.ts +7 -2
  56. package/src/replication-errors.ts +8 -4
  57. package/src/replicator-envelope.ts +110 -0
  58. package/src/replicator-row.ts +75 -0
  59. package/src/replicator.ts +319 -186
  60. package/src/server.ts +11 -7
  61. package/src/socket-engine.ts +14 -2
  62. package/src/socket-idle.ts +3 -1
  63. package/src/socket-routes.ts +8 -3
  64. package/src/subscriber-gate.ts +11 -1
  65. package/src/sync-auth.ts +47 -2
  66. package/src/sync-frames.ts +73 -20
  67. package/src/sync-node-bounds.ts +15 -0
  68. package/src/sync-node-contract.ts +20 -2
  69. package/src/sync-node.ts +92 -17
  70. package/src/sync-origin.ts +26 -11
  71. package/src/sync-protocol.ts +13 -3
  72. package/src/sync-upgrade.ts +76 -8
  73. package/src/use-mutation.ts +33 -5
  74. package/src/wire-channel.ts +13 -4
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Realtime's X_* codes. Every throw in this package goes through one of these classes so
2
2
  // the same string renders in the terminal, the browser overlay, and `--json`.
3
3
 
4
- import { registerErrorCodes } from '@ultimat3/core';
4
+ import { isFixShellSafe, registerErrorCodes, renderFixShellArg } from '@ultimat3/core';
5
5
  import { RealtimeError } from './realtime-error';
6
6
 
7
7
  /** Codes this package declares and owns. */
@@ -36,6 +36,7 @@ export const REALTIME_OWNED_ERROR_CODES = [
36
36
  'X_REALTIME_TOPOLOGY',
37
37
  'X_REPLICATION_TLS',
38
38
  'X_SOCKET_ORIGIN_REFUSED',
39
+ 'X_OFFLINE_QUEUE_ABANDONED',
39
40
  ] as const;
40
41
 
41
42
  /**
@@ -61,6 +62,24 @@ export const POLICY_DENIAL_CODES: ReadonlySet<string> = new Set([
61
62
  'X_UNAUTHENTICATED',
62
63
  ]);
63
64
 
65
+ /**
66
+ * `@ultimat3/entity`'s tenant guard refusing a read FOR THIS ACTOR: it carries no org, names
67
+ * another one, or reached across tenants without the capability. Verdicts about who is asking, so
68
+ * a channel's row loader raising one is a denial. `X_TENANCY_UNSCOPED` is deliberately absent —
69
+ * that is a query written without a tenant predicate, which is the app's defect and no decision.
70
+ * Spelled by name, never matched by prefix: a code's meaning is its owner's, not its shape's.
71
+ */
72
+ export const TENANCY_DENIAL_CODES: ReadonlySet<string> = new Set([
73
+ 'X_TENANCY_ACTOR_ORG_REQUIRED',
74
+ 'X_TENANCY_ACTOR_MISMATCH',
75
+ 'X_TENANCY_CROSS_DENIED',
76
+ ]);
77
+
78
+ /** True when the tenant guard decided against the actor — see `TENANCY_DENIAL_CODES`. */
79
+ export function isTenancyDenial(error: unknown): boolean {
80
+ return TENANCY_DENIAL_CODES.has(codeOf(error) ?? '');
81
+ }
82
+
64
83
  /**
65
84
  * The sync protocol's answer to "which of these is a 4xx". A denied topic, a subscription cap, a
66
85
  * skewed protocol version and a cursor that fell out of the buffer are all conditions the CLIENT
@@ -146,6 +165,7 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
146
165
  X_REALTIME_TOPOLOGY: 'a sync node boots on a real database with no reachable change feed',
147
166
  X_REPLICATION_TLS: 'the replication connection failed TLS',
148
167
  X_SOCKET_ORIGIN_REFUSED: 'the websocket upgrade came from another origin',
168
+ X_OFFLINE_QUEUE_ABANDONED: 'a write was queued after its page changed principal',
149
169
  };
150
170
 
151
171
  // One unconditional call, so a second package claiming one of realtime's codes throws
@@ -159,6 +179,7 @@ registerErrorCodes(
159
179
  export {
160
180
  CursorStaleError,
161
181
  LocalStoreUnavailableError,
182
+ OfflineQueueAbandonedError,
162
183
  ProtocolVersionError,
163
184
  RealtimeUninstalledError,
164
185
  RebaseConflictError,
@@ -300,17 +321,33 @@ export class TransportProtocolError extends RealtimeError {
300
321
  }
301
322
 
302
323
  /**
303
- * A subscribable read projected a row with no `id`. Patches, cursors and the local store all
304
- * address a row by `id`, so such a row cannot be delivered — and delivering it anyway produces a
305
- * subscription that looks correct until the first update nobody can apply.
324
+ * A subscribable read projected a row with no usable `id`. Patches, cursors and the local store all
325
+ * address a row by a text `id`, so such a row cannot be delivered — and delivering it anyway
326
+ * produces a subscription that looks correct until the first update nobody can apply.
327
+ *
328
+ * `idType` is set when the row HAS an id that is neither text nor a safe integer (the one numeric
329
+ * shape the change feed and the snapshot both stringify): "select the primary key" is then the
330
+ * wrong instruction, because the key is selected.
306
331
  */
307
332
  export class LiveRowUnidentifiedError extends RealtimeError {
308
- constructor(args: { query: string; keys: readonly string[] }) {
309
- super({
310
- code: 'X_LIVE_ROW_UNIDENTIFIED',
311
- cause: `live query "${args.query}" returned a row with no id (columns: ${args.keys.join(', ') || 'none'})`,
312
- fix: `select the primary key in ${args.query}'s sql(), or drop live: true from it`,
313
- });
333
+ constructor(args: { query: string; keys: readonly string[]; idType?: string }) {
334
+ const columns = args.keys.join(', ') || 'none';
335
+ super(
336
+ args.idType === undefined
337
+ ? {
338
+ code: 'X_LIVE_ROW_UNIDENTIFIED',
339
+ cause: `live query "${args.query}" returned a row with no id (columns: ${columns})`,
340
+ fix: `select the primary key in ${args.query}'s sql(), or drop live: true from it`,
341
+ }
342
+ : {
343
+ code: 'X_LIVE_ROW_UNIDENTIFIED',
344
+ cause: `live query "${args.query}" returned a row whose id is a ${args.idType}, not text or a safe integer — a live row is addressed by a text id, so project the key as text (a uuid or text column) or drop live: true from the query`,
345
+ // A name the shell would not read verbatim does not travel: the list names it instead.
346
+ fix: isFixShellSafe(args.query)
347
+ ? `x queries describe ${renderFixShellArg(args.query, 'name')} --json`
348
+ : 'x queries list --json',
349
+ },
350
+ );
314
351
  }
315
352
  }
316
353
 
@@ -363,11 +400,21 @@ export class SocketUnauthenticatedError extends RealtimeError {
363
400
  * the accept budget, so a hostile page costs neither.
364
401
  */
365
402
  export class SocketOriginRefusedError extends RealtimeError {
366
- constructor(args: { reason: string }) {
403
+ constructor(args: { reason: string; asked: string | null; admitted: readonly string[] }) {
404
+ // The asking origin is a request header: it travels only as the origin a URL parser read out
405
+ // of it AND only when the shell screen carries it verbatim, so neither the cause nor the
406
+ // pasted `export` can hold anything the client wrote beyond a scheme, a host and a port.
407
+ const parsed = URL.parse(args.asked ?? '')?.origin;
408
+ const asked = isFixShellSafe(parsed) ? parsed : null;
409
+ const { admitted } = args;
410
+ const list =
411
+ admitted.length === 0
412
+ ? 'only the origin it was reached on (no APP_URL declared)'
413
+ : `only ${admitted.join(', ')}`;
367
414
  super({
368
415
  code: 'X_SOCKET_ORIGIN_REFUSED',
369
- cause: `the websocket upgrade was refused: ${args.reason}`,
370
- fix: 'export APP_URL="https://www.example.com" # on the sync role: the origin the page is served on (or createSyncNode({ allowedOrigins }))',
416
+ cause: `the websocket upgrade was refused: ${args.reason} — asked from ${asked ?? 'an origin that cannot be rendered'}; this node admits ${list}`,
417
+ fix: `export APP_URL=${asked ?? 'https://www.example.com'} # on the sync role, and only when that is the origin your pages are served on (or createSyncNode({ allowedOrigins }))`,
371
418
  });
372
419
  }
373
420
  }
@@ -405,13 +452,17 @@ export class WindowReadTimeoutError extends RealtimeError {
405
452
  }
406
453
  }
407
454
 
408
- /** Deep infrastructure that is interface-complete but not wired. Carries the exact next step. */
409
- export class NotImplementedError extends RealtimeError {
410
- constructor(args: { what: string; fix: string }) {
455
+ /**
456
+ * A grant's `refresh()` — the app's token service — did not answer inside its deadline. The grant
457
+ * is NOT renewed and NOT revoked: it stays expired and the next re-auth pass asks again. What the
458
+ * deadline buys is the rest of the pass, which one call that never settles used to park forever.
459
+ */
460
+ export class GrantRefreshTimeoutError extends RealtimeError {
461
+ constructor(args: { socketId: string; afterMs: number }) {
411
462
  super({
412
- code: 'X_NOT_IMPLEMENTED',
413
- cause: `${args.what} is interface-complete but not implemented in this build`,
414
- fix: args.fix,
463
+ code: 'X_TIMEOUT',
464
+ cause: `refresh() for the grant of socket ${args.socketId} did not answer within ${args.afterMs}ms, so the re-auth pass moved on; the grant stays expired and is asked again on the next pass`,
465
+ fix: 'x doctor --json',
415
466
  });
416
467
  }
417
468
  }
package/src/fanout.ts CHANGED
@@ -36,6 +36,14 @@ export interface Transport {
36
36
  publish(subject: string, payload: string): Promise<void>;
37
37
  subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription>;
38
38
  readonly shared: TransportSet;
39
+ /**
40
+ * Told each time the connection behind this transport comes back from a drop. Fanout is
41
+ * at-most-once, so whatever was published in between never arrived — and with no later message
42
+ * there is no sequence number to notice it by. A subscriber that keeps derived state (a `sync`
43
+ * node's windows) invalidates it here. Returns the unsubscribe. A transport with no connection
44
+ * to lose never calls the listener.
45
+ */
46
+ onReconnect(listener: () => void): () => void;
39
47
  close(): Promise<void>;
40
48
  }
41
49
 
@@ -125,6 +133,11 @@ export class InProcessTransport implements Transport {
125
133
  }
126
134
  }
127
135
 
136
+ /** One process, no connection: there is nothing to come back from, so nothing is ever said. */
137
+ onReconnect(): () => void {
138
+ return () => undefined;
139
+ }
140
+
128
141
  async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
129
142
  this.#assertOpen();
130
143
  const handlers = this.#handlers.get(subject) ?? new Set<TransportHandler>();
package/src/index.ts CHANGED
@@ -62,7 +62,7 @@ export {
62
62
  FrameRateLimitError,
63
63
  LiveQueryUnknownError,
64
64
  LiveRowUnidentifiedError,
65
- NotImplementedError,
65
+ OfflineQueueAbandonedError,
66
66
  ProtocolVersionError,
67
67
  REALTIME_ERROR_CODES,
68
68
  REALTIME_ERROR_TITLES,
@@ -26,8 +26,12 @@ export interface LiveQueryDefinition<R extends Row = Row> {
26
26
  readonly entities: readonly string[];
27
27
  /** Read set. Lets the pre-filter skip updates that touch no column this query reads. */
28
28
  readonly columns?: readonly string[];
29
- /** Bounded read (`orderBy` + `limit`, enforced by `x verify`), unfiltered by policy. */
30
- snapshot(args: { input: JsonValue }): Promise<SnapshotResult<R>>;
29
+ /**
30
+ * Bounded read (`orderBy` + `limit`, enforced by `x verify`), unfiltered by policy — and read
31
+ * FOR `tenant`: the org of the subscribers this window serves, `null` for subscribers that carry
32
+ * none. A window is keyed by it (`live-tenant.ts`), so one read is never two orgs' rows.
33
+ */
34
+ snapshot(args: { input: JsonValue; tenant: string | null }): Promise<SnapshotResult<R>>;
31
35
  /** Subscribe-time gate. Throws to deny — the same `policy` used by HTTP, jobs, and MCP. */
32
36
  authorize?(args: { actor: Actor | null; input: JsonValue }): void | Promise<void>;
33
37
  /** Row-level gate, evaluated per subscriber. The only row filter in the pipeline. */
@@ -9,7 +9,7 @@
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 } from '@ultimat3/core';
12
+ import { finiteOption, runWithContext, serviceActor, withChildContext } from '@ultimat3/core';
13
13
  import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
14
14
  import { LiveRowUnidentifiedError } from './errors';
15
15
  import { isRow, type JsonValue, type Row } from './json';
@@ -22,12 +22,15 @@ export interface LiveDefinitionOptions {
22
22
  /**
23
23
  * The node's own context — never a subscriber's. It supplies services, clock and locale to the
24
24
  * shared read; it supplies no authority, because that read is built with the query's policy
25
- * switched off and every row leaving it is gated per subscriber.
25
+ * switched off and every row leaving it is gated per subscriber. The read itself runs in a CHILD
26
+ * of it that carries the window's tenant and nothing else of any subscriber's (`readFor`).
26
27
  */
27
28
  readonly ctx: Ctx;
28
29
  /**
29
- * Where the shared window sits in the change stream, asked at snapshot time. Without a feed
30
- * position a reconnect can only re-snapshot, so a node with a replicator should pass its lsn.
30
+ * Where the shared window sits in the change stream, asked at snapshot time — the node's newest
31
+ * received change (`registry.lastLsn`). Omitted, or answering `''` before the first change, the
32
+ * snapshot claims no position and the retained ring is floored by its first patch instead: a
33
+ * cursor minted before that patch re-snapshots, one that received it resumes as a delta.
31
34
  */
32
35
  readonly lsn?: () => string;
33
36
  /** Pins the reconnect epoch in tests; the server derives it from the build. */
@@ -82,6 +85,25 @@ const UNRESOLVED: IncrementalMatcher = {
82
85
  match: () => ({ patches: [], refill: true }),
83
86
  };
84
87
 
88
+ /** Who a shared read runs as: the node, in one org. Never a subscriber — a window has many. */
89
+ const LIVE_READER_ID = 'live-window';
90
+
91
+ /**
92
+ * Run one shared read FOR a tenant: inside the node's context, as a service actor that carries the
93
+ * window's org and no subscriber's identity, roles or grants. `@ultimat3/entity`'s tenant guard
94
+ * derives from the ambient context, so this is what makes a repo that leaves the tenant to the
95
+ * acting actor read on a sync node exactly as it reads over HTTP — and what refuses a read that
96
+ * names another org (`X_TENANCY_ACTOR_MISMATCH`), which with NO context was simply answered.
97
+ *
98
+ * `tenant === null` runs under the node's own actor, which has no org: a tenant-scoped read is
99
+ * then `X_TENANCY_ACTOR_ORG_REQUIRED`, the verdict the same anonymous read gets over HTTP.
100
+ */
101
+ function readFor<T>(ctx: Ctx, tenant: string | null, read: () => Promise<T>): Promise<T> {
102
+ if (tenant === null) return runWithContext(ctx, read);
103
+ const actor = serviceActor({ id: LIVE_READER_ID, orgId: tenant });
104
+ return runWithContext(ctx, () => withChildContext({ actor }, read));
105
+ }
106
+
85
107
  /**
86
108
  * Registrable definition for one declared query. `register` takes it by name, so what comes back
87
109
  * is input-independent: the per-input half is resolved by `prepare`, which the registry awaits
@@ -140,13 +162,16 @@ export function liveQueryDefinition(
140
162
  prepare: async (input) => {
141
163
  await resolve(input);
142
164
  },
143
- snapshot: async ({ input }): Promise<SnapshotResult> => {
165
+ snapshot: async ({ input, tenant }): Promise<SnapshotResult> => {
144
166
  const window = await resolve(input);
145
167
  // The position is taken BEFORE the rows: the rows are then at least that new, so the claim
146
168
  // is true. Taken after, a commit landing mid-read was claimed and missing — and every change
147
169
  // up to it is dropped downstream as already folded.
170
+ // `''` when the node holds no position — no `lsn` option, or no change received yet. Never
171
+ // a made-up one: a position the node never held becomes a ring floor every foreign cursor
172
+ // is "inside", and the client that resumed there kept rows this node never patched.
148
173
  const lsn = options.lsn?.() ?? '';
149
- return { rows: await window.read(), lsn };
174
+ return { rows: await readFor(options.ctx, tenant, () => window.read()), lsn };
150
175
  },
151
176
  matcher: (input) => windows.get(queryHash(name, input))?.matcher ?? UNRESOLVED,
152
177
  // Read off the same resolved window as the matcher, so the scope the client keys rows under and
@@ -171,15 +196,25 @@ function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
171
196
  }
172
197
 
173
198
  /**
174
- * Rows crossing the wire are addressed by `id` — patches, cursors and the local store all key on
175
- * it. A projection without one is refused here rather than delivered as a row nobody can patch:
176
- * the alternative is a subscription that appears to work until the first update.
199
+ * Rows crossing the wire are addressed by a TEXT `id` — patches, cursors and the local store all
200
+ * key on it. A safe-integer id is stringified, the one normalisation the change feed applies to the
201
+ * same row (`pg-replication.ts`'s `toRow`), so a snapshot row and its later patch name one identity.
202
+ * Anything else is refused here rather than delivered as a row nobody can patch.
177
203
  */
178
204
  function rowsOf(query: string, rows: readonly object[]): readonly Row[] {
179
205
  const out: Row[] = [];
180
206
  for (const row of rows) {
181
- if (!isRow(row)) throw new LiveRowUnidentifiedError({ query, keys: Object.keys(row) });
182
- out.push(row);
207
+ const id: unknown = (row as { id?: unknown }).id;
208
+ const candidate =
209
+ typeof id === 'number' && Number.isSafeInteger(id) ? { ...row, id: String(id) } : row;
210
+ if (!isRow(candidate)) {
211
+ throw new LiveRowUnidentifiedError({
212
+ query,
213
+ keys: Object.keys(row),
214
+ ...(id === undefined || id === null ? {} : { idType: typeof id }),
215
+ });
216
+ }
217
+ out.push(candidate);
183
218
  }
184
219
  return out;
185
220
  }
@@ -8,7 +8,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';
@@ -47,29 +47,53 @@ export async function fanoutChange(
47
47
  return { sent: 0, stale: 0 };
48
48
  }
49
49
  if (change.op === 'truncate') return await truncated(deps, entry, change);
50
- if (entry.stale) await refillWindowInLane(entry);
50
+ const reread = entry.stale;
51
+ if (reread) {
52
+ await refillWindowInLane(entry);
53
+ // What was retained before this read has a hole exactly where the staleness came from.
54
+ deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
55
+ // Every subscriber was fed from the rows this read just replaced, so each is owed a snapshot —
56
+ // not only the ones `invalidate()` marked: a read that failed or timed out stales the window
57
+ // and marks nobody.
58
+ for (const subscription of entry.subscribers.values()) {
59
+ subscription.socket.markDesynced(subscription.sid);
60
+ }
61
+ // The read claims to hold this change already (`lsn` is the node's newest when it began), so
62
+ // there is nothing to patch. Returning before the loop below was the defect: the guard after
63
+ // this refused the change as stale and every desynced subscriber went without the change AND
64
+ // 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 };
66
+ }
51
67
  // The consume-side twin of the replicator's own duplicate guard, which had none. `entry.lsn =
52
68
  // change.lsn` was unconditional, so a change the window already holds — a redelivery, or one
53
69
  // that arrived behind the snapshot that already included it — rewound every subscriber's cursor
54
70
  // to it and asked them to fold state they had already folded over newer rows.
55
71
  if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
56
72
  const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
57
- if (!bridged) return { sent: 0, stale: 0 };
73
+ // Matching nothing ends the fanout, but never before a re-read's marks are answered.
74
+ if (!bridged) return { sent: reread ? await resnapshotAll(deps, entry) : 0, stale: 0 };
58
75
  // Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
59
76
  const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
77
+ // Asked of the window as it stood BEFORE this change: the partial row must not teach it a shape.
78
+ const partial = lacksOmitted(change, result.patches, projectionOf(entry.rows));
60
79
  entry.lsn = change.lsn;
61
80
  entry.rows = applyToWindow(entry.rows, result.patches);
62
- // The window lost its tail, so what it holds is a guess — the next delivery re-reads it rather
63
- // than patching a guess, and every subscriber below is re-snapshotted out of what that returns.
64
- if (result.refill) entry.stale = true;
65
- // The retained window holds the pre-policy patch; resume re-filters it per subscriber.
66
- for (const patch of result.patches) deps.source.append(entry.qid, patch);
81
+ // The window lost its tail — or adopted a row the change could not carry whole — so what it
82
+ // holds is a guess: the next delivery re-reads it rather than patching a guess, and every
83
+ // subscriber below is re-snapshotted out of what that returns.
84
+ const guessed = result.refill || partial;
85
+ if (guessed) entry.stale = true;
86
+ // 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);
89
+ // And the ring says so now, not at the re-read: a resume in between got a delta lacking it.
90
+ else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
67
91
 
68
92
  let sent = 0;
69
93
  // Indexed once for every subscriber below, never searched per subscriber per patch.
70
94
  const index = windowIndex(entry.rows);
71
95
  for (const subscription of entry.subscribers.values()) {
72
- if (result.refill) {
96
+ if (guessed) {
73
97
  // The window lost its tail: guessing is how a sync engine silently diverges. Checked BEFORE
74
98
  // the mark, because a repair reads `entry.rows` — which this fanout has just declared a
75
99
  // guess — and then CLEARS the mark. A subscriber already diverged would be recorded as
@@ -124,6 +148,32 @@ export async function fanoutChange(
124
148
  return { sent, stale: 0 };
125
149
  }
126
150
 
151
+ /**
152
+ * Whether a row ENTERS the window without a column the change did not carry
153
+ * (`ChangeEvent.omitted`: an unchanged out-of-line value Postgres logged no bytes for). An update
154
+ * patch is safe — it merges onto a window row that already holds the column — but an add adopts
155
+ * the row as the whole row, into the window, every later snapshot and the retained ring. An
156
+ * unread projection (an empty window) cannot rule a column out, so every omitted one counts.
157
+ */
158
+ function lacksOmitted(
159
+ change: ChangeEvent,
160
+ patches: readonly RowPatch[],
161
+ projection: Projection,
162
+ ): boolean {
163
+ const omitted = change.omitted;
164
+ if (omitted === undefined || omitted.length === 0) return false;
165
+ return patches.some(
166
+ (patch) =>
167
+ patch.op === 'insert' &&
168
+ patch.row !== null &&
169
+ omitted.some(
170
+ (column) =>
171
+ (projection === undefined || projection.has(column)) &&
172
+ !Object.hasOwn(patch.row ?? {}, column),
173
+ ),
174
+ );
175
+ }
176
+
127
177
  /**
128
178
  * Every row of a relation this window reads is gone. There is nothing to patch from — a truncate
129
179
  * names no row — so the window is re-read now, in the lane, and every subscriber is re-snapshotted
@@ -138,12 +188,21 @@ async function truncated(
138
188
  if (!entry.shape.entities.includes(change.entity)) return { sent: 0, stale: 0 };
139
189
  await refillWindowInLane(entry);
140
190
  if (change.lsn > entry.lsn) entry.lsn = change.lsn;
141
- let sent = 0;
191
+ deps.source.floorAt?.(entry.qid, entry.lsn, { exclusive: true });
142
192
  for (const subscription of entry.subscribers.values()) {
143
193
  subscription.socket.markDesynced(subscription.sid);
194
+ }
195
+ return { sent: await resnapshotAll(deps, entry), stale: 0 };
196
+ }
197
+
198
+ /** Each marked subscriber, re-snapshotted out of the window just read. Frames that left. */
199
+ async function resnapshotAll(deps: FanoutDeps, entry: QueryEntry): Promise<number> {
200
+ let sent = 0;
201
+ for (const subscription of entry.subscribers.values()) {
202
+ if (!subscription.socket.desynced.has(subscription.sid)) continue;
144
203
  if (await resnapshot(deps, entry, subscription)) sent += 1;
145
204
  }
146
- return { sent, stale: 0 };
205
+ return sent;
147
206
  }
148
207
 
149
208
  /**
@@ -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,38 @@
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
+ readonly tenantOf?: (actor: Actor | null) => string | null;
16
+ /**
17
+ * Distinct `(query, input)` pairs this node will hold at once. A `qid` derives from
18
+ * client-chosen input, so without a ceiling one socket mints entries — a matcher, a row window,
19
+ * a `WindowLock` and a fanout target each — until the process dies.
20
+ */
21
+ readonly maxEntries?: number;
22
+ /**
23
+ * How long one entry's SHARED snapshot read may hold its slot. Defaults to
24
+ * `DEFAULT_READ_DEADLINE_MS`. Without it a `definition.snapshot` that never settles pinned the
25
+ * slot for the life of the process and every later cold subscriber joined a promise nothing
26
+ * would resolve — one wedged read taking every future subscriber of that query id with it.
27
+ */
28
+ readonly readDeadlineMs?: number;
29
+ /** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
30
+ readonly schedule?: Scheduler;
31
+ }
32
+
33
+ /**
34
+ * Live `(query, input)` pairs one node holds. Reached, the next NEW pair is refused with
35
+ * `X_SUBSCRIPTION_LIMIT`; subscribing to a pair that already exists keeps working, because the
36
+ * cost this bounds is the entry, not the subscriber.
37
+ */
38
+ export const DEFAULT_MAX_ENTRIES = 10_000;