@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
@@ -4,14 +4,14 @@
4
4
  // the lsn is the only authority the pipeline has.
5
5
 
6
6
  import { type Clock, finiteOption, logger, renderThrowable, systemClock } from '@ultimat3/core';
7
- import type { ChangeEvent, ChangeOp, PgLogicalReplicationOptions } from './changefeed';
7
+ import type { ChangeEvent, ChangeOp, PostgresChangeFeedOptions } 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,14 +85,16 @@ 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
  /**
94
- * One slot, one connection, one process. Started by `PgLogicalReplicationFeed`, which is itself
93
+ * One slot, one connection, one process. Started by `PostgresChangeFeed`, which is itself
95
94
  * held by the single `replicator` role — the advisory lock upstream is what keeps that "one" true.
96
95
  */
97
96
  export class PgReplicationStream {
98
- readonly #options: PgLogicalReplicationOptions;
97
+ readonly #options: PostgresChangeFeedOptions;
99
98
  readonly #clock: Clock;
100
99
  readonly #entities: ReadonlySet<string>;
101
100
  readonly #slot: string;
@@ -115,8 +114,9 @@ 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
- constructor(options: PgLogicalReplicationOptions) {
119
+ constructor(options: PostgresChangeFeedOptions) {
120
120
  this.#options = options;
121
121
  this.#clock = options.clock ?? systemClock;
122
122
  this.#entities = new Set(options.entities.map((name) => assertIdentifier('entity', name)));
@@ -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,10 +412,9 @@ 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
- entity: relation.name,
417
+ table: relation.name,
393
418
  op,
394
419
  before,
395
420
  after,
@@ -397,7 +422,8 @@ export class PgReplicationStream {
397
422
  txid: transaction.xid.toString(10),
398
423
  orgId: tenantOf(after ?? before),
399
424
  at: transaction.commitAt,
400
- ...(transaction.write === undefined ? {} : { write: transaction.write }),
425
+ write: transaction.write ?? null,
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
@@ -52,6 +52,23 @@ export interface PgTarget {
52
52
  readonly rootCert?: string | undefined;
53
53
  }
54
54
 
55
+ /**
56
+ * One URL part, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a stray `%`
57
+ * (`50%off`), so a password pasted unencoded failed the replicator with no code and no fix.
58
+ * Refused under the part's NAME, never its value: that is the database credential.
59
+ */
60
+ function decodeUrlPart(raw: string, part: 'user' | 'password' | 'database'): string {
61
+ try {
62
+ return decodeURIComponent(raw);
63
+ } catch {
64
+ throw new ReplicationFailedError({
65
+ stage: 'connect',
66
+ detail: `DATABASE_URL has a malformed percent-escape in its ${part}`,
67
+ fix: `bun -e 'console.log(encodeURIComponent(process.argv[1]))' '<the raw value>' # then put the result in the ${part} of DATABASE_URL`,
68
+ });
69
+ }
70
+ }
71
+
55
72
  /** `postgres://user:pass@host:5432/db?sslmode=require`. The one place a connection URL is read. */
56
73
  export function parsePgUrl(url: string): PgTarget {
57
74
  let parsed: URL;
@@ -75,13 +92,13 @@ export function parsePgUrl(url: string): PgTarget {
75
92
  });
76
93
  }
77
94
  const { ssl, rootCert } = parseSsl(parsed.searchParams);
78
- const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
95
+ const database = decodeUrlPart(parsed.pathname.replace(/^\//, ''), 'database');
79
96
  return {
80
97
  host: parsed.hostname,
81
98
  port: parsed.port === '' ? 5432 : Number.parseInt(parsed.port, 10),
82
99
  database: database === '' ? 'postgres' : database,
83
- user: decodeURIComponent(parsed.username) || 'postgres',
84
- password: parsed.password === '' ? undefined : decodeURIComponent(parsed.password),
100
+ user: decodeUrlPart(parsed.username, 'user') || 'postgres',
101
+ password: parsed.password === '' ? undefined : decodeUrlPart(parsed.password, 'password'),
85
102
  ssl,
86
103
  rootCert,
87
104
  };
@@ -189,8 +206,8 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
189
206
  const connectFailure = (error: Error): ReplicationFailedError =>
190
207
  new ReplicationFailedError({
191
208
  stage: 'connect',
192
- detail: error.message,
193
- fix: `open the route to ${target.host}:${target.port}, then: x doctor db`,
209
+ detail: `${error.message} — no route to ${target.host}:${target.port} from this process`,
210
+ fix: 'x doctor --json',
194
211
  });
195
212
 
196
213
  const handlers: SocketHandlers = {
@@ -229,8 +246,10 @@ export async function pgStreamOver(runtime: BunConnect, target: PgTarget): Promi
229
246
  if (written < 0) {
230
247
  throw new ReplicationFailedError({
231
248
  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',
249
+ detail:
250
+ `the socket refused a ${rest.length}-byte write — the replicator reconnects on its ` +
251
+ 'own once the host is back',
252
+ fix: 'x doctor --json',
234
253
  });
235
254
  }
236
255
  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-values.ts CHANGED
@@ -33,13 +33,20 @@ export type PhysicalRow = { [key: string]: PhysicalValue };
33
33
  /**
34
34
  * `2026-08-09 12:00:00.123456+00` -> the ISO-8601 form `new Date` is specified to accept.
35
35
  *
36
- * Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530` or
37
- * `+05:30`, and as many fractional digits as the column's precision. `Date` holds milliseconds, so
38
- * the fraction is TRUNCATED to three — which is what the driver does on the repository side, so
39
- * both readers of one column land on the same instant.
36
+ * Postgres writes a space between the date and the clock, an offset that may be `+00`, `+0530`,
37
+ * `+05:30` or — for an instant before its zone adopted standard time, a zone's LMT — `-04:56:02`,
38
+ * and as many fractional digits as the column's precision. `Date` holds milliseconds, so the
39
+ * fraction is TRUNCATED to three — which is what the driver does on the repository side, so both
40
+ * readers of one column land on the same instant.
41
+ *
42
+ * Seconds only ever follow a colon-separated minute (`EncodeTimezone` in postgres' `datetime.c`),
43
+ * so `-0456:02` is not a spelling this matches.
40
44
  */
41
45
  const TIMESTAMP =
42
- /^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(?::?\d{2})?)?$/;
46
+ /^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}(?:\d{2}|:\d{2}(?::\d{2})?)?)?$/;
47
+
48
+ /** `±HH`, `±HHMM`, `±HH:MM`, `±HH:MM:SS` — the zone group of `TIMESTAMP`, taken apart. */
49
+ const OFFSET = /^([+-])(\d{2}):?(\d{2})?:?(\d{2})?$/;
43
50
 
44
51
  /**
45
52
  * `undefined` for a text this decoder does not describe — `infinity`, a BC date, a non-ISO
@@ -61,18 +68,26 @@ function toInstant(text: string): Date | undefined {
61
68
  if (parts === null) return undefined;
62
69
  const [, year, month, day, hour, minute, second, fraction, zone] = parts;
63
70
  const millis = fraction === undefined ? '000' : `${fraction.slice(1)}000`.slice(0, 3);
64
- const parsed = new Date(
65
- `${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}${offsetOf(zone)}`,
66
- );
71
+ const offset = offsetMillis(zone);
72
+ if (offset === undefined) return undefined;
73
+ // Read the wall clock as UTC, then move it by the offset: `Date` has no spelling for a seconds
74
+ // offset, so handing it the zone would leave an LMT instant as text while the driver, which
75
+ // reads the binary form, hands the repository its `Date`.
76
+ const wall = Date.parse(`${year}-${month}-${day}T${hour}:${minute}:${second}.${millis}Z`);
77
+ if (Number.isNaN(wall)) return undefined;
78
+ const parsed = new Date(wall - offset);
67
79
  return Number.isNaN(parsed.getTime()) ? undefined : parsed;
68
80
  }
69
81
 
70
- /** `+05` and `+0530` are offsets postgres writes and `Date` does not read; `±HH:MM` is both. */
71
- function offsetOf(zone: string | undefined): string {
72
- if (zone === undefined || zone === 'Z') return 'Z';
73
- if (zone.length === 3) return `${zone}:00`;
74
- if (zone.length === 5) return `${zone.slice(0, 3)}:${zone.slice(3)}`;
75
- return zone;
82
+ /** East of UTC is positive. `undefined` for a minute or second past 59 — not an offset at all. */
83
+ function offsetMillis(zone: string | undefined): number | undefined {
84
+ if (zone === undefined || zone === 'Z') return 0;
85
+ const parts = OFFSET.exec(zone);
86
+ if (parts === null) return undefined;
87
+ const [, sign, hours, minutes = '00', seconds = '00'] = parts;
88
+ if (Number(minutes) > 59 || Number(seconds) > 59) return undefined;
89
+ const magnitude = (Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds)) * 1000;
90
+ return sign === '-' ? -magnitude : magnitude;
76
91
  }
77
92
 
78
93
  const HEX = /^[0-9a-fA-F]*$/;
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 {
@@ -1,6 +1,6 @@
1
- // The single seam between realtime and authz. It goes through `@ultimat3/query`'s `guard`, which
2
- // is itself the only point of contact with `@ultimat3/policy` — one authz system, never two, and
3
- // realtime does not get its own opinion about what a decision means.
1
+ // The single seam between realtime and authz. It goes through `@ultimat3/query`'s `guardQuery`,
2
+ // which is itself the only point of contact with `@ultimat3/policy` — one authz system, never two,
3
+ // and realtime does not get its own opinion about what a decision means.
4
4
  //
5
5
  // The row gate turns a denial into "not visible" instead of an error: a row that fails an actor's
6
6
  // policy is dropped, never sent. That is the rule from the live-query pipeline, implemented once.
@@ -8,7 +8,7 @@
8
8
  // database is down" are different facts and one of them has to page someone.
9
9
 
10
10
  import type { Actor, Ctx } from '@ultimat3/core';
11
- import { guard, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
11
+ import { guardQuery, QueryDeniedError, type QueryPolicy, type QuerySubject } from '@ultimat3/query';
12
12
  import type { JsonValue, Row } from './json';
13
13
 
14
14
  export interface GateOptions {
@@ -25,7 +25,7 @@ export function authorizeWithPolicy(
25
25
  return async (args) => {
26
26
  // No row exists yet at subscribe time; `null` says so rather than leaving the predicate
27
27
  // to infer it from an absent field.
28
- guard(policy, subjectOf(options, args.actor, args.input, null), 'live');
28
+ guardQuery(policy, subjectOf(options, args.actor, args.input, null), 'live');
29
29
  };
30
30
  }
31
31
 
@@ -40,10 +40,10 @@ export function visibleWithPolicy<R extends Row = Row>(
40
40
  ): (args: { actor: Actor | null; row: R; input: JsonValue }) => Promise<boolean> {
41
41
  return async (args) => {
42
42
  try {
43
- guard(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
43
+ guardQuery(policy, subjectOf(options, args.actor, args.input, args.row), 'live');
44
44
  return true;
45
45
  } catch (error) {
46
- // `guard` throws `QueryDeniedError` for a decision and for nothing else, so that class is
46
+ // `guardQuery` throws `QueryDeniedError` for a decision and for nothing else, so that class is
47
47
  // the whole of "not visible". A rule that reached for a row and timed out throws something
48
48
  // else, and answering `false` to it would report an outage as a permission change: the rows
49
49
  // leave the subscriber's screen, `live.rows_denied` counts the drop, and no error reaches
package/src/presence.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // On the wire it is not a frame kind: a roster change is an `events` frame on the channel the member
7
7
  // joined (`channel-presence.ts` is the payload), so only a channel declared `events: true` has one.
8
8
 
9
- import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core';
9
+ import { type Clock, finiteOption, systemClock, uuidV7 } from '@ultimat3/core';
10
10
  import type { ChannelHub, Topic } from './channel';
11
11
  import { presenceEvent } from './channel-presence';
12
12
  import type { ChannelEventsFrame } from './channel-wire';
@@ -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;
@@ -75,7 +86,7 @@ export class PresenceRegistry {
75
86
  1,
76
87
  finiteOption('presence', 'maxMembers', options.maxMembers ?? DEFAULT_MAX_PRESENCE_MEMBERS),
77
88
  );
78
- this.#nodeId = options.nodeId ?? uuid();
89
+ this.#nodeId = options.nodeId ?? uuidV7();
79
90
  }
80
91
 
81
92
  get ttlMs(): number {
@@ -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);