@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
@@ -0,0 +1,75 @@
1
+ // One principal's open sockets on this node, counted so the upgrade can refuse the next one. The
2
+ // per-actor SUBSCRIPTION cap bounds what a principal holds; this bounds how many sockets it holds it
3
+ // across — each one a grant, a frame budget, a heartbeat and a slot of `maxConnections`.
4
+
5
+ import { ConfigInvalidError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * Sixteen sockets at the per-socket 128 subscriptions is 2,048 — past the per-actor 1,000, so a
9
+ * legitimate principal's tabs never hit this before they hit the subscription cap, while one
10
+ * principal can no longer take a node's `maxConnections` for itself.
11
+ */
12
+ export const DEFAULT_MAX_SOCKETS_PER_ACTOR = 16;
13
+
14
+ /**
15
+ * An ANONYMOUS principal is a network, not a person: an office behind one corporate NAT viewing a
16
+ * public live page is one `address:` key, and held to an actor's 16 it was 16 sockets for the whole
17
+ * floor. A network gets this many actors' worth — 128 at the default — while a signed-in actor keeps
18
+ * `maxSocketsPerActor`. The subscription cap is not multiplied.
19
+ */
20
+ export const ANONYMOUS_SOCKET_MULTIPLIER = 8;
21
+
22
+ const isNetwork = (principal: string): boolean => principal.startsWith('address:');
23
+
24
+ /** A COUNT of at least 1, refused as the config key of the same name is. */
25
+ function socketCeiling(value: number | undefined): number {
26
+ const max = value ?? DEFAULT_MAX_SOCKETS_PER_ACTOR;
27
+ if (Number.isSafeInteger(max) && max >= 1) return max;
28
+ throw new ConfigInvalidError({
29
+ cause: `maxSocketsPerActor (realtime.maxSocketsPerActor) must be a whole number of at least 1, not ${String(max)} — 0 refuses every upgrade, and a fraction or a NaN is a cap no count ever reaches`,
30
+ fix: `syncNode({ maxSocketsPerActor: ${String(DEFAULT_MAX_SOCKETS_PER_ACTOR)} }) # or realtime: { maxSocketsPerActor: ${String(DEFAULT_MAX_SOCKETS_PER_ACTOR)} } in app.config.ts`,
31
+ meta: { key: 'realtime.maxSocketsPerActor', value: max },
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Taken at the upgrade — synchronously, right before `server.upgrade`, so a herd parked in
37
+ * `authenticate` cannot all pass one count — and given back on EVERY path a socket ends: its
38
+ * close, and an upgrade that threw or declined after the slot was taken. `release` is idempotent.
39
+ */
40
+ export class PrincipalSockets {
41
+ readonly max: number;
42
+ readonly #bySocket = new Map<string, string>();
43
+ readonly #count = new Map<string, number>();
44
+
45
+ constructor(max: number | undefined) {
46
+ this.max = socketCeiling(max);
47
+ }
48
+
49
+ /** Admit `socketId` under `principal`, or `false` when that principal is at its cap. */
50
+ admit(socketId: string, principal: string): boolean {
51
+ const held = this.#count.get(principal) ?? 0;
52
+ if (held >= this.limitFor(principal)) return false;
53
+ this.#bySocket.set(socketId, principal);
54
+ this.#count.set(principal, held + 1);
55
+ return true;
56
+ }
57
+
58
+ /** The cap `principal` is held to: `max` for an actor, `max × 8` for an anonymous network. */
59
+ limitFor(principal: string): number {
60
+ return isNetwork(principal) ? this.max * ANONYMOUS_SOCKET_MULTIPLIER : this.max;
61
+ }
62
+
63
+ release(socketId: string): void {
64
+ const principal = this.#bySocket.get(socketId);
65
+ if (principal === undefined) return;
66
+ this.#bySocket.delete(socketId);
67
+ const next = (this.#count.get(principal) ?? 0) - 1;
68
+ if (next > 0) this.#count.set(principal, next);
69
+ else this.#count.delete(principal);
70
+ }
71
+
72
+ count(principal: string): number {
73
+ return this.#count.get(principal) ?? 0;
74
+ }
75
+ }
@@ -80,7 +80,7 @@ export interface PendingRead {
80
80
  readonly result: Promise<SnapshotResult>;
81
81
  }
82
82
 
83
- export function createEntry(
83
+ export function queryEntry(
84
84
  qid: string,
85
85
  definition: LiveQueryDefinition,
86
86
  input: JsonValue,
@@ -131,9 +131,8 @@ export function createEntry(
131
131
  * had already been fanned out would rewind every later subscriber to rows the window has moved
132
132
  * past, so a stale read is discarded and its caller is served from the newer window instead.
133
133
  */
134
- export async function fillWindow(
135
- entry: QueryEntry,
136
- ): Promise<{ rows: readonly Row[]; lsn: string }> {
134
+ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
135
+ let landed: FilledWindow['landed'] = null;
137
136
  for (let attempt = 0; ; attempt += 1) {
138
137
  // Read before `startRead` clears it: a second caller arriving during the read joins it and is
139
138
  // not the one that forced it, which is what keeps one forced read from becoming N.
@@ -155,15 +154,28 @@ export async function fillWindow(
155
154
  const first = entry.applied === 0;
156
155
  if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
157
156
  applyRead(entry, pending, result);
157
+ // A forced read outranks a first one: what it replaced was known to be wrong.
158
+ landed = forced || landed === 'forced' ? 'forced' : first ? 'first' : landed;
158
159
  }
159
160
  // A change reached this window while its first read was in flight and could not be folded,
160
161
  // so what just landed may predate it: read once more before serving anyone a partial window.
161
162
  return first && entry.stale && attempt < COLD_REREADS;
162
163
  });
163
- if (!again) return { rows: entry.rows, lsn: entry.lsn };
164
+ if (!again) return { rows: entry.rows, lsn: entry.lsn, landed };
164
165
  }
165
166
  }
166
167
 
168
+ /**
169
+ * The window a caller is served from, and whether THIS call put a read into it. `landed` is what
170
+ * decides the retained ring's floor (`change-buffer.ts`): `null` is a window that was already
171
+ * filled — its patch history is still true, and flooring it would evict every other subscriber's.
172
+ */
173
+ export interface FilledWindow {
174
+ readonly rows: readonly Row[];
175
+ readonly lsn: string;
176
+ readonly landed: 'first' | 'forced' | null;
177
+ }
178
+
167
179
  /**
168
180
  * How many times a cold window re-reads because writes kept landing during its read. Bounded: a
169
181
  * table written faster than it can be read would otherwise never serve a subscriber, and after the
@@ -88,10 +88,15 @@ export function recordPersister(options: RecordPersisterOptions): RecordPersiste
88
88
  });
89
89
 
90
90
  const restore = async (): Promise<number> => {
91
- const scope = scopeKey(principal());
91
+ const owner = principal();
92
+ const scope = scopeKey(owner);
92
93
  if (scope === undefined) return 0;
94
+ const stored = await options.local.rows(scope);
95
+ // The read is async and a principal change can land inside it: its rescope has already cleared
96
+ // the store, so restoring now would hand the previous principal's rows to the next one.
97
+ if (principal() !== owner) return 0;
93
98
  let restored = 0;
94
- for (const [type, rows] of await options.local.rows(scope)) {
99
+ for (const [type, rows] of stored) {
95
100
  if (!options.types.has(type)) continue;
96
101
  options.store.restore(type, rows);
97
102
  restored += Object.keys(rows).length;
@@ -0,0 +1,178 @@
1
+ // Committed rows an app hands over → change envelopes on the subject every sync node reads, for a
2
+ // deployment that runs no replicator. Channels only: a publisher has no commit position, so its
3
+ // changes feed the declared channels and never a live window (`sync-bus-handlers.ts`). One per
4
+ // process, built at boot: it is one producer, and its seq is what a node sees a lost message in.
5
+
6
+ import {
7
+ assert,
8
+ type Clock,
9
+ currentWriteOrigin,
10
+ isWriteDigest,
11
+ systemClock,
12
+ uuidV7,
13
+ } from '@ultimat3/core';
14
+ import { type RecordProjection, recordProjection } from '@ultimat3/entity/record';
15
+ import { type ChangeEvent, formatLsn } from './changefeed';
16
+ import type { ChannelEntity } from './channel-decl';
17
+ import type { Transport } from './fanout';
18
+ import type { Row } from './json';
19
+ import { changeSubject } from './replicator';
20
+ import { encodeEnvelope } from './replicator-envelope';
21
+
22
+ export interface RecordPublisherOptions {
23
+ readonly transport: Transport;
24
+ /**
25
+ * Every entity this process publishes, claimed until `close()`. Claimed, `x dev`'s in-process
26
+ * bridge stops carrying that entity's repository writes to channels — the app's publish is then
27
+ * the one delivery, exactly as it is in production with no replicator.
28
+ */
29
+ readonly entities: readonly ChannelEntity[];
30
+ /** Tenant column, hoisted into the subject as the replicator hoists it. Default `orgId`. */
31
+ readonly tenantColumn?: string;
32
+ readonly clock?: Clock;
33
+ }
34
+
35
+ export interface PublishOptions {
36
+ /** `upsert` (default): the rows are adopted. `delete`: the rows are gone, by their key. */
37
+ readonly op?: 'upsert' | 'delete';
38
+ /**
39
+ * The keyed write these rows belong to — `writeDigest(idempotencyKey)` — so the page that made
40
+ * it settles its own echo. Default: the keyed request this call runs inside, else `null`.
41
+ */
42
+ readonly write?: string | null;
43
+ }
44
+
45
+ export interface RecordPublisher extends Disposable {
46
+ /** This publisher's producer id: the stream its `seq` counts in. */
47
+ readonly producer: string;
48
+ /**
49
+ * Put COMMITTED rows on the bus, one change per row, in call order. Call it after the
50
+ * transaction commits: a row published from inside one is on every page before a rollback.
51
+ * Rejects with the first refusal once every row was tried; a refused row's seq stays spent, so
52
+ * every node reads the hole and its channel members re-run their catch-up read.
53
+ */
54
+ publish(
55
+ entity: ChannelEntity,
56
+ rows: readonly Readonly<Record<string, unknown>>[],
57
+ options?: PublishOptions,
58
+ ): Promise<void>;
59
+ /** Releases the claim. Idempotent; a closed publisher refuses to publish. */
60
+ close(): void;
61
+ }
62
+
63
+ /** Tables a live publisher claims, counted: two publishers may claim one. */
64
+ const claims = new Map<string, number>();
65
+
66
+ /**
67
+ * Whether a `recordPublisher` in this process claims `table` — the relation, the vocabulary of
68
+ * `ChangeEvent.table` on every producer.
69
+ */
70
+ export function isPublishedTable(table: string): boolean {
71
+ return claims.has(table);
72
+ }
73
+
74
+ export function recordPublisher(options: RecordPublisherOptions): RecordPublisher {
75
+ const clock = options.clock ?? systemClock;
76
+ const tenant = options.tenantColumn ?? 'orgId';
77
+ const projections = new Map<string, RecordProjection>();
78
+ for (const declared of options.entities) {
79
+ const projection = recordProjection(declared);
80
+ projections.set(projection.type, projection);
81
+ }
82
+ const names = [...projections.values()].map((projection) => projection.table);
83
+ for (const name of names) claims.set(name, (claims.get(name) ?? 0) + 1);
84
+ const producer = uuidV7();
85
+ let seq = 0;
86
+ let open = true;
87
+ // One chain, so seq order is bus order whatever the caller awaits: a node reads a seq that
88
+ // arrives out of order as a hole that was never there.
89
+ let tail: Promise<unknown> = Promise.resolve();
90
+
91
+ const close = (): void => {
92
+ if (!open) return;
93
+ open = false;
94
+ for (const name of names) {
95
+ const held = (claims.get(name) ?? 1) - 1;
96
+ if (held > 0) claims.set(name, held);
97
+ else claims.delete(name);
98
+ }
99
+ };
100
+
101
+ const changeOf = (
102
+ projection: RecordProjection,
103
+ row: Readonly<Record<string, unknown>>,
104
+ op: 'upsert' | 'delete',
105
+ write: string | null,
106
+ ): ChangeEvent => {
107
+ // The record's own properties and nothing else: the entity's record schema omits every sealed
108
+ // column, so neither a secret nor a property the entity never declared can ride.
109
+ const columns = projection.schema.properties ?? {};
110
+ const image = Object.fromEntries(
111
+ Object.entries(row).filter(([property]) => Object.hasOwn(columns, property)),
112
+ ) as Row;
113
+ // Refused here, before any row of the call is sequenced: a keyless row matches no record.
114
+ projection.key(image);
115
+ const orgId = image[tenant];
116
+ return {
117
+ table: projection.table,
118
+ op: op === 'delete' ? 'delete' : 'update',
119
+ before: op === 'delete' ? image : null,
120
+ after: op === 'delete' ? null : image,
121
+ // Stamped with the seq at send: it orders nothing outside this producer, which is why no
122
+ // live window is fed one.
123
+ lsn: '',
124
+ txid: producer,
125
+ orgId: typeof orgId === 'string' ? orgId : null,
126
+ at: clock.now().getTime(),
127
+ write,
128
+ };
129
+ };
130
+
131
+ const send = async (change: ChangeEvent): Promise<void> => {
132
+ seq += 1;
133
+ await options.transport.publish(
134
+ changeSubject(change),
135
+ encodeEnvelope({ ...change, lsn: formatLsn(seq) }, seq, producer, 'publisher'),
136
+ );
137
+ };
138
+
139
+ const publish: RecordPublisher['publish'] = async (entity, rows, publishOptions = {}) => {
140
+ assert(
141
+ open,
142
+ `recordPublisher ${producer} was closed and published "${entity.$name}" after it`,
143
+ 'build one recordPublisher per process at boot and close it at shutdown',
144
+ );
145
+ const projection = projections.get(entity.$name);
146
+ assert(
147
+ projection !== undefined,
148
+ `recordPublisher was asked to publish "${entity.$name}", which it did not claim`,
149
+ `add ${entity.$name} to recordPublisher({ entities: [...] }) where the publisher is built`,
150
+ );
151
+ const write =
152
+ publishOptions.write === undefined ? (currentWriteOrigin() ?? null) : publishOptions.write;
153
+ assert(
154
+ write === null || isWriteDigest(write),
155
+ `publish({ write }) for "${entity.$name}" is not a write digest`,
156
+ "pass writeDigest(idempotencyKey) from '@ultimat3/core', or null for a write no request made",
157
+ );
158
+ const op = publishOptions.op ?? 'upsert';
159
+ const changes = rows.map((row) => changeOf(projection, row, op, write));
160
+ const sent = tail.then(async () => {
161
+ // Every row the bus takes goes, a refused one in the middle included: its spent seq is the
162
+ // hole the nodes repair, and the caller hears the first refusal once the rest are out.
163
+ let refused: { readonly error: unknown } | undefined;
164
+ for (const change of changes) {
165
+ try {
166
+ await send(change);
167
+ } catch (error) {
168
+ refused ??= { error };
169
+ }
170
+ }
171
+ if (refused !== undefined) throw refused.error;
172
+ });
173
+ tail = sent.catch(() => undefined);
174
+ await sent;
175
+ };
176
+
177
+ return { producer, publish, close, [Symbol.dispose]: close };
178
+ }
@@ -61,16 +61,21 @@ export class RecordStore implements RecordSink {
61
61
 
62
62
  /** Every record of one type, as seen — what a mutator's `tx.<type>.all()` walks. */
63
63
  all(type: string): readonly Row[] {
64
+ return this.keyed(type).map(([, row]) => row);
65
+ }
66
+
67
+ /** `all`, with each record's key — what a whole-type reader orders by. */
68
+ keyed(type: string): readonly (readonly [string, Row])[] {
64
69
  const prefix = `${type}:`;
65
- const out: Row[] = [];
70
+ const out: (readonly [string, Row])[] = [];
66
71
  const seen = new Set<RecordKey>();
67
72
  for (const [rk, row] of this.#view) {
68
73
  if (!rk.startsWith(prefix)) continue;
69
74
  seen.add(rk);
70
- if (row !== null) out.push(row);
75
+ if (row !== null) out.push([rk.slice(prefix.length), row]);
71
76
  }
72
77
  for (const [rk, row] of this.#synced.entries()) {
73
- if (rk.startsWith(prefix) && !seen.has(rk)) out.push(row);
78
+ if (rk.startsWith(prefix) && !seen.has(rk)) out.push([rk.slice(prefix.length), row]);
74
79
  }
75
80
  return out;
76
81
  }
@@ -153,6 +158,18 @@ export class RecordStore implements RecordSink {
153
158
  if (this.#synced.release(rk)) this.#touchSynced(rk);
154
159
  }
155
160
 
161
+ /** Every record of `type` is shown by one reader: none is evicted while it is held. */
162
+ retainType(type: string): void {
163
+ this.#synced.retainType(type);
164
+ }
165
+
166
+ /** The last type hold leaving evicts the rows it alone kept (`SyncedLayer.releaseType`). */
167
+ releaseType(type: string): void {
168
+ this.batch(() => {
169
+ for (const rk of this.#synced.releaseType(type)) this.#touchSynced(rk);
170
+ });
171
+ }
172
+
156
173
  /**
157
174
  * An optimistic write, visible to every holder before this returns. `apply` is the mutator's
158
175
  * pure `local` half: it is REPLAYED over synced truth whenever synced truth moves, which is what
@@ -342,9 +359,13 @@ export class RecordStore implements RecordSink {
342
359
  this.#touched = result.touched;
343
360
  }
344
361
 
345
- /** The one way an overlay leaves: its entry and the digest that named it go together. */
362
+ /**
363
+ * The one way an overlay leaves: its entry, the digest that named it and its server wait go
364
+ * together. A wait left behind ran for its whole bound after a later settle removed the overlay.
365
+ */
346
366
  #forget(key: string): boolean {
347
367
  this.#names.forget(key);
368
+ this.#wait.forget(key);
348
369
  return this.#overlays.delete(key);
349
370
  }
350
371
 
@@ -8,6 +8,10 @@ import type { RecordKey } from './record-key';
8
8
  export class SyncedLayer {
9
9
  readonly #rows = new Map<RecordKey, Row>();
10
10
  readonly #holds = new Map<RecordKey, number>();
11
+ /** Types a whole-type reader holds (`useRecords(type, selection)`): none of their rows is evicted. */
12
+ readonly #typeHolds = new Map<string, number>();
13
+ /** Per held type, the rows whose last key holder left while it was held: the hold's to evict. */
14
+ readonly #spared = new Map<string, Set<RecordKey>>();
11
15
  /** Keys restored from disk and not yet confirmed: the first server row REPLACES, never merges. */
12
16
  readonly #provisional = new Set<RecordKey>();
13
17
 
@@ -74,6 +78,8 @@ export class SyncedLayer {
74
78
  /** Answers whether a row was there to remove. */
75
79
  remove(rk: RecordKey): boolean {
76
80
  this.#provisional.delete(rk);
81
+ // Gone is no longer spared: a row the server sends again later is a new arrival, not this one.
82
+ this.#spared.get(rk.slice(0, rk.indexOf(':')))?.delete(rk);
77
83
  return this.#rows.delete(rk);
78
84
  }
79
85
 
@@ -81,7 +87,10 @@ export class SyncedLayer {
81
87
  this.#holds.set(rk, (this.#holds.get(rk) ?? 0) + 1);
82
88
  }
83
89
 
84
- /** The last holder leaving evicts the row. Answers whether a row went. */
90
+ /**
91
+ * The last holder leaving evicts the row — unless a reader holds its whole type, which shows
92
+ * every row of it and would lose this one to another holder's unmount. Answers whether a row went.
93
+ */
85
94
  release(rk: RecordKey): boolean {
86
95
  const holds = this.#holds.get(rk);
87
96
  if (holds === undefined) return false;
@@ -90,11 +99,44 @@ export class SyncedLayer {
90
99
  return false;
91
100
  }
92
101
  this.#holds.delete(rk);
102
+ const type = rk.slice(0, rk.indexOf(':'));
103
+ if (this.#typeHolds.has(type)) {
104
+ const spared = this.#spared.get(type) ?? new Set<RecordKey>();
105
+ spared.add(rk);
106
+ this.#spared.set(type, spared);
107
+ return false;
108
+ }
93
109
  return this.remove(rk);
94
110
  }
95
111
 
112
+ retainType(type: string): void {
113
+ this.#typeHolds.set(type, (this.#typeHolds.get(type) ?? 0) + 1);
114
+ }
115
+
116
+ /**
117
+ * The last type hold leaving evicts what it spared — each row whose last key holder left while
118
+ * the type was held and that no key holder has taken since — exactly as that release would have.
119
+ * A row no key holder ever held is not the hold's to evict. Answers the rows that went.
120
+ */
121
+ releaseType(type: string): readonly RecordKey[] {
122
+ const holds = this.#typeHolds.get(type) ?? 0;
123
+ if (holds > 1) {
124
+ this.#typeHolds.set(type, holds - 1);
125
+ return [];
126
+ }
127
+ this.#typeHolds.delete(type);
128
+ const spared = this.#spared.get(type);
129
+ this.#spared.delete(type);
130
+ const evicted: RecordKey[] = [];
131
+ for (const rk of spared ?? []) {
132
+ if (!this.#holds.has(rk) && this.remove(rk)) evicted.push(rk);
133
+ }
134
+ return evicted;
135
+ }
136
+
96
137
  clear(): void {
97
138
  this.#rows.clear();
98
139
  this.#provisional.clear();
140
+ this.#spared.clear();
99
141
  }
100
142
  }
@@ -17,10 +17,14 @@ export class ReplicationProtocolError extends RealtimeError {
17
17
  constructor(args: { stage: string; detail: string; fix?: string }) {
18
18
  super({
19
19
  code: 'X_REPLICATION_PROTOCOL',
20
- cause: `postgres replication ${args.stage}: ${args.detail}`,
21
- fix:
22
- args.fix ??
23
- 'x doctor db — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical',
20
+ // The requirement rides the cause when the caller names no fix of its own: a `fix:` is one
21
+ // command, and `x doctor` has no `db` subcommand for the old line to have been one.
22
+ cause:
23
+ `postgres replication ${args.stage}: ${args.detail}` +
24
+ (args.fix === undefined
25
+ ? ' — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical'
26
+ : ''),
27
+ fix: args.fix ?? 'x doctor --json',
24
28
  });
25
29
  }
26
30
  }
@@ -59,8 +63,8 @@ export class ReplicationTlsError extends RealtimeError {
59
63
  /**
60
64
  * A second replicator found the advisory lock held. Distinct from `X_REPLICATION_FAILED` because
61
65
  * nothing is wrong with this process: the database already has its one replicator, and a second
62
- * one that started anyway would publish every change twice. Terminal for a container whose whole
63
- * job is that role — the scheduler is the thing that has to change, not the connection.
66
+ * one that started anyway would publish every change twice. Thrown only under `x dev`: a container
67
+ * that loses the lock stays up unready and takes over when it frees (`cli/src/role-replicator.ts`).
64
68
  */
65
69
  export class ReplicatorSlotHeldError extends RealtimeError {
66
70
  constructor(args: { key: string; holder?: string | undefined }) {
@@ -69,7 +73,7 @@ export class ReplicatorSlotHeldError extends RealtimeError {
69
73
  cause:
70
74
  `advisory lock ${args.key} is held${args.holder === undefined ? '' : ` by ${args.holder}`}` +
71
75
  ' — one database has exactly one replicator',
72
- fix: 'scale the replicator to 1 per database: kubectl scale deploy/replicator --replicas=1',
76
+ fix: 'x dev --role replicator # rerun after the replicator already on this DATABASE_URL stops — one database has one replicator',
73
77
  });
74
78
  }
75
79
  }
@@ -0,0 +1,160 @@
1
+ // The bus envelope, both sides: the one encoder every producer writes with (`replicator.ts`,
2
+ // `record-publisher.ts`), its decode back into a `ChangeEvent` (rows revived through their entity's
3
+ // columns) with the producer and sequence a lost message is visible in, and the detector a `sync`
4
+ // node runs over them.
5
+
6
+ import { isWriteDigest } from '@ultimat3/core';
7
+ import type { ChangeEvent } from './changefeed';
8
+ import { busRow, reviveBusRow } from './replicator-row';
9
+
10
+ /**
11
+ * What the bus actually carries: one change, plus who published it and where in that publisher's
12
+ * stream it sits.
13
+ *
14
+ * Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
15
+ * cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
16
+ * cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
17
+ * arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
18
+ *
19
+ * Both fields are optional on the wire, so a node reading a publisher that predates them simply
20
+ * detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
21
+ */
22
+ export interface ChangeEnvelope {
23
+ readonly change: ChangeEvent;
24
+ /** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
25
+ readonly seq: number | null;
26
+ /** Identifies one replicator *run*. A new one restarts `seq` — and follows a stream that died. */
27
+ readonly producer: string | null;
28
+ /**
29
+ * Who sequenced it. Absent is the replicator (every publisher that predates the field). A
30
+ * `recordPublisher` says `'publisher'`: its changes carry no commit position, so they feed the
31
+ * declared channels and never a live window, and several publishers run side by side.
32
+ */
33
+ readonly source?: EnvelopeSource;
34
+ }
35
+
36
+ /** The two producers of the change bus: the one WAL replicator, and an app's record publishers. */
37
+ export type EnvelopeSource = 'replicator' | 'publisher';
38
+
39
+ /**
40
+ * Producer side: one change as the bus carries it. Flat — the change plus `seq`, `producer` and,
41
+ * for a record publisher, `source` — so a consumer that only knows the change reads it unchanged
42
+ * (`parseChange`). Rows cross as `busRow` writes them: bytes as base64, revived by the entity.
43
+ */
44
+ export function encodeEnvelope(
45
+ change: ChangeEvent,
46
+ seq: number,
47
+ producer: string,
48
+ source?: EnvelopeSource,
49
+ ): string {
50
+ return JSON.stringify({
51
+ ...change,
52
+ before: busRow(change.before),
53
+ after: busRow(change.after),
54
+ seq,
55
+ producer,
56
+ ...(source === 'publisher' ? { source } : {}),
57
+ });
58
+ }
59
+
60
+ /** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
61
+ export function parseChange(payload: string): ChangeEvent | null {
62
+ return parseEnvelope(payload)?.change ?? null;
63
+ }
64
+
65
+ /** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
66
+ export function parseEnvelope(payload: string): ChangeEnvelope | null {
67
+ try {
68
+ const parsed: unknown = JSON.parse(payload);
69
+ if (typeof parsed !== 'object' || parsed === null) return null;
70
+ const shape = parsed as Partial<ChangeEvent> & {
71
+ seq?: unknown;
72
+ producer?: unknown;
73
+ source?: unknown;
74
+ entity?: unknown;
75
+ };
76
+ // `entity` is the field a 24.x replicator wrote the table under. Read, never written: across a
77
+ // rolling deploy to 25.0.0 the replicator and the sync nodes run different builds for minutes,
78
+ // and a node refusing every change of the old one is every live view frozen for that window.
79
+ const table = typeof shape.table === 'string' ? shape.table : shape.entity;
80
+ if (typeof table !== 'string' || typeof shape.lsn !== 'string') return null;
81
+ if (
82
+ shape.op !== 'insert' &&
83
+ shape.op !== 'update' &&
84
+ shape.op !== 'delete' &&
85
+ shape.op !== 'truncate'
86
+ ) {
87
+ return null;
88
+ }
89
+ return {
90
+ change: {
91
+ table,
92
+ op: shape.op,
93
+ before: reviveBusRow(table, shape.before ?? null),
94
+ after: reviveBusRow(table, shape.after ?? null),
95
+ lsn: shape.lsn,
96
+ txid: typeof shape.txid === 'string' ? shape.txid : '',
97
+ orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
98
+ at: typeof shape.at === 'number' ? shape.at : 0,
99
+ // Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
100
+ // label here would cost every member the whole frame rather than one page its echo.
101
+ write: isWriteDigest(shape.write) ? shape.write : null,
102
+ ...(isNames(shape.omitted) ? { omitted: shape.omitted } : {}),
103
+ },
104
+ seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
105
+ producer: typeof shape.producer === 'string' ? shape.producer : null,
106
+ ...(shape.source === 'publisher' ? { source: 'publisher' as const } : {}),
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** A non-empty list of property names — the only shape `ChangeEvent.omitted` crosses the bus in. */
114
+ const isNames = (value: unknown): value is readonly string[] =>
115
+ Array.isArray(value) && value.length > 0 && value.every((name) => typeof name === 'string');
116
+
117
+ /**
118
+ * The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
119
+ * say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
120
+ * there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
121
+ * simply never happened as far as every subscriber on this node could tell.
122
+ *
123
+ * Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
124
+ * reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
125
+ * the first message this node ever reads: a node that joined late has missed everything by
126
+ * definition, and every subscription it holds started after it did.
127
+ *
128
+ * A NEW producer after a known one IS a gap. The run before it died — that is why there is a new
129
+ * one — and nothing says its last messages reached this node: the tail of a dead stream has no
130
+ * later sequence number to be missed against. One re-read per replicator restart is the price.
131
+ *
132
+ * Only a REPLICATOR run succeeds another. Record publishers (`source: 'publisher'`) run side by
133
+ * side, one per app process, so a new one says nothing about the others; and a replicator run
134
+ * after a publisher is no successor either. A hole inside any one producer's sequence is a gap.
135
+ */
136
+ export class SeqGapDetector {
137
+ readonly #next = new Map<string, number>();
138
+ /** Replicator runs read — the producers a NEW replicator run succeeds. */
139
+ readonly #runs = new Set<string>();
140
+
141
+ /** `true` when at least one message between the last one and this one was never delivered. */
142
+ observe(envelope: ChangeEnvelope): boolean {
143
+ const { producer, seq } = envelope;
144
+ if (producer === null || seq === null) return false;
145
+ const expected = this.#next.get(producer);
146
+ const replicator = envelope.source !== 'publisher';
147
+ const succeeds = replicator && expected === undefined && this.#runs.size > 0;
148
+ if (replicator) this.#runs.add(producer);
149
+ // Never backwards: a redelivery must not lower the bar and turn the next legitimate message
150
+ // into a gap of its own.
151
+ this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
152
+ return succeeds || (expected !== undefined && seq > expected);
153
+ }
154
+
155
+ /** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
156
+ forget(): void {
157
+ this.#next.clear();
158
+ this.#runs.clear();
159
+ }
160
+ }