@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
@@ -2,7 +2,7 @@
2
2
  // queries, and the CopyBoth switch that a replication stream lives inside. It speaks only in
3
3
  // messages, never in bytes on a socket, so the whole handshake is driven by hand in the tests.
4
4
 
5
- import { logger } from '@ultimat3/core';
5
+ import { logger, renderThrowable } from '@ultimat3/core';
6
6
  import { ReplicationFailedError, ReplicationProtocolError } from './errors';
7
7
  import {
8
8
  chooseMechanism,
@@ -12,6 +12,7 @@ import {
12
12
  scramSession,
13
13
  } from './pg-auth';
14
14
  import { ByteReader } from './pg-bytes';
15
+ import { answersWeakAuth, type SslMode } from './pg-tls';
15
16
  import {
16
17
  copyDoneMessage,
17
18
  frame,
@@ -42,6 +43,11 @@ export interface PgConnectionOptions {
42
43
  readonly applicationName?: string | undefined;
43
44
  /** Injected so the SCRAM nonce is deterministic under a seeded test. */
44
45
  readonly rng?: Rng | undefined;
46
+ /**
47
+ * The `sslmode` the stream was dialled under — what decides whether a cleartext or md5 password
48
+ * request is answered (`answersWeakAuth`). Unstated is `prefer`, which refuses both.
49
+ */
50
+ readonly ssl?: SslMode | undefined;
45
51
  }
46
52
 
47
53
  /** A result set as text, exactly as the wire carries it. `null` is SQL NULL, never `''`. */
@@ -61,12 +67,30 @@ const needPassword = (method: string): ReplicationFailedError =>
61
67
  fix: 'put the credentials in the URL: postgres://user:password@host:5432/db',
62
68
  });
63
69
 
70
+ /**
71
+ * The server asked for the password in a form the network can use, on a session nothing
72
+ * guarantees is encrypted. Refused before a byte of it is written.
73
+ */
74
+ const weakAuthRefused = (method: string, ssl: SslMode | undefined): ReplicationFailedError =>
75
+ new ReplicationFailedError({
76
+ stage: 'auth',
77
+ detail:
78
+ `the server asked for ${method} and sslmode=${ssl ?? 'prefer'} does not guarantee an ` +
79
+ 'encrypted session — connect with ?sslmode=require (or verify-full), move the role to ' +
80
+ 'scram-sha-256, or state ?sslmode=disable to accept a cleartext session',
81
+ fix: 'x doctor --json',
82
+ });
83
+
64
84
  export class PgConnection {
65
85
  readonly #stream: PgStream;
66
86
  readonly #reader: MessageReader;
67
87
  readonly #parameters = new Map<string, string>();
68
88
  #copyBoth = false;
69
89
  #closed = false;
90
+ /** The read parked on an idle session, if one is — see `watchIdle`. A query takes it over. */
91
+ #idle: Promise<PgMessage | undefined> | undefined;
92
+ #onIdleEnd: ((reason: string) => void) | undefined;
93
+ #querying = false;
70
94
 
71
95
  private constructor(stream: PgStream) {
72
96
  this.#stream = stream;
@@ -123,26 +147,82 @@ export class PgConnection {
123
147
 
124
148
  /** One simple query. Returns the rows as text; a `CommandComplete` with no rows returns `[]`. */
125
149
  async query(sql: string): Promise<PgRows> {
126
- await this.#stream.write(queryMessage(sql));
127
- const rows: (readonly (string | null)[])[] = [];
128
- for (;;) {
129
- const message = await this.#expect('query');
130
- switch (message.tag) {
131
- case 'D':
132
- rows.push(dataRow(message.body));
133
- break;
134
- case 'E':
135
- // Drain to `ReadyForQuery` first: leaving the session mid-result desynchronises reuse.
136
- await this.#drainToReady();
137
- throw serverError('query', message.body);
138
- case 'Z':
139
- return rows;
140
- default:
141
- this.#note(message);
150
+ this.#querying = true;
151
+ try {
152
+ await this.#stream.write(queryMessage(sql));
153
+ const rows: (readonly (string | null)[])[] = [];
154
+ for (;;) {
155
+ const message = await this.#expect('query');
156
+ switch (message.tag) {
157
+ case 'D':
158
+ rows.push(dataRow(message.body));
159
+ break;
160
+ case 'E':
161
+ // Drain to `ReadyForQuery` first: leaving the session mid-result desynchronises reuse.
162
+ await this.#drainToReady();
163
+ throw serverError('query', message.body);
164
+ case 'Z':
165
+ return rows;
166
+ default:
167
+ this.#note(message);
168
+ }
142
169
  }
170
+ } finally {
171
+ this.#querying = false;
172
+ this.#park();
143
173
  }
144
174
  }
145
175
 
176
+ /**
177
+ * Watch a session that is doing NOTHING. An idle connection has no reader, so a server that
178
+ * ended it — a failover, `pg_terminate_backend`, a dropped socket — was noticed by the next
179
+ * statement, which for a session held only for its advisory lock is never. This parks one read
180
+ * between queries: EOF, a read error or an ErrorResponse calls `onEnd` once, with why.
181
+ *
182
+ * Never for `close()`. A `query()` takes the parked read over rather than racing it — the stream
183
+ * allows one reader — and the watch resumes when the query is done.
184
+ */
185
+ watchIdle(onEnd: (reason: string) => void): void {
186
+ this.#onIdleEnd = onEnd;
187
+ this.#park();
188
+ }
189
+
190
+ #park(): void {
191
+ if (this.#closed || this.#querying || this.#copyBoth) return;
192
+ if (this.#onIdleEnd === undefined || this.#idle !== undefined) return;
193
+ const parked = this.#reader.next();
194
+ this.#idle = parked;
195
+ const ended = (reason: string): void => {
196
+ const onEnd = this.#onIdleEnd;
197
+ this.#onIdleEnd = undefined;
198
+ if (!this.#closed) onEnd?.(reason);
199
+ };
200
+ parked.then(
201
+ (message) => {
202
+ // A query took this read over: the message is its answer, not this watch's.
203
+ if (this.#idle !== parked) return;
204
+ this.#idle = undefined;
205
+ if (message === undefined) return ended('the server closed the connection');
206
+ if (message.tag === 'E') return ended(renderThrowable(serverError('idle', message.body)));
207
+ this.#note(message);
208
+ this.#park();
209
+ },
210
+ (failure: unknown) => {
211
+ if (this.#idle !== parked) return;
212
+ this.#idle = undefined;
213
+ ended(renderThrowable(failure));
214
+ },
215
+ );
216
+ }
217
+
218
+ /** The next message: the read an idle watch already parked, when there is one. */
219
+ #next(): Promise<PgMessage | undefined> {
220
+ const parked = this.#idle;
221
+ if (parked === undefined) return this.#reader.next();
222
+ this.#idle = undefined;
223
+ return parked;
224
+ }
225
+
146
226
  /**
147
227
  * `START_REPLICATION` — the connection stops being request/response and becomes a duplex copy
148
228
  * stream. There is no way back short of closing it, which is why this is one-way on purpose.
@@ -205,6 +285,18 @@ export class PgConnection {
205
285
  this.#stream.close();
206
286
  }
207
287
 
288
+ /**
289
+ * Close the socket and say nothing — no `Terminate`, nothing awaited. For a session that may
290
+ * already be dead: `close()` writes a goodbye first, and a write to a black-holed socket is a
291
+ * promise nothing settles. The server ends the session when the socket goes, which releases
292
+ * whatever the session held. Never reported to an idle watch.
293
+ */
294
+ destroy(): void {
295
+ if (this.#closed) return;
296
+ this.#closed = true;
297
+ closeQuietly(this.#stream);
298
+ }
299
+
208
300
  async #authenticate(options: PgConnectionOptions): Promise<void> {
209
301
  let scram: ScramSession | undefined;
210
302
  for (;;) {
@@ -222,12 +314,16 @@ export class PgConnection {
222
314
  case AUTH_CLEARTEXT: {
223
315
  const password = options.password ?? '';
224
316
  if (password === '') throw needPassword('a cleartext password');
317
+ if (!answersWeakAuth(options.ssl)) {
318
+ throw weakAuthRefused('a cleartext password', options.ssl);
319
+ }
225
320
  await this.#stream.write(passwordMessage(password));
226
321
  break;
227
322
  }
228
323
  case AUTH_MD5: {
229
324
  const password = options.password ?? '';
230
325
  if (password === '') throw needPassword('an md5 password');
326
+ if (!answersWeakAuth(options.ssl)) throw weakAuthRefused('an md5 password', options.ssl);
231
327
  const salt = reader.take(4);
232
328
  await this.#stream.write(
233
329
  passwordMessage(md5Password({ user: options.user, password, salt })),
@@ -276,13 +372,13 @@ export class PgConnection {
276
372
 
277
373
  async #drainToReady(): Promise<void> {
278
374
  for (;;) {
279
- const message = await this.#reader.next();
375
+ const message = await this.#next();
280
376
  if (message === undefined || message.tag === 'Z') return;
281
377
  }
282
378
  }
283
379
 
284
380
  async #expect(stage: string): Promise<PgMessage> {
285
- const message = await this.#reader.next();
381
+ const message = await this.#next();
286
382
  if (message !== undefined) return message;
287
383
  throw new ReplicationFailedError({
288
384
  stage,
@@ -315,8 +411,10 @@ export class PgConnection {
315
411
  const outOfOrder = (what: string): ReplicationProtocolError =>
316
412
  new ReplicationProtocolError({
317
413
  stage: 'auth',
318
- detail: `the server sent ${what} before it offered a SASL mechanism`,
319
- fix: 'x doctor db — the SASL exchange arrived out of order; check for a pooler or proxy between this client and postgres',
414
+ detail:
415
+ `the server sent ${what} before it offered a SASL mechanism — the exchange arrived out of ` +
416
+ 'order; check for a pooler or proxy between this client and postgres',
417
+ fix: 'x doctor --json',
320
418
  });
321
419
 
322
420
  /** A `close()` that throws must not replace the handshake failure that is worth reporting. */
@@ -6,8 +6,12 @@
6
6
  // pair folded into money by name. So a nullable money column diverged from the repository shape,
7
7
  // two plain columns that happened to be called `x_minor`/`x_currency` were folded into a `Money`,
8
8
  // and a `.column()` rename arrived under its physical name. One decoder, the entity's, now.
9
+ //
10
+ // A SEALED column is the one place a live row is not the repository's: the log carries the stored
11
+ // string, and nothing here opens it. It is dropped, so a change — on the bus, in the ring, in a
12
+ // patch frame — names no sealed column at all.
9
13
 
10
- import { decodeRow, entityForTable } from '@ultimat3/entity';
14
+ import { decodeRow, entityForTable, sealedFields } from '@ultimat3/entity';
11
15
  import { ReplicationProtocolError } from './errors';
12
16
  import type { PhysicalRow } from './pg-values';
13
17
  import type { PgRelation } from './pgoutput';
@@ -47,5 +51,26 @@ export function entityRow(
47
51
  });
48
52
  }
49
53
  const source = image === 'before' ? replicatedOnly(relation, physical) : physical;
50
- return decodeRow(entity, source) as PhysicalRow;
54
+ const row = decodeRow(entity, source) as PhysicalRow;
55
+ // Not opened and not forwarded. A client never receives a sealed column on any other path, so
56
+ // a patch carrying one would be the only frame that does — and a `lookup` column's stored string
57
+ // is the same for equal plaintexts, which tells every subscriber which rows share a value.
58
+ for (const field of sealedFields(entity)) delete row[field.property];
59
+ return row;
60
+ }
61
+
62
+ /**
63
+ * The entity properties a decoded `after` image does not carry — what an UPDATE's unchanged TOAST
64
+ * columns become once the tuple is an entity row. Asked only when the tuple said it left columns
65
+ * out, and answered in the row's own vocabulary: a consumer compares these against the properties
66
+ * it projects, and it has never seen a physical column name. A sealed column is never listed: no
67
+ * change names one, so its absence says nothing.
68
+ */
69
+ export function omittedProperties(relation: PgRelation, row: PhysicalRow): readonly string[] {
70
+ const entity = entityForTable(relation.name);
71
+ if (entity === undefined) return [];
72
+ const sealed = new Set(sealedFields(entity).map((field) => field.property));
73
+ return Object.keys(entity.$columns)
74
+ .filter((property) => !Object.hasOwn(row, property) && !sealed.has(property))
75
+ .sort();
51
76
  }
@@ -0,0 +1,25 @@
1
+ // Single responsibility: which replicated tables need `REPLICA IDENTITY FULL` for a reason the
2
+ // live-query path does not have — a channel with params routes a DELETE by columns of the OLD row.
3
+
4
+ import type { Channel } from './channel-decl';
5
+ import { registeredChannels } from './channel-registry';
6
+
7
+ /**
8
+ * The `records` tables of every channel declared with params, deduped and sorted.
9
+ *
10
+ * A live query never needs FULL on a keyed table: its shared window holds the whole row, so a
11
+ * delete that names only the key is enough. A channel holds no window. Its removal is routed to
12
+ * the topic the old row's params name (`channel-records.ts`), and under the default identity the
13
+ * old image is the key alone — it names no topic, no `remove` is sent, and every member keeps the
14
+ * deleted record. A channel with no params has one topic and needs nothing.
15
+ */
16
+ export function paramsChannelTables(
17
+ channels: Iterable<Channel> = registeredChannels(),
18
+ ): readonly string[] {
19
+ const tables = new Set<string>();
20
+ for (const declared of channels) {
21
+ if (declared.params.length === 0) continue;
22
+ for (const projection of declared.records) tables.add(projection.table);
23
+ }
24
+ return [...tables].sort();
25
+ }
@@ -1,7 +1,7 @@
1
- // Single responsibility: the four questions asked of a database BEFORE `START_REPLICATION`. Two
2
- // refuse the boot with the exact statement that fixes them, one — the publication — is answered by
3
- // ensuring it (`pg-publication.ts`), and the fourth warns, because refusing it would stop every app
4
- // on the default replica identity from starting.
1
+ // Single responsibility: the questions asked of a database BEFORE `START_REPLICATION`. Two refuse
2
+ // the boot with the exact statement that fixes them, one — the publication — is answered by
3
+ // ensuring it (`pg-publication.ts`), and the replica-identity ones warn, because refusing would
4
+ // stop every app on the default replica identity from starting.
5
5
 
6
6
  import { logger } from '@ultimat3/core';
7
7
  import { ReplicaIdentityError, ReplicationFailedError } from './errors';
@@ -22,6 +22,7 @@ export async function preflight(
22
22
  slot: string,
23
23
  publication: string,
24
24
  entities: ReadonlySet<string>,
25
+ fullIdentity: ReadonlySet<string> = new Set(),
25
26
  ): Promise<void> {
26
27
  assertIdentifier('slot', slot);
27
28
  assertIdentifier('publication', publication);
@@ -37,6 +38,7 @@ export async function preflight(
37
38
  }
38
39
  await ensurePublication(connection, publication, entities);
39
40
  await warnPartialIdentity(connection, entities);
41
+ await warnChannelIdentity(connection, entities, fullIdentity);
40
42
  const [existing] = await connection.query(
41
43
  `SELECT plugin FROM pg_replication_slots WHERE slot_name = '${slot}'`,
42
44
  );
@@ -102,3 +104,47 @@ async function warnPartialIdentity(
102
104
  // `@ultimat3/http`'s error-map stage follows, so a log index can be alerted on by code.
103
105
  logger.warn(warning.code, { cause: warning.cause, fix: warning.fix, tables });
104
106
  }
107
+
108
+ /** The log event a params channel's table without FULL identity is reported under. */
109
+ export const CHANNEL_IDENTITY_EVENT = 'replication.channel_identity_partial';
110
+
111
+ const CHANNEL_IDENTITY_FIX = 'x db gen "replica identity full"';
112
+
113
+ /**
114
+ * The tables a channel with params carries that are not `REPLICA IDENTITY FULL`. Their DELETE logs
115
+ * the key alone, the channel reads its topic off columns the key does not include, and the removal
116
+ * is sent to nobody: members keep a record that no longer exists, and nothing else says so.
117
+ *
118
+ * WARNED, like its neighbour, and for the same reason. Only names that are also in the entity list
119
+ * are asked about — that list has been through `assertIdentifier`, which is what makes the
120
+ * interpolation safe; a declared table the feed does not decode is not this stream's to judge.
121
+ */
122
+ async function warnChannelIdentity(
123
+ connection: PgConnection,
124
+ entities: ReadonlySet<string>,
125
+ fullIdentity: ReadonlySet<string>,
126
+ ): Promise<void> {
127
+ const asked = [...fullIdentity].filter((name) => entities.has(name)).sort();
128
+ if (asked.length === 0) return;
129
+ const names = asked.map((name) => `'${name}'`).join(', ');
130
+ const rows = await connection.query(
131
+ `SELECT c.relname FROM pg_class c WHERE c.relkind = 'r' AND c.relname IN (${names}) ` +
132
+ `AND c.relreplident <> 'f'`,
133
+ );
134
+ const tables = [
135
+ ...new Set(
136
+ rows
137
+ .map((row) => row[0])
138
+ .filter((name): name is string => typeof name === 'string' && asked.includes(name)),
139
+ ),
140
+ ].sort();
141
+ if (tables.length === 0) return;
142
+ logger.warn(CHANNEL_IDENTITY_EVENT, {
143
+ cause:
144
+ `${tables.join(', ')} carry a channel declared with params but are not REPLICA IDENTITY ` +
145
+ 'FULL, so a DELETE names no topic and its members keep the deleted record',
146
+ // `x db gen` reads the same declarations this warning does and writes the ALTER per table.
147
+ fix: CHANNEL_IDENTITY_FIX,
148
+ tables,
149
+ });
150
+ }
@@ -0,0 +1,82 @@
1
+ // Single responsibility: the two row images of one replicated change — a physical tuple pair
2
+ // becomes the `before`/`after` the matcher's predicates are written against, plus the properties
3
+ // `after` could not carry. Split out of `pg-replication.ts`, which decides ordering and nothing else.
4
+
5
+ import { ReplicationProtocolError } from './errors';
6
+ import { isRow, type Row } from './json';
7
+ import { entityRow, omittedProperties } from './pg-entity-row';
8
+ import type { PhysicalRow } from './pg-values';
9
+ import type { PgRelation } from './pgoutput';
10
+
11
+ export interface ReplicatedImages {
12
+ readonly before: Row | null;
13
+ readonly after: Row | null;
14
+ /** Properties missing from `after` because Postgres logged no value for them. Usually empty. */
15
+ readonly omitted: readonly string[];
16
+ }
17
+
18
+ /**
19
+ * An UPDATE that leaves an out-of-line (TOAST) value untouched logs no bytes for it, so the new
20
+ * tuple arrives without that column. Published as it stood, the change read as the whole row: the
21
+ * window adopted it, and every later snapshot served the row with the column gone.
22
+ *
23
+ * Under REPLICA IDENTITY FULL the old tuple carries the value and the column did not change, so it
24
+ * is copied across and nothing is missing. Under any other identity the old tuple is the key alone
25
+ * — its other columns are NULL placeholders, not values — so the column is NAMED instead, and the
26
+ * consumer re-reads the row rather than adopting a partial one.
27
+ */
28
+ export function replicatedImages(
29
+ relation: PgRelation,
30
+ oldTuple: PhysicalRow | null,
31
+ newTuple: PhysicalRow | null,
32
+ unchanged: readonly string[],
33
+ ): ReplicatedImages {
34
+ const before = toRow(relation, oldTuple, 'before');
35
+ if (newTuple === null || unchanged.length === 0) {
36
+ return { before, after: toRow(relation, newTuple, 'after'), omitted: [] };
37
+ }
38
+ // Null-prototype, as the decoder built it: a column name is off the wire.
39
+ const filled: PhysicalRow = Object.assign(Object.create(null) as PhysicalRow, newTuple);
40
+ let missing = 0;
41
+ for (const name of unchanged) {
42
+ if (relation.replicaIdentity === 'f' && oldTuple !== null && Object.hasOwn(oldTuple, name)) {
43
+ const kept = oldTuple[name];
44
+ if (kept !== undefined) filled[name] = kept;
45
+ } else {
46
+ missing += 1;
47
+ }
48
+ }
49
+ const after = toRow(relation, filled, 'after');
50
+ return {
51
+ before,
52
+ after,
53
+ omitted: missing === 0 || after === null ? [] : omittedProperties(relation, after),
54
+ };
55
+ }
56
+
57
+ /** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
58
+ function toRow(
59
+ relation: PgRelation,
60
+ physical: PhysicalRow | null,
61
+ image: 'before' | 'after',
62
+ ): Row | null {
63
+ if (physical === null) return null;
64
+ const row = entityRow(relation, physical, image);
65
+ // A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
66
+ // so the same table would otherwise identify small rows by number and large ones by string.
67
+ // `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
68
+ const id = row['id'];
69
+ if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
70
+ if (isRow(row)) return row;
71
+ throw new ReplicationProtocolError({
72
+ stage: 'stream',
73
+ detail: `table "${relation.name}" replicated a row with no text id column`,
74
+ fix: `give ${relation.name} an id column, or drop it from the publication and the entity list`,
75
+ });
76
+ }
77
+
78
+ /** The tenant, hoisted out of the row so fanout filters without parsing it. */
79
+ export const tenantOf = (row: Row | null): string | null => {
80
+ const orgId = row?.['orgId'];
81
+ return typeof orgId === 'string' ? orgId : null;
82
+ };
@@ -6,12 +6,12 @@
6
6
  import { type Clock, finiteOption, logger, renderThrowable, systemClock } from '@ultimat3/core';
7
7
  import type { ChangeEvent, ChangeOp, PgLogicalReplicationOptions } from './changefeed';
8
8
  import { ReplicationProtocolError } from './errors';
9
- import { isRow, type Row } from './json';
10
9
  import { ByteReader, ByteWriter, epochMsToPgTimestamp, printLsn } from './pg-bytes';
11
10
  import { PgConnection } from './pg-connection';
12
- import { entityRow } from './pg-entity-row';
13
11
  import { assertIdentifier } from './pg-identifier';
12
+ import { paramsChannelTables } from './pg-identity-tables';
14
13
  import { preflight } from './pg-preflight';
14
+ import { replicatedImages, tenantOf } from './pg-replication-row';
15
15
  import { bunPgStream, parsePgUrl } from './pg-socket';
16
16
  import type { PhysicalRow } from './pg-values';
17
17
  import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
@@ -52,10 +52,7 @@ export interface ReplicationStreamStats {
52
52
  * reporting it. Reset by the first confirm that lands.
53
53
  */
54
54
  readonly confirmFailures: number;
55
- /**
56
- * Why the pump stopped, or `null` while it is live. The read loop cannot throw into a caller —
57
- * nothing awaits it — so this is the one place `/readyz` and a test can see that it died at all.
58
- */
55
+ /** Why the pump stopped, or `null` while live. Recorded here and ANNOUNCED through `onEnd`. */
59
56
  readonly failure: string | null;
60
57
  }
61
58
 
@@ -88,6 +85,8 @@ export const commitPositionOf = (lsn: string): bigint => BigInt(`0x${lsn.slice(0
88
85
  export interface ReplicationStreamHandlers {
89
86
  readonly from?: string | undefined;
90
87
  onChange(event: ChangeEvent): void | Promise<void>;
88
+ /** Called once when the pump dies on its own; never for a `stop()`. */
89
+ readonly onEnd?: ((reason: string) => void) | undefined;
91
90
  }
92
91
 
93
92
  /**
@@ -115,6 +114,7 @@ export class PgReplicationStream {
115
114
  #partialBefore = 0;
116
115
  #confirmFailures = 0;
117
116
  #failure: string | null = null;
117
+ #onEnd: ((reason: string) => void) | undefined;
118
118
 
119
119
  constructor(options: PgLogicalReplicationOptions) {
120
120
  this.#options = options;
@@ -166,10 +166,12 @@ export class PgReplicationStream {
166
166
  replication: 'database',
167
167
  applicationName: `ultimate-replicator:${slot}`,
168
168
  rng: this.#options.rng,
169
+ ssl: target.ssl,
169
170
  });
170
171
  this.#connection = connection;
171
172
  try {
172
- await preflight(connection, slot, publication, this.#entities);
173
+ const full = this.#options.fullIdentityTables ?? paramsChannelTables();
174
+ await preflight(connection, slot, publication, this.#entities, new Set(full));
173
175
  const from = handlers.from;
174
176
  this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
175
177
  await connection.startCopyBoth(
@@ -183,10 +185,10 @@ export class PgReplicationStream {
183
185
  throw failure;
184
186
  }
185
187
  this.#running = true;
186
- // A restart that kept the last death in `stats()` reports a live stream as failed, and the
187
- // supervisor that reads it never sees the replicator come back.
188
+ // A restart that kept the last death in `stats()` would report a live stream as failed.
188
189
  this.#failure = null;
189
190
  this.#confirmFailures = 0;
191
+ this.#onEnd = handlers.onEnd;
190
192
  this.#timer = setInterval(
191
193
  () => {
192
194
  void this.#confirmOnTimer();
@@ -241,6 +243,20 @@ export class PgReplicationStream {
241
243
  if (failures.length > 0) throw failures[0];
242
244
  }
243
245
 
246
+ /**
247
+ * `stop()` without the goodbye: no confirm, no `CopyDone`, no `Terminate`, nothing awaited — on a
248
+ * black-holed walsender each of those is a write nothing settles. The unconfirmed tail is
249
+ * re-sent to the next stream and dropped by its `from`.
250
+ */
251
+ abandon(): void {
252
+ this.#running = false;
253
+ this.#onEnd = undefined;
254
+ this.#clearTimer();
255
+ const connection = this.#connection;
256
+ this.#connection = null;
257
+ connection?.destroy();
258
+ }
259
+
244
260
  /**
245
261
  * The pump's only way out, however it ended — a decode error, or a walsender that said goodbye.
246
262
  * The four things it owns go together or not at all, because each one left behind is a dead
@@ -256,8 +272,7 @@ export class PgReplicationStream {
256
272
  this.#clearTimer();
257
273
  const connection = this.#connection;
258
274
  this.#connection = null;
259
- // The supervisor reads /readyz, so the loop records, reports and ends rather than throwing
260
- // into a promise nothing awaits.
275
+ // The loop records, reports and ends rather than throwing into a promise nothing awaits.
261
276
  logger.error('replication stream ended', { slot: this.#options.slot, error: reason });
262
277
  try {
263
278
  await connection?.close();
@@ -265,6 +280,14 @@ export class PgReplicationStream {
265
280
  // The socket is already unusable and `failure` above is the report that matters — a
266
281
  // goodbye that throws must not become the rejection `#drain` promised never to produce.
267
282
  }
283
+ // Last, so whoever restarts finds the socket already let go. Called, never awaited.
284
+ const onEnd = this.#onEnd;
285
+ this.#onEnd = undefined;
286
+ try {
287
+ onEnd?.(reason);
288
+ } catch (failure) {
289
+ logger.error('replication onEnd listener threw', { error: renderThrowable(failure) });
290
+ }
268
291
  }
269
292
 
270
293
  #clearTimer(): void {
@@ -330,19 +353,21 @@ export class PgReplicationStream {
330
353
  if (message.endLsn > this.#confirmed) this.#confirmed = message.endLsn;
331
354
  return;
332
355
  case 'insert':
333
- await this.#deliver('insert', message.relation, null, message.after, handlers);
356
+ await this.#deliver('insert', message.relation, null, message.after, [], handlers);
334
357
  return;
335
- case 'update':
336
- await this.#deliver('update', message.relation, message.before, message.after, handlers);
358
+ case 'update': {
359
+ const { relation, before, after, unchanged } = message;
360
+ await this.#deliver('update', relation, before, after, unchanged, handlers);
337
361
  return;
362
+ }
338
363
  case 'delete':
339
- await this.#deliver('delete', message.relation, message.before, null, handlers);
364
+ await this.#deliver('delete', message.relation, message.before, null, [], handlers);
340
365
  return;
341
366
  case 'truncate':
342
367
  // One change per truncated relation, rowless. It was decoded and DROPPED here, and with the
343
368
  // recommended `FOR ALL TABLES` publication every window and every client kept the rows.
344
369
  for (const relation of message.relations) {
345
- await this.#deliver('truncate', relation, null, null, handlers);
370
+ await this.#deliver('truncate', relation, null, null, [], handlers);
346
371
  }
347
372
  return;
348
373
  default:
@@ -356,6 +381,7 @@ export class PgReplicationStream {
356
381
  relation: PgRelation,
357
382
  oldTuple: PhysicalRow | null,
358
383
  newTuple: PhysicalRow | null,
384
+ unchanged: readonly string[],
359
385
  handlers: ReplicationStreamHandlers,
360
386
  ): Promise<void> {
361
387
  const transaction = this.#transaction;
@@ -386,8 +412,7 @@ export class PgReplicationStream {
386
412
  if ((op === 'update' || op === 'delete') && relation.replicaIdentity !== 'f') {
387
413
  this.#partialBefore += 1;
388
414
  }
389
- const before = toRow(relation, oldTuple, 'before');
390
- const after = toRow(relation, newTuple, 'after');
415
+ const { before, after, omitted } = replicatedImages(relation, oldTuple, newTuple, unchanged);
391
416
  const event: ChangeEvent = {
392
417
  entity: relation.name,
393
418
  op,
@@ -398,6 +423,7 @@ export class PgReplicationStream {
398
423
  orgId: tenantOf(after ?? before),
399
424
  at: transaction.commitAt,
400
425
  ...(transaction.write === undefined ? {} : { write: transaction.write }),
426
+ ...(omitted.length === 0 ? {} : { omitted }),
401
427
  };
402
428
  await handlers.onChange(event);
403
429
  this.#lastLsn = lsn;
@@ -470,30 +496,3 @@ export class PgReplicationStream {
470
496
  await attempt;
471
497
  }
472
498
  }
473
-
474
- /** A physical tuple becomes the row the matcher's predicates are written against, or nothing. */
475
- function toRow(
476
- relation: PgRelation,
477
- physical: PhysicalRow | null,
478
- image: 'before' | 'after',
479
- ): Row | null {
480
- if (physical === null) return null;
481
- const row = entityRow(relation, physical, image);
482
- // A bigserial id decodes as a number inside `Number.isSafeInteger` range and as text outside it,
483
- // so the same table would otherwise identify small rows by number and large ones by string.
484
- // `Row.id`, `RowPatch.id` and every cursor are text: the identity is normalised once, here.
485
- const id = row['id'];
486
- if (typeof id === 'number' && Number.isSafeInteger(id)) row['id'] = String(id);
487
- if (isRow(row)) return row;
488
- throw new ReplicationProtocolError({
489
- stage: 'stream',
490
- detail: `table "${relation.name}" replicated a row with no text id column`,
491
- fix: `give ${relation.name} an id column, or drop it from the publication and the entity list`,
492
- });
493
- }
494
-
495
- /** The tenant, hoisted out of the row so fanout filters without parsing it. */
496
- const tenantOf = (row: Row | null): string | null => {
497
- const orgId = row?.['orgId'];
498
- return typeof orgId === 'string' ? orgId : null;
499
- };
package/src/pg-socket.ts CHANGED
@@ -189,8 +189,8 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
189
189
  const connectFailure = (error: Error): ReplicationFailedError =>
190
190
  new ReplicationFailedError({
191
191
  stage: 'connect',
192
- detail: error.message,
193
- fix: `open the route to ${target.host}:${target.port}, then: x doctor db`,
192
+ detail: `${error.message} — no route to ${target.host}:${target.port} from this process`,
193
+ fix: 'x doctor --json',
194
194
  });
195
195
 
196
196
  const handlers: SocketHandlers = {
@@ -229,8 +229,10 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
229
229
  if (written < 0) {
230
230
  throw new ReplicationFailedError({
231
231
  stage: 'write',
232
- detail: `the socket refused a ${rest.length}-byte write`,
233
- fix: 'the replicator reconnects on its own; confirm the host is up with: x doctor db',
232
+ detail:
233
+ `the socket refused a ${rest.length}-byte write — the replicator reconnects on its ` +
234
+ 'own once the host is back',
235
+ fix: 'x doctor --json',
234
236
  });
235
237
  }
236
238
  if (written > 0) rest = rest.subarray(written);