@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,13 +1,10 @@
1
1
  /**
2
- * The page's ONE outbox: writes a page could not send are queued in `OfflineQueue`, persisted in
3
- * the page's durable store under the current principal, and replayed IN ORDER over HTTP — each to
4
- * `actionPath(name)` through core's `clientTransport`, each with its own idempotency key, so a
5
- * replay after a lost response is answered from the action's idempotency store and never applied
6
- * twice. Replayed on open, on `online`, and on the service worker's `OUTBOX_DRAIN_MESSAGE`.
7
- * A principal change wipes the previous principal's queue: its writes are never sent as the next.
2
+ * The page's ONE outbox: writes a page could not send, persisted under the current principal and
3
+ * replayed IN ORDER over HTTP, each under its own idempotency key. AT LEAST once — an entry leaves
4
+ * the disk when its ack is written. A principal change abandons the pass and wipes the queue.
8
5
  */
9
6
 
10
- import type { ClientScope } from '@ultimat3/core/page';
7
+ import type { ClientScope, UltimateError } from '@ultimat3/core/page';
11
8
  import {
12
9
  actionPath,
13
10
  classifyThrown,
@@ -15,12 +12,14 @@ import {
15
12
  OUTBOX_DRAIN_MESSAGE,
16
13
  onRescope,
17
14
  pageClient,
15
+ renderThrowable,
18
16
  } from '@ultimat3/core/page';
19
17
  import type { LocalStore } from './local-store-idb';
20
18
  import { pageLocalStore, scopeKey } from './local-store-idb';
21
19
  import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
22
20
  import { MemoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
23
21
  import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
22
+ import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
24
23
  import { peekPageRealtime } from './page-store';
25
24
  import { carriedBy } from './record-store';
26
25
 
@@ -63,70 +62,142 @@ export interface OutboxOptions {
63
62
  * the write queued and the post it liked missing, and showed the old count.
64
63
  */
65
64
  readonly beforeEnqueue?: (() => Promise<void>) | undefined;
65
+ /** Where a disk that refused the queue is said, by code. Default `console.warn`. */
66
+ readonly warn?: ((error: UltimateError) => void) | undefined;
66
67
  }
67
68
 
68
69
  const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
69
70
 
71
+ /** One principal's open queue, and the scope the drain lock is named after. */
72
+ interface Held {
73
+ readonly queue: OfflineQueue;
74
+ readonly scope: string | undefined;
75
+ }
76
+
70
77
  export function createOutbox(options: OutboxOptions): PageOutbox {
71
78
  const principal =
72
79
  options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
73
80
  const send = options.send ?? sendOverHttp;
74
81
  const overlays =
75
82
  options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
76
- let queue: OfflineQueue | undefined;
77
- /** The scope the open queue belongs to — what the drain lock is named after. */
78
- let scope: string | undefined;
83
+ /**
84
+ * The open queue AND the scope it belongs to, as one value: a pass captures both when it starts,
85
+ * so nothing it does later can be read off a principal that arrived since.
86
+ */
87
+ let held: Held | undefined;
88
+ /**
89
+ * Bumped by every principal change, SYNCHRONOUSLY. Anything here that resolves a queue after an
90
+ * await compares it first: the answer to "whose queue" must be the one from before the await.
91
+ */
92
+ let epoch = 0;
93
+ const warn = options.warn ?? ((error: UltimateError): void => console.warn(error));
94
+ const unavailable = (what: string, error: unknown): void =>
95
+ warn(new LocalStoreUnavailableError({ reason: `${what}: ${renderThrowable(error)}` }));
79
96
 
97
+ /** Never rejects: a queue the disk would not open is a memory queue, said once by code. */
80
98
  const open = async (): Promise<void> => {
81
- scope = scopeKey(principal());
82
- queue = await OfflineQueue.open(queueStore(await options.local, scope));
83
- };
84
- let ready = open();
85
- let running: Promise<DrainReport> | undefined;
86
- /** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
87
- let again = false;
88
-
89
- const deliver = async (mutation: QueuedMutation): Promise<void> => {
90
- const current = queue;
91
- const carried = new Set<string>();
99
+ const at = epoch;
100
+ const scope = scopeKey(principal());
101
+ let queue: OfflineQueue;
92
102
  try {
93
- await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
103
+ queue = await OfflineQueue.open(queueStore(await options.local, scope));
94
104
  } catch (error) {
95
- const kind = classifyThrown(error);
96
- // The network, or a server asking to be asked again: stays queued, and the pass stops so
97
- // nothing behind it overtakes it.
98
- if (kind === 'retryable' || kind === 'retry-after') throw error;
99
- // Anything else is the server's decision about this write — kept for the UI, never resent.
100
- await current?.fail(mutation.key, toQueueError(error));
101
- overlays()?.drop(mutation.key);
105
+ unavailable('the outbox could not be opened', error);
106
+ queue = await OfflineQueue.open(new MemoryQueueStore());
107
+ }
108
+ // The principal left while the disk was being read: this queue is the one that left with it,
109
+ // and the handler that saw the change has already chained the next open.
110
+ if (at !== epoch) {
111
+ queue.abandon();
102
112
  return;
103
113
  }
104
- await current?.ack(mutation.key);
105
- const store = overlays();
106
- try {
107
- // Exactly as a live write settles: a row the answer did not carry keeps its overlay until
108
- // the server's row for it arrives.
109
- store?.settle(mutation.key, carried);
110
- } catch {
111
- // A custom merge that answered no row: the server's truth stands.
112
- store?.drop(mutation.key);
114
+ held?.queue.abandon();
115
+ held = { queue, scope };
116
+ };
117
+ let ready = open();
118
+ /** The current principal's queue: `ready` is re-made by a rescope, so it is re-read until still. */
119
+ const current = async (): Promise<Held | undefined> => {
120
+ for (;;) {
121
+ const opening = ready;
122
+ await opening;
123
+ if (opening === ready) return held;
113
124
  }
114
125
  };
126
+ let running: Promise<DrainReport> | undefined;
127
+ /** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
128
+ let again = false;
129
+
130
+ const deliver =
131
+ (queue: OfflineQueue) =>
132
+ async (mutation: QueuedMutation): Promise<void> => {
133
+ const carried = new Set<string>();
134
+ try {
135
+ await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
136
+ } catch (error) {
137
+ const kind = classifyThrown(error);
138
+ // The network, or a server asking to be asked again: stays queued, and the pass stops so
139
+ // nothing behind it overtakes it. So does a queue whose principal left mid-send.
140
+ if (kind === 'retryable' || kind === 'retry-after' || queue.abandoned) throw error;
141
+ // Anything else is the server's decision about this write — kept for the UI, never resent.
142
+ await queue.fail(mutation.key, toQueueError(error));
143
+ overlays()?.drop(mutation.key);
144
+ return;
145
+ }
146
+ // The principal changed while this was on the wire: its store and its twins are already
147
+ // gone, and what is on the page now is somebody else's.
148
+ if (queue.abandoned) return;
149
+ await queue.ack(mutation.key);
150
+ const store = overlays();
151
+ try {
152
+ // Exactly as a live write settles: a row the answer did not carry keeps its overlay until
153
+ // the server's row for it arrives.
154
+ store?.settle(mutation.key, carried);
155
+ } catch {
156
+ // A custom merge that answered no row: the server's truth stands.
157
+ store?.drop(mutation.key);
158
+ }
159
+ };
115
160
 
116
161
  onRescope((_next, prev) => {
117
162
  const gone = scopeKey(prev.principal);
163
+ // SYNCHRONOUSLY, before anything is awaited: a pass parked inside `send` resumes into the next
164
+ // principal's session, and the wipe below reaches the disk but never the pass. Abandoned, it
165
+ // sends nothing more and writes nothing back.
166
+ held?.queue.abandon();
167
+ held = undefined;
168
+ epoch += 1;
169
+ // The single-flight slot is the principal's too: left set, the next principal's trigger JOINED
170
+ // the abandoned pass — handed its report, another principal's key in it — and its own queue
171
+ // was not drained until some later trigger. `settled` only clears a slot that is still its own.
172
+ running = undefined;
173
+ again = false;
118
174
  ready = ready.then(async () => {
119
- if (gone !== undefined) await (await options.local).wipe(gone);
175
+ try {
176
+ if (gone !== undefined) await (await options.local).wipe(gone);
177
+ } catch (error) {
178
+ // Still on disk, under the scope that left: unreachable from this principal, and the next
179
+ // boot's `wipeOthers` takes it. Said, never thrown — a rejection here used to poison the
180
+ // chain, so every later enqueue, replay and rescope on this tab failed with it.
181
+ unavailable("the previous principal's outbox could not be wiped", error);
182
+ }
120
183
  await open();
121
184
  });
122
185
  });
123
186
 
124
187
  const self: PageOutbox = {
125
188
  enqueue: async (entry) => {
189
+ // WHOSE queue is decided now, before anything is awaited: resolved after the flush below,
190
+ // a write issued under one principal and parked there was queued — and sent — as the next.
191
+ // A queue still opening is waited for, and belongs to this call only if nobody left since.
192
+ const at = epoch;
193
+ const mine = held ?? (await current());
194
+ if (mine === undefined || at !== epoch) {
195
+ throw new OfflineQueueAbandonedError({ name: entry.name });
196
+ }
126
197
  // A disk that refused the rows must not also cost the write: the intent still goes on disk.
127
198
  await options.beforeEnqueue?.().catch(() => undefined);
128
- await ready;
129
- await queue?.enqueue(entry);
199
+ // Abandoned meanwhile, it refuses by the same code.
200
+ await mine.queue.enqueue(entry);
130
201
  },
131
202
  replay: () => {
132
203
  // Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
@@ -140,20 +211,19 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
140
211
  return running;
141
212
  }
142
213
  const pass = (async (): Promise<DrainReport> => {
143
- await ready;
144
- if (queue === undefined) return EMPTY;
214
+ const now = await current();
215
+ if (now === undefined) return EMPTY;
145
216
  // Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
146
217
  // attempt the browser already knows cannot leave is a failed request on the wire and
147
218
  // nothing more. `online` asks again.
148
- if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
149
- const draining = queue;
219
+ if (knownOffline()) return { ...EMPTY, remaining: now.queue.pending().length };
150
220
  // One tab drains a principal's outbox at a time, and it drains what EVERY tab queued: the
151
221
  // queue is re-read under the lock, so a write another tab made since this one opened is
152
222
  // sent too, and an `inflight` entry — which only a pass holding this lock could have put
153
223
  // on the wire, and it is over — goes back to `pending`.
154
- return await exclusive(`ultimate-outbox:${scope ?? 'memory'}`, async () => {
155
- await draining.reload();
156
- return await draining.drain(deliver);
224
+ return await exclusive(`ultimate-outbox:${now.scope ?? 'memory'}`, async () => {
225
+ await now.queue.reload();
226
+ return await now.queue.drain(deliver(now.queue));
157
227
  });
158
228
  })();
159
229
  const settled = (report?: DrainReport): void => {
@@ -172,9 +242,10 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
172
242
  return pass;
173
243
  },
174
244
  get size(): number {
175
- return queue?.pending().length ?? 0;
245
+ return held?.queue.pending().length ?? 0;
176
246
  },
177
- pending: () => (queue?.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
247
+ pending: () =>
248
+ (held?.queue.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
178
249
  get ready(): Promise<void> {
179
250
  return ready;
180
251
  },
@@ -3,11 +3,11 @@
3
3
  // is scoped to this connection's Postgres *session*, so acquiring means keeping the connection
4
4
  // open and releasing means closing it: no lease, no renewal, no fencing token, ever.
5
5
 
6
+ import type { AdvisoryLock } from './advisory-lock';
6
7
  import { ReplicationFailedError } from './errors';
7
8
  import { PgConnection, type PgRows } from './pg-connection';
8
9
  import { bunPgStream, type PgTarget, parsePgUrl } from './pg-socket';
9
10
  import type { PgStream } from './pg-wire';
10
- import type { AdvisoryLock } from './replicator';
11
11
  import type { Rng } from './thundering-herd';
12
12
 
13
13
  /** A key reaches a simple query unparameterised, so its charset is the injection boundary. */
@@ -45,6 +45,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
45
45
  * the process dies. A standby then never takes over a slot whose owner has already stopped.
46
46
  */
47
47
  #acquiring: Promise<boolean> | null = null;
48
+ readonly #lostListeners = new Set<(reason: string) => void>();
48
49
 
49
50
  constructor(options: PgAdvisoryLockOptions) {
50
51
  if (!KEY_PATTERN.test(options.key)) {
@@ -91,6 +92,7 @@ export class PgAdvisoryLock implements AdvisoryLock {
91
92
  database: target.database,
92
93
  applicationName: `ultimate-replicator-lock:${this.key}`,
93
94
  rng: this.#options.rng,
95
+ ssl: target.ssl,
94
96
  });
95
97
  let rows: PgRows;
96
98
  try {
@@ -110,9 +112,51 @@ export class PgAdvisoryLock implements AdvisoryLock {
110
112
  // `release()` — which is exactly what lets a crashed replicator free the slot with nothing
111
113
  // left to clean up.
112
114
  this.#connection = connection;
115
+ // The session is idle from here on, and an idle session has no reader: without this a server
116
+ // that ended it was never noticed, and `tryAcquire()` went on answering `true` from memory
117
+ // for a lock Postgres had already handed to another process.
118
+ connection.watchIdle((reason) => this.#lost(connection, reason));
113
119
  return true;
114
120
  }
115
121
 
122
+ /**
123
+ * Drop the session without the unlock statement. The socket is closed outright — no goodbye is
124
+ * written, because a write to a black-holed socket is one more thing that never settles — and
125
+ * Postgres releases the lock when the session ends. An acquisition still in flight is dropped
126
+ * the moment it lands.
127
+ */
128
+ abandon(): void {
129
+ const acquiring = this.#acquiring;
130
+ if (acquiring !== null) {
131
+ // Voided: its own caller has the outcome; this only makes sure nothing stays held after it.
132
+ void acquiring.then(
133
+ () => this.abandon(),
134
+ () => undefined,
135
+ );
136
+ return;
137
+ }
138
+ const connection = this.#connection;
139
+ if (connection === null) return;
140
+ this.#connection = null;
141
+ connection.destroy();
142
+ }
143
+
144
+ onLost(listener: (reason: string) => void): () => void {
145
+ this.#lostListeners.add(listener);
146
+ return () => {
147
+ this.#lostListeners.delete(listener);
148
+ };
149
+ }
150
+
151
+ /** The session ended underneath the lock. A release that got there first owns the goodbye. */
152
+ #lost(connection: PgConnection, reason: string): void {
153
+ if (this.#connection !== connection) return;
154
+ this.#connection = null;
155
+ // The socket is already gone: nothing is said to it, and the listeners hear first.
156
+ connection.destroy();
157
+ for (const listener of [...this.#lostListeners]) listener(reason);
158
+ }
159
+
116
160
  async release(): Promise<void> {
117
161
  // A release that arrives mid-acquisition waits it out. `#connection` is still `null` there, so
118
162
  // the early return below would answer "nothing to release" and the session would open behind
package/src/pg-auth.ts CHANGED
@@ -63,8 +63,10 @@ class ScramSha256Session implements ScramSession {
63
63
  if (!serverNonce.startsWith(this.#clientNonce)) {
64
64
  throw new ReplicationProtocolError({
65
65
  stage: 'auth',
66
- detail: `server nonce "${serverNonce}" does not extend client nonce "${this.#clientNonce}"`,
67
- fix: 'x doctor db — the server did not echo the client nonce; check for a proxy on the replication URL',
66
+ detail:
67
+ `server nonce "${serverNonce}" does not extend client nonce "${this.#clientNonce}" — ` +
68
+ 'the server did not echo the client nonce; check for a proxy on the replication URL',
69
+ fix: 'x doctor --json',
68
70
  });
69
71
  }
70
72
 
@@ -216,8 +218,11 @@ function parseServerFirst(text: string): ServerFirst {
216
218
  if (iterations > MAX_SCRAM_ITERATIONS) {
217
219
  throw new ReplicationProtocolError({
218
220
  stage: 'auth',
219
- detail: `server-first-message asked for ${iterationsText} PBKDF2 iterations, above the ${MAX_SCRAM_ITERATIONS} ceiling`,
220
- fix: 'x doctor db — point the replication URL at postgres itself; a real server asks for 4096',
221
+ detail:
222
+ `server-first-message asked for ${iterationsText} PBKDF2 iterations, above the ` +
223
+ `${MAX_SCRAM_ITERATIONS} ceiling — a real server asks for 4096, so point the replication ` +
224
+ 'URL at postgres itself',
225
+ fix: 'x doctor --json',
221
226
  });
222
227
  }
223
228
  const salt = decodeBase64('salt', saltB64);
package/src/pg-bytes.ts CHANGED
@@ -129,7 +129,10 @@ export class ByteWriter {
129
129
  }
130
130
 
131
131
  uint8(value: number): this {
132
- this.#bytes[this.#room(1)] = value & 0xff;
132
+ // The offset FIRST: `#room` may replace `#bytes`, and `this.#bytes[this.#room(1)]` reads the
133
+ // old buffer before it does — the byte that caused a growth was written into the discard.
134
+ const at = this.#room(1);
135
+ this.#bytes[at] = value & 0xff;
133
136
  return this;
134
137
  }
135
138
 
@@ -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. */
@@ -58,3 +58,19 @@ export function entityRow(
58
58
  for (const field of sealedFields(entity)) delete row[field.property];
59
59
  return row;
60
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();
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
+ }