@ultimat3/realtime 23.0.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 (68) hide show
  1. package/CLAUDE.md +75 -81
  2. package/README.md +107 -17
  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 +7 -1
  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-definition.ts +22 -7
  25. package/src/live-fanout.ts +43 -7
  26. package/src/live-floor.ts +35 -0
  27. package/src/live-query-options.ts +38 -0
  28. package/src/live-query.ts +36 -37
  29. package/src/nats-client.ts +11 -2
  30. package/src/nats-transport.ts +22 -0
  31. package/src/offline-queue.ts +48 -5
  32. package/src/page-errors.ts +17 -0
  33. package/src/page-outbox.ts +121 -50
  34. package/src/pg-advisory-lock.ts +45 -1
  35. package/src/pg-auth.ts +9 -4
  36. package/src/pg-bytes.ts +4 -1
  37. package/src/pg-connection.ts +119 -21
  38. package/src/pg-entity-row.ts +16 -0
  39. package/src/pg-identity-tables.ts +25 -0
  40. package/src/pg-preflight.ts +50 -4
  41. package/src/pg-replication-row.ts +82 -0
  42. package/src/pg-replication.ts +44 -45
  43. package/src/pg-socket.ts +6 -4
  44. package/src/pg-tls.ts +18 -0
  45. package/src/pg-wire.ts +5 -3
  46. package/src/pgoutput.ts +19 -9
  47. package/src/presence.ts +85 -8
  48. package/src/query-window.ts +16 -4
  49. package/src/record-persister.ts +7 -2
  50. package/src/replication-errors.ts +8 -4
  51. package/src/replicator-envelope.ts +110 -0
  52. package/src/replicator-row.ts +75 -0
  53. package/src/replicator.ts +319 -186
  54. package/src/server.ts +10 -6
  55. package/src/socket-engine.ts +14 -2
  56. package/src/socket-idle.ts +3 -1
  57. package/src/socket-routes.ts +8 -3
  58. package/src/subscriber-gate.ts +11 -1
  59. package/src/sync-auth.ts +47 -2
  60. package/src/sync-frames.ts +73 -20
  61. package/src/sync-node-bounds.ts +15 -0
  62. package/src/sync-node-contract.ts +20 -2
  63. package/src/sync-node.ts +92 -17
  64. package/src/sync-origin.ts +26 -11
  65. package/src/sync-protocol.ts +13 -3
  66. package/src/sync-upgrade.ts +76 -8
  67. package/src/use-mutation.ts +33 -5
  68. package/src/wire-channel.ts +13 -4
@@ -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);
package/src/pg-tls.ts CHANGED
@@ -62,6 +62,24 @@ export function parseSsl(params: URLSearchParams): SslSettings {
62
62
  /** Whether a mode asks the runtime for the verification at all. */
63
63
  export const verifies = (ssl: SslMode): boolean => ssl === 'verify-ca' || ssl === 'verify-full';
64
64
 
65
+ /**
66
+ * Whether a cleartext or md5 password request is answered. Cleartext IS the password; md5 is
67
+ * replayable. What each mode actually protects against:
68
+ *
69
+ * - `verify-ca` / `verify-full`: the session is encrypted to a server whose certificate was
70
+ * checked. The only modes that stop an ON-PATH attacker.
71
+ * - `require`: encrypted, to whoever answered. No certificate is verified, so this stops a PASSIVE
72
+ * listener and nothing else — an attacker on the path can terminate the TLS session itself and
73
+ * ask for the password. Answered anyway: it is libpq's behaviour, and managed databases whose
74
+ * CA an app does not hold depend on it. An operator who needs more names a `verify-*` mode.
75
+ * - `disable`: the operator SAYING the session is cleartext — the opt-in.
76
+ * - `allow` / `prefer` (the default, and what an unstated mode is): refused. They do not even
77
+ * promise encryption — a stripped upgrade falls back to cleartext without a word — so they stop
78
+ * neither kind of attacker, and nobody chose that.
79
+ */
80
+ export const answersWeakAuth = (ssl: SslMode | undefined): boolean =>
81
+ ssl === 'disable' || ssl === 'require' || (ssl !== undefined && verifies(ssl));
82
+
65
83
  /** OpenSSL's code for a certificate whose chain verified but whose names do not include the host. */
66
84
  const HOST_MISMATCH = 'ERR_TLS_CERT_ALTNAME_INVALID';
67
85
 
package/src/pg-wire.ts CHANGED
@@ -69,8 +69,10 @@ export class MessageReader {
69
69
  if (held === 0) return undefined;
70
70
  throw new ReplicationProtocolError({
71
71
  stage: 'read',
72
- detail: `the connection closed with ${held} bytes of a partial message`,
73
- fix: 'x doctor db — the backend was terminated mid-message; the server log names the reason',
72
+ detail:
73
+ `the connection closed with ${held} bytes of a partial message — the backend was ` +
74
+ 'terminated mid-message, and the server log names the reason',
75
+ fix: 'x doctor --json',
74
76
  });
75
77
  }
76
78
  this.#append(chunk);
@@ -245,7 +247,7 @@ export const FIXES: Readonly<Record<string, string>> = {
245
247
  };
246
248
 
247
249
  /** What a SQLSTATE this table has no entry for is answered with. */
248
- const GENERIC_FIX = 'x doctor db — the postgres message above names the object to change';
250
+ const GENERIC_FIX = 'x doctor --json';
249
251
 
250
252
  /**
251
253
  * An `ErrorResponse` becomes the one error class whose `fix` names the command to run.
package/src/pgoutput.ts CHANGED
@@ -1,9 +1,7 @@
1
- // Decodes pgoutput logical-replication messages (protocol version 1, Postgres >= 12) into typed
2
- // PgOutputMessage values. Pure byte decoding: no sockets, no I/O. A decoder instance owns the
3
- // per-connection relation cache that later Insert/Update/Delete/Truncate messages reference by oid.
4
- //
5
- // What a tuple's TEXT means is `pg-values.ts`'s: this file frames messages, that one owns the type
6
- // catalogue that turns postgres' text into the value a repository row holds.
1
+ // Decodes pgoutput logical-replication messages (protocol version 1) into typed PgOutputMessage
2
+ // values; the stream asks `messages 'true'`, which needs Postgres >= 14. Pure byte decoding, no
3
+ // I/O: a decoder owns the per-connection relation cache later tuples reference by oid. What a
4
+ // tuple's TEXT means is `pg-values.ts`'s — this file frames messages, that one owns the types.
7
5
 
8
6
  import { isWriteDigest, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
9
7
  import { ReplicationProtocolError } from './errors';
@@ -48,6 +46,12 @@ export type PgOutputMessage =
48
46
  readonly relation: PgRelation;
49
47
  readonly before: PhysicalRow | null;
50
48
  readonly after: PhysicalRow;
49
+ /**
50
+ * Physical columns the new tuple sent as `'u'`: an out-of-line (TOAST) value the UPDATE did
51
+ * not touch, for which Postgres logs no bytes. They are absent from `after`, and this list is
52
+ * what tells "absent" apart from "the whole row".
53
+ */
54
+ readonly unchanged: readonly string[];
51
55
  }
52
56
  | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
53
57
  | { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
@@ -67,7 +71,11 @@ export type PgOutputMessage =
67
71
  * `'u'` standing in for the columns a key-only tuple leaves out — so the column count always
68
72
  * matches the relation, and only the per-column byte tells us whether a value is actually there.
69
73
  */
70
- function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow {
74
+ function decodeTupleData(
75
+ reader: ByteReader,
76
+ relation: PgRelation,
77
+ unchanged?: string[],
78
+ ): PhysicalRow {
71
79
  const count = reader.int16();
72
80
  if (count !== relation.columns.length) {
73
81
  throw new ReplicationProtocolError({
@@ -90,6 +98,7 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow
90
98
  if (kind === 'u') {
91
99
  // Omitted, not nulled: "unchanged" and "set to null" are different facts about the row,
92
100
  // and a missing key is the only encoding that keeps those two facts distinguishable.
101
+ unchanged?.push(column.name);
93
102
  continue;
94
103
  }
95
104
  if (kind === 't') {
@@ -245,8 +254,9 @@ export class PgOutputDecoder {
245
254
  detail: `expected the new-tuple marker "N" but got "${marker}"`,
246
255
  });
247
256
  }
248
- const after = decodeTupleData(reader, relation);
249
- return { kind: 'update', relation, before, after };
257
+ const unchanged: string[] = [];
258
+ const after = decodeTupleData(reader, relation, unchanged);
259
+ return { kind: 'update', relation, before, after, unchanged };
250
260
  }
251
261
 
252
262
  #decodeDelete(reader: ByteReader): PgOutputMessage {
package/src/presence.ts CHANGED
@@ -25,6 +25,13 @@ export const PRESENCE_SWEEP_PREFIX = 'presence.sweep';
25
25
  */
26
26
  export const DEFAULT_MAX_PRESENCE_MEMBERS = 256;
27
27
 
28
+ /**
29
+ * Member ids the sweep leader ships on its lease, for the other nodes to record. Bounded because
30
+ * the lease is one bus message: past it, a member is known to the leader alone, as every member
31
+ * was before the lease carried a roster.
32
+ */
33
+ export const PRESENCE_LEASE_ROSTER_LIMIT = 4_096;
34
+
28
35
  export interface PresenceOptions {
29
36
  readonly transport: Transport;
30
37
  /** Optional: without a hub, presence is queryable but silent (no join/leave frames). */
@@ -65,6 +72,10 @@ export class PresenceRegistry {
65
72
  readonly #nodeId: string;
66
73
  /** Diffing cache only — the truth is always `transport.shared`. Safe to lose. */
67
74
  readonly #seen = new Map<string, Set<string>>();
75
+ /** Per topic this node LEADS: the roster on its lease, as written. */
76
+ readonly #shipped = new Map<string, string>();
77
+ /** Per topic this node FOLLOWS: the ids the leader's lease last named. */
78
+ readonly #heard = new Map<string, ReadonlySet<string>>();
68
79
 
69
80
  constructor(options: PresenceOptions) {
70
81
  this.#transport = options.transport;
@@ -100,7 +111,10 @@ export class PresenceRegistry {
100
111
  return await this.roster(name);
101
112
  }
102
113
 
103
- /** `false` means the member had already expired: the caller must `join` again, not `heartbeat`. */
114
+ /**
115
+ * A member's beat: its TTL renewed, nothing announced and nothing read. `false` means the member
116
+ * had already expired: the caller must `join` again, not `heartbeat`.
117
+ */
104
118
  async heartbeat(name: Topic, id: string): Promise<boolean> {
105
119
  const alive = await this.#transport.shared.touch(this.#key(name), id, this.#ttlMs);
106
120
  if (!alive) this.#track(name).delete(id);
@@ -190,12 +204,18 @@ export class PresenceRegistry {
190
204
  // A room nobody is in is not a room. Without this the cache keeps one entry per topic ever
191
205
  // subscribed to, for the life of the process, and the sweep walks all of them forever.
192
206
  if ((this.#seen.get(name)?.size ?? 0) === 0) {
193
- this.#seen.delete(name);
207
+ this.#forget(name);
208
+ continue;
209
+ }
210
+ const lease = await this.#claimSweep(name);
211
+ if (!lease.leads) {
212
+ this.#record(name, lease.roster);
194
213
  continue;
195
214
  }
196
- if (!(await this.#claimSweep(name))) continue;
215
+ this.#heard.delete(name);
197
216
  gone.push(...(await this.sweep(name)));
198
- if ((this.#seen.get(name)?.size ?? 0) === 0) this.#seen.delete(name);
217
+ await this.#ship(name);
218
+ if ((this.#seen.get(name)?.size ?? 0) === 0) this.#forget(name);
199
219
  }
200
220
  return gone;
201
221
  }
@@ -211,14 +231,59 @@ export class PresenceRegistry {
211
231
  * different views is a duplicate `leave` for a member who has already gone — which is what a
212
232
  * `leave` frame means anyway. What it never produces is nobody sweeping: a claim is re-put every
213
233
  * pass, and a dead leader's expires within one TTL.
234
+ *
235
+ * The leader's claim CARRIES the ids it last saw live, and every follower reads it in the read
236
+ * the election already makes. That is what a takeover diffs against: a leader that dies takes
237
+ * its own members with it, and a successor that had skipped every sweep held nothing but its own
238
+ * — so their leaves were never announced, to anyone, until each client reconnected.
214
239
  */
215
- async #claimSweep(name: Topic): Promise<boolean> {
240
+ async #claimSweep(
241
+ name: Topic,
242
+ ): Promise<{ readonly leads: boolean; readonly roster: readonly string[] | null }> {
216
243
  const key = `${PRESENCE_SWEEP_PREFIX}.${name}`;
217
- await this.#transport.shared.put(key, this.#nodeId, '', this.#ttlMs);
244
+ await this.#transport.shared.put(key, this.#nodeId, this.#shipped.get(name) ?? '', this.#ttlMs);
218
245
  const claimants = await this.#transport.shared.entries(key);
219
246
  let leader = this.#nodeId;
220
- for (const claimant of claimants) if (claimant.member < leader) leader = claimant.member;
221
- return leader === this.#nodeId;
247
+ let roster: readonly string[] | null = null;
248
+ for (const claimant of claimants) {
249
+ if (claimant.member > leader) continue;
250
+ leader = claimant.member;
251
+ roster = parseRoster(claimant.value);
252
+ }
253
+ return { leads: leader === this.#nodeId, roster };
254
+ }
255
+
256
+ /** A follower's pass: what the leader said is live is recorded, what it stopped saying is not. */
257
+ #record(name: Topic, roster: readonly string[] | null): void {
258
+ this.#shipped.delete(name);
259
+ // A leader that has not swept yet has said nothing: nothing is learned and nothing forgotten.
260
+ if (roster === null) return;
261
+ const tracked = this.#track(name);
262
+ const now = new Set(roster);
263
+ // Dropped by the leader while it is alive means the leader announced it.
264
+ for (const id of this.#heard.get(name) ?? []) if (!now.has(id)) tracked.delete(id);
265
+ for (const id of now) tracked.add(id);
266
+ this.#heard.set(name, now);
267
+ }
268
+
269
+ /** The leader's roster, written onto its lease when it changed. */
270
+ async #ship(name: Topic): Promise<void> {
271
+ const ids = [...(this.#seen.get(name) ?? [])].sort().slice(0, PRESENCE_LEASE_ROSTER_LIMIT);
272
+ const value = JSON.stringify(ids);
273
+ if (this.#shipped.get(name) === value) return;
274
+ this.#shipped.set(name, value);
275
+ await this.#transport.shared.put(
276
+ `${PRESENCE_SWEEP_PREFIX}.${name}`,
277
+ this.#nodeId,
278
+ value,
279
+ this.#ttlMs,
280
+ );
281
+ }
282
+
283
+ #forget(name: Topic): void {
284
+ this.#seen.delete(name);
285
+ this.#shipped.delete(name);
286
+ this.#heard.delete(name);
222
287
  }
223
288
 
224
289
  /** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
@@ -283,6 +348,18 @@ export function presenceFrame(
283
348
  };
284
349
  }
285
350
 
351
+ /** A lease value as the leader wrote it: a JSON list of member ids, or nothing yet. */
352
+ function parseRoster(value: string): readonly string[] | null {
353
+ if (value === '') return null;
354
+ try {
355
+ const parsed: unknown = JSON.parse(value);
356
+ if (!Array.isArray(parsed)) return null;
357
+ return parsed.filter((id): id is string => typeof id === 'string');
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
286
363
  function parseMember(id: string, value: string): PresenceMember | null {
287
364
  try {
288
365
  const parsed: unknown = JSON.parse(value);