@ultimat3/realtime 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CLAUDE.md +89 -102
  2. package/README.md +140 -20
  3. package/package.json +4 -4
  4. package/src/advisory-lock.ts +57 -0
  5. package/src/boot.ts +12 -7
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +2 -2
  8. package/src/changefeed.ts +39 -1
  9. package/src/channel-authz.ts +43 -12
  10. package/src/channel-latch.ts +52 -0
  11. package/src/channel-logs.ts +34 -4
  12. package/src/channel-records.ts +33 -2
  13. package/src/channel-sids.ts +37 -0
  14. package/src/channel.ts +143 -44
  15. package/src/client-channels.ts +10 -1
  16. package/src/client-contract.ts +4 -1
  17. package/src/client-frames.ts +34 -4
  18. package/src/client-heartbeat.ts +39 -9
  19. package/src/client.ts +1 -0
  20. package/src/cursor.ts +11 -0
  21. package/src/errors.ts +70 -19
  22. package/src/fanout.ts +13 -0
  23. package/src/index.ts +1 -1
  24. package/src/live-contract.ts +6 -2
  25. package/src/live-definition.ts +46 -11
  26. package/src/live-fanout.ts +70 -11
  27. package/src/live-floor.ts +35 -0
  28. package/src/live-query-options.ts +38 -0
  29. package/src/live-query.ts +119 -91
  30. package/src/live-refusal.ts +21 -0
  31. package/src/live-replicator.ts +6 -6
  32. package/src/live-resume.ts +74 -0
  33. package/src/live-tenant.ts +25 -0
  34. package/src/nats-client.ts +11 -2
  35. package/src/nats-open.ts +9 -0
  36. package/src/nats-transport.ts +23 -1
  37. package/src/offline-queue.ts +48 -5
  38. package/src/page-errors.ts +17 -0
  39. package/src/page-outbox.ts +121 -50
  40. package/src/pg-advisory-lock.ts +45 -1
  41. package/src/pg-auth.ts +9 -4
  42. package/src/pg-bytes.ts +4 -1
  43. package/src/pg-connection.ts +119 -21
  44. package/src/pg-entity-row.ts +27 -2
  45. package/src/pg-identity-tables.ts +25 -0
  46. package/src/pg-preflight.ts +50 -4
  47. package/src/pg-replication-row.ts +82 -0
  48. package/src/pg-replication.ts +44 -45
  49. package/src/pg-socket.ts +6 -4
  50. package/src/pg-tls.ts +18 -0
  51. package/src/pg-wire.ts +5 -3
  52. package/src/pgoutput.ts +19 -9
  53. package/src/presence.ts +85 -8
  54. package/src/query-window.ts +28 -7
  55. package/src/record-persister.ts +7 -2
  56. package/src/replication-errors.ts +8 -4
  57. package/src/replicator-envelope.ts +110 -0
  58. package/src/replicator-row.ts +75 -0
  59. package/src/replicator.ts +319 -186
  60. package/src/server.ts +11 -7
  61. package/src/socket-engine.ts +14 -2
  62. package/src/socket-idle.ts +3 -1
  63. package/src/socket-routes.ts +8 -3
  64. package/src/subscriber-gate.ts +11 -1
  65. package/src/sync-auth.ts +47 -2
  66. package/src/sync-frames.ts +73 -20
  67. package/src/sync-node-bounds.ts +15 -0
  68. package/src/sync-node-contract.ts +20 -2
  69. package/src/sync-node.ts +92 -17
  70. package/src/sync-origin.ts +26 -11
  71. package/src/sync-protocol.ts +13 -3
  72. package/src/sync-upgrade.ts +76 -8
  73. package/src/use-mutation.ts +33 -5
  74. package/src/wire-channel.ts +13 -4
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { renderThrowable, stringField } from '@ultimat3/core/page';
13
13
  import type { JsonValue } from './json';
14
+ import { OfflineQueueAbandonedError } from './page-errors';
14
15
  import type { WireError } from './sync-protocol';
15
16
 
16
17
  export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
@@ -91,6 +92,8 @@ export class OfflineQueue {
91
92
  * is parked inside `send`. Bumped by every loss so a pass that resumes afterwards claims nothing.
92
93
  */
93
94
  #epoch = 0;
95
+ /** Set by `abandon`, never cleared: this queue's principal is gone. */
96
+ #abandoned = false;
94
97
 
95
98
  private constructor(store: QueueStore, state: QueueState) {
96
99
  this.#store = store;
@@ -103,8 +106,10 @@ export class OfflineQueue {
103
106
  *
104
107
  * An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
105
108
  * as it was, `#sendable` skipped it forever — no ack was coming to a page that no longer exists —
106
- * and every later write overtook it. The replay carries its idempotency key, so a write the old
107
- * page did get through is answered from the action's idempotency store, never applied twice.
109
+ * and every later write overtook it. The replay carries its idempotency key, and a `mutator()`
110
+ * is REQUIRED to declare `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT` at declaration), so a
111
+ * write the old page did get through is answered from the action's idempotency store and is
112
+ * never applied twice. That requirement is what makes this resend safe, not the key alone.
108
113
  */
109
114
  static async open(store: QueueStore): Promise<OfflineQueue> {
110
115
  const queue = new OfflineQueue(store, await store.load());
@@ -119,6 +124,7 @@ export class OfflineQueue {
119
124
  * other pass can have one on the wire.
120
125
  */
121
126
  async reload(): Promise<void> {
127
+ if (this.#abandoned) return;
122
128
  const state = await this.#store.load();
123
129
  this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
124
130
  this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
@@ -167,6 +173,17 @@ export class OfflineQueue {
167
173
  input: JsonValue;
168
174
  at?: number;
169
175
  }): Promise<QueuedMutation> {
176
+ // Read BEFORE the key is looked up, never between the lookup and the push: with the await in
177
+ // between, two enqueues of one key both found nothing and both pushed — one intent, two
178
+ // entries under one sequence number. Another tab of the same user may have taken sequence
179
+ // numbers since this one loaded, which is what the read is for.
180
+ //
181
+ // An abandoned queue REFUSES, before the read and after it (the principal can leave during
182
+ // it): a write accepted here would sit in memory, shown as queued, with nothing to send it.
183
+ if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
184
+ const floor = (await this.#store.load()).nextSeq;
185
+ if (this.#abandoned) throw new OfflineQueueAbandonedError({ name: args.name });
186
+ this.#nextSeq = Math.max(this.#nextSeq, floor);
170
187
  const existing = this.find(args.key);
171
188
  if (existing && existing.status !== 'failed') {
172
189
  this.#collapsed += 1;
@@ -178,8 +195,6 @@ export class OfflineQueue {
178
195
  // entry is dropped and this one takes a new sequence at the back of the queue.
179
196
  // By identity: `existing` IS the entry for this key, found above — no second key comparison.
180
197
  if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
181
- // Another tab of the same user may have taken sequence numbers since this one loaded.
182
- this.#nextSeq = Math.max(this.#nextSeq, (await this.#store.load()).nextSeq);
183
198
  const mutation: QueuedMutation = {
184
199
  key: args.key,
185
200
  seq: this.#nextSeq,
@@ -248,6 +263,22 @@ export class OfflineQueue {
248
263
  return returned;
249
264
  }
250
265
 
266
+ /**
267
+ * The principal this queue belongs to is gone (`page-outbox.ts` calls it on a `rescope`), and it
268
+ * is final: nothing more is SENT, WRITTEN or ACCEPTED (`enqueue` refuses). The epoch bump stops a pass parked
269
+ * inside `send` before its next entry — which would otherwise leave under the next principal's
270
+ * session — and the flag stops a pass that starts later, and every write-back: the store was
271
+ * wiped for this principal, and a pass that then saved what it touched put the writes back.
272
+ */
273
+ abandon(): void {
274
+ this.#epoch += 1;
275
+ this.#abandoned = true;
276
+ }
277
+
278
+ get abandoned(): boolean {
279
+ return this.#abandoned;
280
+ }
281
+
251
282
  /** Server acknowledged: the mutation leaves the queue and its rebase entry can be committed. */
252
283
  async ack(key: string): Promise<void> {
253
284
  const mutation = this.find(key);
@@ -294,7 +325,7 @@ export class OfflineQueue {
294
325
  /** One drain pass. Never called concurrently with itself — `drain` owns that. */
295
326
  async #pass(send: MutationSender): Promise<DrainReport> {
296
327
  const epoch = this.#epoch;
297
- const sendable = this.#sendable();
328
+ const sendable = this.#abandoned ? [] : this.#sendable();
298
329
  // Nothing to do: a pass chained behind one that already sent everything must not rewrite the
299
330
  // durable state for the privilege of reporting zero.
300
331
  if (sendable.length === 0) {
@@ -326,6 +357,12 @@ export class OfflineQueue {
326
357
  // `WebSocket.send` on a CLOSING socket discards it and returns normally — so calling that
327
358
  // an ack drops the mutation on exactly the socket death this queue exists to survive.
328
359
  // Only `ack`/`fail` (the server) or `requeueInflight` (a lost connection) moves it on.
360
+ //
361
+ // A sender may settle the write itself before it returns — the page outbox does, over
362
+ // HTTP. One it REFUSED keeps the error `fail` just recorded and is not counted as sent:
363
+ // clearing it here put `{ status: 'failed', error: null }` on disk and reported a refused
364
+ // write as a sent one.
365
+ if (statusOf(mutation) === 'failed') continue;
329
366
  mutation.error = null;
330
367
  sent += 1;
331
368
  } catch (error) {
@@ -364,6 +401,7 @@ export class OfflineQueue {
364
401
  * queue: a second tab's entries are not this tab's to overwrite or delete.
365
402
  */
366
403
  async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
404
+ if (this.#abandoned) return;
367
405
  await this.#store.write({
368
406
  puts: puts.map((mutation) => ({ ...mutation })),
369
407
  deletes,
@@ -372,6 +410,11 @@ export class OfflineQueue {
372
410
  }
373
411
  }
374
412
 
413
+ /** Read through a call: `send` awaited in between, and the compiler still narrows to `inflight`. */
414
+ function statusOf(mutation: QueuedMutation): MutationStatus {
415
+ return mutation.status;
416
+ }
417
+
375
418
  /** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
376
419
  export function toQueueError(error: unknown): WireError {
377
420
  return {
@@ -93,6 +93,23 @@ export class LocalStoreUnavailableError extends RealtimeError {
93
93
  }
94
94
  }
95
95
 
96
+ /**
97
+ * A write reached a queue whose principal is gone: the page signed out, or in as someone else,
98
+ * while the write was being queued. It is not stored and never sent — a write issued under one
99
+ * session must not leave under the next — and it is RAISED, because a user's write dropped in
100
+ * silence is a loss nobody is told about. `name` is the mutator's registered name; the write's
101
+ * idempotency key is deliberately not rendered.
102
+ */
103
+ export class OfflineQueueAbandonedError extends RealtimeError {
104
+ constructor(args: { name: string }) {
105
+ super({
106
+ code: 'X_OFFLINE_QUEUE_ABANDONED',
107
+ cause: `the write "${args.name}" was queued after its page changed principal, so it was not stored and will not be sent — call the mutation again under the current session`,
108
+ fix: 'x errors explain X_OFFLINE_QUEUE_ABANDONED --json',
109
+ });
110
+ }
111
+ }
112
+
96
113
  /**
97
114
  * Something that can only mean "talk to the socket" ran on the server client — a mutation, a
98
115
  * publish, a topic subscription, a dial. There is no socket during a server render and there never
@@ -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