@ultimat3/realtime 30.8.0 → 31.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 (67) hide show
  1. package/CLAUDE.md +72 -82
  2. package/README.md +36 -40
  3. package/package.json +8 -6
  4. package/src/adapter.ts +14 -0
  5. package/src/apply-patches.ts +8 -5
  6. package/src/boot.ts +15 -2
  7. package/src/changefeed.ts +11 -4
  8. package/src/channel-decl.ts +29 -1
  9. package/src/channel-publish.ts +17 -19
  10. package/src/channel-ref.ts +22 -2
  11. package/src/client-channels.ts +11 -5
  12. package/src/client-contract.ts +2 -2
  13. package/src/cursor.ts +5 -5
  14. package/src/device-agent.ts +2 -2
  15. package/src/device-errors.ts +1 -1
  16. package/src/device-socket.ts +1 -1
  17. package/src/error-titles-server.ts +40 -0
  18. package/src/error-titles.ts +12 -21
  19. package/src/errors.ts +43 -10
  20. package/src/idb-types.ts +6 -0
  21. package/src/index.ts +5 -27
  22. package/src/json.ts +7 -3
  23. package/src/live-contract.ts +4 -4
  24. package/src/live-definition.ts +9 -6
  25. package/src/live-deliver.ts +40 -2
  26. package/src/live-entries.ts +73 -0
  27. package/src/live-fanout.ts +25 -9
  28. package/src/live-patch-encode.ts +30 -0
  29. package/src/live-query-options.ts +11 -0
  30. package/src/live-query.ts +26 -11
  31. package/src/live-replicator.ts +24 -21
  32. package/src/local-store-idb.ts +66 -6
  33. package/src/matcher-bridge.ts +15 -15
  34. package/src/nats-transport.ts +5 -5
  35. package/src/offline-queue-store.ts +69 -0
  36. package/src/offline-queue.ts +91 -91
  37. package/src/page-errors.ts +15 -0
  38. package/src/page-outbox.ts +18 -3
  39. package/src/pg-connection.ts +9 -1
  40. package/src/pg-preflight.ts +21 -5
  41. package/src/pg-replication-dial.ts +95 -0
  42. package/src/pg-replication-liveness.ts +84 -0
  43. package/src/pg-replication-position.ts +42 -0
  44. package/src/pg-replication-row.ts +6 -6
  45. package/src/pg-replication-stats.ts +28 -0
  46. package/src/pg-replication.ts +106 -125
  47. package/src/pg-slot-release.ts +40 -0
  48. package/src/policy-gate.ts +2 -2
  49. package/src/query-window.ts +4 -4
  50. package/src/record-publisher.ts +2 -2
  51. package/src/record-store.ts +7 -5
  52. package/src/replication-errors.ts +40 -2
  53. package/src/replication-resync.ts +38 -0
  54. package/src/replicator-deadline.ts +33 -0
  55. package/src/replicator-reconnect.ts +85 -0
  56. package/src/replicator-resync.ts +30 -0
  57. package/src/replicator-row.ts +5 -5
  58. package/src/replicator-subject.ts +28 -0
  59. package/src/replicator.ts +79 -69
  60. package/src/server.ts +8 -6
  61. package/src/socket-engine.ts +2 -2
  62. package/src/subscriber-gate.ts +14 -10
  63. package/src/sync-bus-handlers.ts +20 -4
  64. package/src/sync-protocol.ts +3 -3
  65. package/src/thundering-herd.ts +17 -24
  66. package/src/transport-env.ts +20 -48
  67. package/src/window-lock.ts +15 -3
package/src/index.ts CHANGED
@@ -34,7 +34,6 @@ export type {
34
34
  ChannelRemove,
35
35
  ChannelSince,
36
36
  ChannelSubscribeTarget,
37
- ChannelWireFrame,
38
37
  ReplayGapFrame,
39
38
  } from './channel-wire';
40
39
  // ---- the hooks: every read and write a component makes -----------------------------------------
@@ -42,7 +41,6 @@ export type { ChannelHandlers, ChannelRef, ChannelState } from './client-channel
42
41
  // ---- the shapes a hook hands back ----------------------------------------------------------------
43
42
  export type {
44
43
  LiveHandle,
45
- LiveQueryRef,
46
44
  SignalFactory,
47
45
  Unsubscribe,
48
46
  } from './client-contract';
@@ -50,9 +48,7 @@ export type {
50
48
  export {
51
49
  advance,
52
50
  CURSOR_ID_LIMIT,
53
- defaultReconnectBudget,
54
51
  type LiveCursor,
55
- makeCursor,
56
52
  type ReconnectBudget,
57
53
  type ResumeDecision,
58
54
  type ResumeDeps,
@@ -64,31 +60,16 @@ export {
64
60
  } from './cursor';
65
61
  // ---- errors: one vocabulary for both halves, because every code reaches the wire ---------------
66
62
  export {
67
- CursorStaleError,
68
- FrameRateLimitError,
69
- LiveQueryUnknownError,
70
- LiveRowUnidentifiedError,
71
63
  OfflineQueueAbandonedError,
72
- ProtocolVersionError,
73
- REALTIME_ERROR_CODES,
64
+ OfflineQueueFullError,
74
65
  REALTIME_ERROR_TITLES,
66
+ REALTIME_PAGE_ERROR_TITLES,
75
67
  RealtimeError,
76
68
  type RealtimeErrorCode,
77
- RealtimeUninstalledError,
78
- RebaseConflictError,
79
- RecordRejectedError,
80
- ReplicaIdentityError,
81
- ReplicationFailedError,
82
- ReplicationProtocolError,
83
- ReplicationTlsError,
84
69
  ReplicatorSlotHeldError,
85
- ServerRenderLiveError,
86
70
  SubscriptionLimitError,
87
- SyncUnconfiguredError,
88
71
  TopicForbiddenError,
89
- TransportProtocolError,
90
72
  TransportUnavailableError,
91
- WindowReadTimeoutError,
92
73
  } from './errors';
93
74
  // ---- the page: one record store, one socket, installed by the island bootstrap ------------------
94
75
  export {
@@ -102,11 +83,11 @@ export {
102
83
  isRow,
103
84
  type JsonObject,
104
85
  type JsonValue,
105
- type Row,
86
+ type LiveRow,
106
87
  type RowOp,
107
88
  type RowPatch,
108
89
  } from './json';
109
- export { type LiveState, type Registration, RowWindows, unnamedType } from './live-rows';
90
+ export { type LiveState, type Registration, unnamedType } from './live-rows';
110
91
  // ---- offline: the durable store, the persisted records, the one outbox (plan 101 slice 12) ------
111
92
  export {
112
93
  type LocalStore,
@@ -124,6 +105,7 @@ export {
124
105
  type MutationStatus,
125
106
  memoryQueueStore,
126
107
  OfflineQueue,
108
+ type OfflineQueueOptions,
127
109
  type QueuedMutation,
128
110
  type QueueState,
129
111
  type QueueStore,
@@ -139,7 +121,6 @@ export { resetPageRealtime } from './page-reset';
139
121
  export { hasPageSocket, type SyncTarget } from './page-store';
140
122
  export { pollEvery, type QueryPoll } from './query-poll';
141
123
  export { installRealtime, type RealtimeInstall } from './reactivity';
142
- export { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
143
124
  export {
144
125
  DEFAULT_MAX_ROWS_PER_TYPE,
145
126
  type RecordKey,
@@ -172,15 +153,12 @@ export {
172
153
  } from './sync-protocol';
173
154
  // ---- the client's own reconnect: the backoff it computes and the timer it arms -----------------
174
155
  export {
175
- type BackoffPolicy,
176
156
  BROWSER_RECONNECT_MAX_MS,
177
157
  browserBackoff,
178
158
  defaultBackoff,
179
159
  type JitterMode,
180
- type ReconnectReason,
181
160
  type Rng,
182
161
  type Scheduler,
183
- timeoutScheduler,
184
162
  } from './thundering-herd';
185
163
  export {
186
164
  type ChannelAccessor,
package/src/json.ts CHANGED
@@ -20,8 +20,12 @@ export type JsonValue =
20
20
 
21
21
  export type JsonObject = { [key: string]: JsonValue };
22
22
 
23
- /** Every row that crosses the wire or lands in the local store is identified by `id`. */
24
- export type Row = JsonObject & { id: string };
23
+ /**
24
+ * Every row that crosses the wire or lands in the local store is identified by `id`. `LiveRow`, not
25
+ * `Row`: core's `Row` (`conflict-policy.ts`) is any keyed record and entity's `Row` an expression
26
+ * row; three types under one name was an import that type-checked against the wrong one.
27
+ */
28
+ export type LiveRow = JsonObject & { id: string };
25
29
 
26
30
  export type RowOp = 'insert' | 'update' | 'delete';
27
31
 
@@ -58,7 +62,7 @@ export function isParsedJsonObject(value: unknown): value is JsonObject {
58
62
  return isJsonObject(value);
59
63
  }
60
64
 
61
- export function isRow(value: unknown): value is Row {
65
+ export function isRow(value: unknown): value is LiveRow {
62
66
  return isParsedJsonObject(value) && typeof value['id'] === 'string';
63
67
  }
64
68
 
@@ -12,11 +12,11 @@
12
12
  import type { Actor } from '@ultimat3/core';
13
13
  import type { LiveReadPhase } from '@ultimat3/query';
14
14
  import type { LiveCursor } from './cursor';
15
- import type { JsonValue, Row } from './json';
15
+ import type { JsonValue, LiveRow } from './json';
16
16
  import type { IncrementalMatcher } from './matcher-bridge';
17
17
  import type { SyncSocket } from './socket';
18
18
 
19
- export interface SnapshotResult<R extends Row = Row> {
19
+ export interface SnapshotResult<R extends LiveRow = LiveRow> {
20
20
  readonly rows: readonly R[];
21
21
  readonly lsn: string;
22
22
  }
@@ -34,7 +34,7 @@ export interface SnapshotArgs {
34
34
  readonly phase?: LiveReadPhase | undefined;
35
35
  }
36
36
 
37
- export interface LiveQueryDefinition<R extends Row = Row> {
37
+ export interface LiveQueryDefinition<R extends LiveRow = LiveRow> {
38
38
  readonly name: string;
39
39
  /** Dependency set for the pre-filter. `x verify` rejects a `live: true` query without one. */
40
40
  readonly entities: readonly string[];
@@ -70,7 +70,7 @@ export interface LiveQueryDefinition<R extends Row = Row> {
70
70
  * The record key each row travels under, resolved with `rowEntity`: the entity's own projection.
71
71
  * `null` (or absent) is a plain table, whose rows are keyed by `id`.
72
72
  */
73
- rowKey?(input: JsonValue): ((row: Row) => string) | null;
73
+ rowKey?(input: JsonValue): ((row: LiveRow) => string) | null;
74
74
  /**
75
75
  * Resolve whatever this input needs before an entry is built. `matcher` is synchronous by
76
76
  * design — a change event must not await anything — so a definition that has to compile a
@@ -24,7 +24,7 @@ import {
24
24
  traceLiveRead,
25
25
  } from '@ultimat3/query';
26
26
  import { LiveRowUnidentifiedError } from './errors';
27
- import { isRow, type JsonValue, type Row } from './json';
27
+ import { isRow, type JsonValue, type LiveRow } from './json';
28
28
  import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
29
29
  import { liveRecords } from './live-record-type';
30
30
  import { type IncrementalMatcher, matcherFor, type Projection } from './matcher-bridge';
@@ -64,8 +64,8 @@ interface SharedWindow {
64
64
  /** The compiled shape's root entity — the client's identity scope for every row of this read. */
65
65
  readonly rowEntity: string;
66
66
  /** Its projection's record key; `null` for a plain table keyed by `id`. */
67
- readonly rowKey: ((row: Row) => string) | null;
68
- read(): Promise<readonly Row[]>;
67
+ readonly rowKey: ((row: LiveRow) => string) | null;
68
+ read(): Promise<readonly LiveRow[]>;
69
69
  }
70
70
 
71
71
  /**
@@ -74,7 +74,10 @@ interface SharedWindow {
74
74
  * read it from. Learned once and kept, because the case the window's own rows cannot answer is an
75
75
  * EMPTY window: the first row to arrive would otherwise be sent as the whole table row.
76
76
  */
77
- const learnProjection = (): { read: () => Projection; teach: (rows: readonly Row[]) => void } => {
77
+ const learnProjection = (): {
78
+ read: () => Projection;
79
+ teach: (rows: readonly LiveRow[]) => void;
80
+ } => {
78
81
  let projection: Projection;
79
82
  return {
80
83
  read: () => projection,
@@ -227,8 +230,8 @@ function evictOldest(windows: Map<string, SharedWindow>, max: number): void {
227
230
  * same row (`pg-replication.ts`'s `toRow`), so a snapshot row and its later patch name one identity.
228
231
  * Anything else is refused here rather than delivered as a row nobody can patch.
229
232
  */
230
- function rowsOf(query: string, rows: readonly object[]): readonly Row[] {
231
- const out: Row[] = [];
233
+ function rowsOf(query: string, rows: readonly object[]): readonly LiveRow[] {
234
+ const out: LiveRow[] = [];
232
235
  for (const row of rows) {
233
236
  const id: unknown = (row as { id?: unknown }).id;
234
237
  const candidate =
@@ -1,10 +1,37 @@
1
1
  // One change, fanned out over every live window on the node. Split from `live-query.ts` at its
2
2
  // size ceiling: the registry owns who holds which window, this owns the ordering of one delivery.
3
3
 
4
+ import { finiteOption } from '@ultimat3/core';
4
5
  import type { ChangeEvent } from './changefeed';
5
6
  import { type FanoutDeps, fanoutChange } from './live-fanout';
7
+ import { DEFAULT_MAX_LANE_DEPTH } from './live-query-options';
6
8
  import type { QueryEntry } from './query-window';
7
9
 
10
+ /**
11
+ * The two counters every delivery on a node feeds, and the lane bound it sheds at. Owned by the
12
+ * registry, one per node; read back as `staleChanges` / `changesShed`.
13
+ */
14
+ export class DeliveryCounts {
15
+ readonly maxLaneDepth: number;
16
+ /** `live.changes_stale`: a change at or below a window's own lsn, refused rather than folded. */
17
+ staleChanges = 0;
18
+ /** `live.changes_shed`: a change a full lane did not queue; its window re-reads instead. */
19
+ changesShed = 0;
20
+
21
+ constructor(maxLaneDepth: number | undefined) {
22
+ // At least two: depth counts the RUNNING task, and a shed is repaired by a task queued behind
23
+ // it finding the mark. A lane of one shed with nobody behind to read it.
24
+ this.maxLaneDepth = Math.max(
25
+ 2,
26
+ finiteOption(
27
+ 'the live-query registry',
28
+ 'maxLaneDepth',
29
+ maxLaneDepth ?? DEFAULT_MAX_LANE_DEPTH,
30
+ ),
31
+ );
32
+ }
33
+ }
34
+
8
35
  /**
9
36
  * Fan one change out. Matched once per query id, authorized once per subscriber. Returns the
10
37
  * number of frames sent — the metric the reconnect benchmark watches.
@@ -24,12 +51,23 @@ export async function deliverChange(
24
51
  entries: Iterable<QueryEntry>,
25
52
  fanout: FanoutDeps,
26
53
  change: ChangeEvent,
27
- onStale: (count: number) => void,
54
+ counts: DeliveryCounts,
28
55
  ): Promise<number> {
29
56
  const lanes = [...entries].map(async (entry) => {
57
+ if (entry.lock.depth >= counts.maxLaneDepth) {
58
+ // Shed, never queued: the lane is a closure per change, and one slow policy pass would hold
59
+ // them all. Staled, not desynced, because the lane is still full of changes that will run —
60
+ // the first one in finds the mark and re-reads (`live-fanout.ts`), and that read began after
61
+ // this change was handed to the registry, so it holds it. Over-reading is the cost; losing
62
+ // the change is not one. Outside the lane, as `invalidate()` sets it: a read issued after
63
+ // this answers it, and one issued before cleared the mark already and cannot clear this.
64
+ entry.stale = true;
65
+ counts.changesShed += 1;
66
+ return 0;
67
+ }
30
68
  try {
31
69
  const result = await entry.lock.run(() => fanoutChange(fanout, entry, change));
32
- onStale(result.stale);
70
+ counts.staleChanges += result.stale;
33
71
  return result.sent;
34
72
  } catch (error) {
35
73
  // The window advanced under a fanout that did not finish, so every subscriber of this one
@@ -0,0 +1,73 @@
1
+ // The registry's windows, indexed by the relations each one reads. One owner for both, so an entry
2
+ // added or forgotten can never be in the table and missing from the index (or the reverse): a
3
+ // change then reaches only the windows that read its table, never every window on the node.
4
+
5
+ import type { QueryEntry } from './query-window';
6
+
7
+ export class LiveEntries {
8
+ readonly #byQid = new Map<string, QueryEntry>();
9
+ /** `shape.entities` → the entries reading it. A shape is fixed at birth, so this never moves. */
10
+ readonly #byTable = new Map<string, Set<QueryEntry>>();
11
+
12
+ get size(): number {
13
+ return this.#byQid.size;
14
+ }
15
+
16
+ get(qid: string): QueryEntry | undefined {
17
+ return this.#byQid.get(qid);
18
+ }
19
+
20
+ values(): IterableIterator<QueryEntry> {
21
+ return this.#byQid.values();
22
+ }
23
+
24
+ add(entry: QueryEntry): void {
25
+ this.delete(entry.qid);
26
+ this.#byQid.set(entry.qid, entry);
27
+ for (const table of entry.shape.entities) {
28
+ let readers = this.#byTable.get(table);
29
+ if (readers === undefined) {
30
+ readers = new Set();
31
+ this.#byTable.set(table, readers);
32
+ }
33
+ readers.add(entry);
34
+ }
35
+ }
36
+
37
+ delete(qid: string): void {
38
+ const entry = this.#byQid.get(qid);
39
+ if (entry === undefined) return;
40
+ this.#byQid.delete(qid);
41
+ for (const table of entry.shape.entities) {
42
+ const readers = this.#byTable.get(table);
43
+ if (readers === undefined) continue;
44
+ readers.delete(entry);
45
+ // An emptied bucket goes too: a client-chosen table name must not outlive its windows.
46
+ if (readers.size === 0) this.#byTable.delete(table);
47
+ }
48
+ }
49
+
50
+ /** The windows that read `table` — `invalidate(entity)`'s set, and a change's first. */
51
+ readersOf(table: string): ReadonlySet<QueryEntry> {
52
+ return this.#byTable.get(table) ?? EMPTY;
53
+ }
54
+
55
+ /**
56
+ * Every window one change is work for: its table's readers, plus every window that owes a
57
+ * read whatever it reads — a `stale` one is repaired by the next change to reach it, and one no
58
+ * read has landed in yet marks itself stale so its in-flight read is followed by a fresh one
59
+ * (`live-fanout.ts`). Those two are a boolean pair per entry, read without a lane; the expensive
60
+ * part — a lane entered, a closure queued, a fanout awaited — is paid by these entries only.
61
+ * Measured: 10k windows on other tables, 34.6 ms → 0.23 ms per change (29 → 4,430/s).
62
+ */
63
+ targets(table: string): QueryEntry[] {
64
+ const readers = this.readersOf(table);
65
+ const out = [...readers];
66
+ for (const entry of this.#byQid.values()) {
67
+ if ((entry.stale || entry.applied === 0) && !readers.has(entry)) out.push(entry);
68
+ }
69
+ return out;
70
+ }
71
+ }
72
+
73
+ const EMPTY: ReadonlySet<QueryEntry> = new Set();
@@ -6,13 +6,19 @@
6
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
- import type { Row, RowPatch } from './json';
9
+ import type { LiveRow, RowPatch } from './json';
10
10
  import type { LiveSubscription } from './live-contract';
11
+ import { encodePatchFrame, samePatches } from './live-patch-encode';
11
12
  import { applyToWindow, bridgeChange, type Projection, projectionOf } from './matcher-bridge';
12
13
  import { type QueryEntry, refillWindowInLane } from './query-window';
13
- import { type Subscriber, type SubscriberGate, windowIndex } from './subscriber-gate';
14
+ import {
15
+ type Subscriber,
16
+ type SubscriberGate,
17
+ type WindowIndex,
18
+ windowIndex,
19
+ } from './subscriber-gate';
14
20
  import { subscriptionKey } from './subscription-book';
15
- import { type Frame, PROTOCOL_VERSION } from './sync-protocol';
21
+ import { encode, type Frame, PROTOCOL_VERSION } from './sync-protocol';
16
22
  import { FRAME_LIMITS } from './wire-version';
17
23
 
18
24
  export interface FanoutDeps {
@@ -101,8 +107,11 @@ export async function fanoutChange(
101
107
  else deps.source.floorAt?.(entry.qid, change.lsn, { exclusive: true });
102
108
 
103
109
  let sent = 0;
104
- // Indexed once for every subscriber below, never searched per subscriber per patch.
105
- const index = windowIndex(entry.rows);
110
+ // Indexed once for every subscriber below, never searched per subscriber per patch — and only
111
+ // once a subscriber reaches its gate: a change every subscriber is desynced for builds none.
112
+ let index: WindowIndex | null = null;
113
+ // The patch list as text, once per change for every subscriber the gate let see all of it.
114
+ let shared: string | null = null;
106
115
  for (const subscription of entry.subscribers.values()) {
107
116
  if (guessed) {
108
117
  // The window lost its tail: guessing is how a sync engine silently diverges. Checked BEFORE
@@ -126,6 +135,7 @@ export async function fanoutChange(
126
135
  }
127
136
  const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
128
137
  let allowed: readonly RowPatch[];
138
+ index ??= windowIndex(entry.rows);
129
139
  try {
130
140
  allowed = await deps.gate.filterPatches(
131
141
  entry,
@@ -143,8 +153,14 @@ export async function fanoutChange(
143
153
  continue;
144
154
  }
145
155
  if (allowed.length === 0) continue;
146
- const frame = patchFrame(subscription.sid, allowed, change.lsn, writes);
147
- if (subscription.socket.send(frame)) {
156
+ let text: string;
157
+ if (samePatches(allowed, result.patches)) {
158
+ shared ??= JSON.stringify(result.patches);
159
+ text = encodePatchFrame(subscription.sid, shared, change.lsn, writes);
160
+ } else {
161
+ text = encode(patchFrame(subscription.sid, allowed, change.lsn, writes));
162
+ }
163
+ if (subscription.socket.sendEncoded(text)) {
148
164
  subscription.cursor = advance(subscription.cursor, allowed, change.lsn, change.at);
149
165
  sent += 1;
150
166
  } else {
@@ -244,7 +260,7 @@ async function resnapshot(
244
260
  named: readonly NamedWrite[],
245
261
  ): Promise<boolean> {
246
262
  const who: Subscriber = { sid: subscription.sid, actor: subscription.socket.actor };
247
- let rows: readonly Row[];
263
+ let rows: readonly LiveRow[];
248
264
  try {
249
265
  rows = await deps.gate.filterRows(entry, who, entry.rows);
250
266
  } catch {
@@ -315,7 +331,7 @@ export interface NamedWrite {
315
331
  export function snapshotFrame(
316
332
  entry: QueryEntry,
317
333
  sid: string,
318
- rows: readonly Row[],
334
+ rows: readonly LiveRow[],
319
335
  cursor: LiveCursor,
320
336
  named: readonly NamedWrite[] = [],
321
337
  ): Frame {
@@ -0,0 +1,30 @@
1
+ // A patch frame as text, built around a patch list encoded ONCE for every subscriber of a change.
2
+ // The frame differs per subscriber by its `sid` alone when the gate let the whole list through, so
3
+ // stringifying the list per subscriber was the fanout's cost growing with every viewer.
4
+
5
+ import type { RowPatch } from './json';
6
+ import { PROTOCOL_VERSION } from './sync-protocol';
7
+
8
+ /**
9
+ * Byte-for-byte `encode(patchFrame(sid, patches, lsn, writes))` with `patches` already encoded:
10
+ * the same keys in the same order, and `JSON.stringify` is compositional with no indentation.
11
+ * Pinned against the unshared spelling by `live-fanout.test.ts`.
12
+ */
13
+ export function encodePatchFrame(
14
+ sid: string,
15
+ patchesJson: string,
16
+ lsn: string,
17
+ writes: readonly string[],
18
+ ): string {
19
+ const head = `{"type":"patch","v":${JSON.stringify(PROTOCOL_VERSION)},"sid":${JSON.stringify(sid)}`;
20
+ const tail = writes.length === 0 ? '' : `,"writes":${JSON.stringify(writes)}`;
21
+ return `${head},"patches":${patchesJson},"lsn":${JSON.stringify(lsn)}${tail}}`;
22
+ }
23
+
24
+ /** The gate let every patch through untouched: the shared encoding is this subscriber's too. */
25
+ export function samePatches(allowed: readonly RowPatch[], shared: readonly RowPatch[]): boolean {
26
+ if (allowed === shared) return true;
27
+ if (allowed.length !== shared.length) return false;
28
+ for (let at = 0; at < allowed.length; at += 1) if (allowed[at] !== shared[at]) return false;
29
+ return true;
30
+ }
@@ -34,8 +34,19 @@ export interface LiveQueryRegistryOptions extends SubscriberGateOptions {
34
34
  readonly readDeadlineMs?: number;
35
35
  /** Injected so that deadline is provable without waiting for one. Production uses `setTimeout`. */
36
36
  readonly schedule?: Scheduler;
37
+ /**
38
+ * Changes one window's lane may hold queued. Defaults to `DEFAULT_MAX_LANE_DEPTH`. Past it a
39
+ * change is SHED for that window: the window is marked stale, so the next change queued re-reads
40
+ * it and re-snapshots every subscriber — the documented gap recovery — and the shed is counted
41
+ * (`changesShed`). Without it one slow row policy queued a closure per change, unbounded.
42
+ * Floored at 2: the count includes the running task, and a shed needs one queued behind it.
43
+ */
44
+ readonly maxLaneDepth?: number;
37
45
  }
38
46
 
47
+ /** Changes one window's lane holds before shedding to a re-read. */
48
+ export const DEFAULT_MAX_LANE_DEPTH = 1_024;
49
+
39
50
  /**
40
51
  * Live `(query, input)` pairs one node holds. Reached, the next NEW pair is refused with
41
52
  * `X_SUBSCRIPTION_LIMIT`; subscribing to a pair that already exists keeps working, because the
package/src/live-query.ts CHANGED
@@ -13,7 +13,8 @@ import { type LiveCursor, makeCursor } from './cursor';
13
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 { deliverChange } from './live-deliver';
16
+ import { DeliveryCounts, deliverChange } from './live-deliver';
17
+ import { LiveEntries } from './live-entries';
17
18
  import { type FanoutDeps, repairSubscriber, repairWindow, snapshotFrame } from './live-fanout';
18
19
  import { floorAfterRead } from './live-floor';
19
20
  import { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
@@ -27,11 +28,16 @@ import { type Subscriber, SubscriberGate } from './subscriber-gate';
27
28
  import { SubscriptionBook, subscriptionKey } from './subscription-book';
28
29
  import type { Frame } from './sync-protocol';
29
30
 
30
- export { DEFAULT_MAX_ENTRIES, type LiveQueryRegistryOptions } from './live-query-options';
31
+ export {
32
+ DEFAULT_MAX_ENTRIES,
33
+ DEFAULT_MAX_LANE_DEPTH,
34
+ type LiveQueryRegistryOptions,
35
+ } from './live-query-options';
31
36
 
32
37
  export class LiveQueryRegistry {
33
38
  readonly #definitions = new Map<string, LiveQueryDefinition>();
34
- readonly #entries = new Map<string, QueryEntry>();
39
+ /** Indexed by the relations each window reads (`live-entries.ts`): a change is work for few. */
40
+ readonly #entries = new LiveEntries();
35
41
  /** Keyed by `(socket, sid)`, never by `sid` alone — `subscription-book.ts` owns why. */
36
42
  readonly #book: SubscriptionBook;
37
43
  readonly #options: LiveQueryRegistryOptions;
@@ -46,7 +52,7 @@ export class LiveQueryRegistry {
46
52
  * `!` sorts below every hex digit, so every real change is above it.
47
53
  */
48
54
  readonly #origin = `!${uuidV7()}`;
49
- #staleChanges = 0;
55
+ readonly #counts: DeliveryCounts;
50
56
 
51
57
  constructor(options: LiveQueryRegistryOptions) {
52
58
  this.#options = options;
@@ -60,6 +66,7 @@ export class LiveQueryRegistry {
60
66
  // The book owns the caps because it is the only thing that can answer them in O(1).
61
67
  this.#book = new SubscriptionBook(options);
62
68
  this.#fanout = { gate: this.#gate, source: options.source, clock: this.#clock };
69
+ this.#counts = new DeliveryCounts(options.maxLaneDepth);
63
70
  }
64
71
 
65
72
  /** `live.rows_denied` for this node: rows a subscriber's policy refused since boot. */
@@ -74,7 +81,12 @@ export class LiveQueryRegistry {
74
81
 
75
82
  /** `live.changes_stale`: changes at or below a window's own lsn, refused rather than folded. */
76
83
  get staleChanges(): number {
77
- return this.#staleChanges;
84
+ return this.#counts.staleChanges;
85
+ }
86
+
87
+ /** `live.changes_shed`: window deliveries a full lane refused, each repaired by a re-read. */
88
+ get changesShed(): number {
89
+ return this.#counts.changesShed;
78
90
  }
79
91
 
80
92
  /**
@@ -93,8 +105,8 @@ export class LiveQueryRegistry {
93
105
  */
94
106
  invalidate(entity?: string): number {
95
107
  let marked = 0;
96
- for (const entry of this.#entries.values()) {
97
- if (entity !== undefined && !entry.shape.entities.includes(entity)) continue;
108
+ const entries = entity === undefined ? this.#entries.values() : this.#entries.readersOf(entity);
109
+ for (const entry of entries) {
98
110
  entry.stale = true;
99
111
  for (const subscription of entry.subscribers.values()) {
100
112
  subscription.socket.markDesynced(subscription.sid);
@@ -333,9 +345,12 @@ export class LiveQueryRegistry {
333
345
  /** Fan one change out, every query id in its own lane — `live-deliver.ts` owns why. */
334
346
  async deliver(change: ChangeEvent): Promise<number> {
335
347
  if (change.lsn > this.#lastLsn) this.#lastLsn = change.lsn;
336
- return await deliverChange(this.#entries.values(), this.#fanout, change, (stale) => {
337
- this.#staleChanges += stale;
338
- });
348
+ return await deliverChange(
349
+ this.#entries.targets(change.table),
350
+ this.#fanout,
351
+ change,
352
+ this.#counts,
353
+ );
339
354
  }
340
355
 
341
356
  /**
@@ -436,7 +451,7 @@ export class LiveQueryRegistry {
436
451
  schedule: this.#options.schedule,
437
452
  tenant,
438
453
  });
439
- this.#entries.set(qid, created);
454
+ this.#entries.add(created);
440
455
  return created;
441
456
  }
442
457