@ultimat3/realtime 30.9.0 → 31.1.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 (67) hide show
  1. package/CLAUDE.md +72 -82
  2. package/README.md +36 -40
  3. package/package.json +8 -6
  4. package/src/adapter.ts +14 -0
  5. package/src/apply-patches.ts +8 -5
  6. package/src/boot.ts +15 -2
  7. package/src/changefeed.ts +11 -4
  8. package/src/channel-decl.ts +29 -1
  9. package/src/channel-publish.ts +17 -19
  10. package/src/channel-ref.ts +22 -2
  11. package/src/client-channels.ts +11 -5
  12. package/src/client-contract.ts +2 -2
  13. package/src/cursor.ts +5 -5
  14. package/src/device-agent.ts +2 -2
  15. package/src/device-errors.ts +1 -1
  16. package/src/device-socket.ts +1 -1
  17. package/src/error-titles-server.ts +40 -0
  18. package/src/error-titles.ts +12 -21
  19. package/src/errors.ts +43 -10
  20. package/src/idb-types.ts +6 -0
  21. package/src/index.ts +5 -27
  22. package/src/json.ts +7 -3
  23. package/src/live-contract.ts +4 -4
  24. package/src/live-definition.ts +9 -6
  25. package/src/live-deliver.ts +40 -2
  26. package/src/live-entries.ts +73 -0
  27. package/src/live-fanout.ts +25 -9
  28. package/src/live-patch-encode.ts +30 -0
  29. package/src/live-query-options.ts +11 -0
  30. package/src/live-query.ts +26 -11
  31. package/src/live-replicator.ts +24 -21
  32. package/src/local-store-idb.ts +66 -6
  33. package/src/matcher-bridge.ts +15 -15
  34. package/src/nats-transport.ts +5 -5
  35. package/src/offline-queue-store.ts +69 -0
  36. package/src/offline-queue.ts +91 -91
  37. package/src/page-errors.ts +15 -0
  38. package/src/page-outbox.ts +18 -3
  39. package/src/pg-connection.ts +9 -1
  40. package/src/pg-preflight.ts +21 -5
  41. package/src/pg-replication-dial.ts +95 -0
  42. package/src/pg-replication-liveness.ts +84 -0
  43. package/src/pg-replication-position.ts +42 -0
  44. package/src/pg-replication-row.ts +6 -6
  45. package/src/pg-replication-stats.ts +28 -0
  46. package/src/pg-replication.ts +106 -125
  47. package/src/pg-slot-release.ts +40 -0
  48. package/src/policy-gate.ts +2 -2
  49. package/src/query-window.ts +4 -4
  50. package/src/record-publisher.ts +2 -2
  51. package/src/record-store.ts +7 -5
  52. package/src/replication-errors.ts +40 -2
  53. package/src/replication-resync.ts +38 -0
  54. package/src/replicator-deadline.ts +33 -0
  55. package/src/replicator-reconnect.ts +85 -0
  56. package/src/replicator-resync.ts +30 -0
  57. package/src/replicator-row.ts +5 -5
  58. package/src/replicator-subject.ts +28 -0
  59. package/src/replicator.ts +79 -69
  60. package/src/server.ts +8 -6
  61. package/src/socket-engine.ts +2 -2
  62. package/src/subscriber-gate.ts +14 -10
  63. package/src/sync-bus-handlers.ts +20 -4
  64. package/src/sync-protocol.ts +3 -3
  65. package/src/thundering-herd.ts +17 -24
  66. package/src/transport-env.ts +20 -48
  67. package/src/window-lock.ts +15 -3
@@ -10,72 +10,22 @@
10
10
  // `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
11
11
 
12
12
  import type { ActionPin } from '@ultimat3/core/page';
13
- import { renderThrowable, stringField } from '@ultimat3/core/page';
13
+ import { finiteCount, renderThrowable, stringField } from '@ultimat3/core/page';
14
14
  import type { JsonValue } from './json';
15
- import { OfflineQueueAbandonedError } from './page-errors';
15
+ import type { MutationStatus, QueuedMutation, QueueState, QueueStore } from './offline-queue-store';
16
+ import { OfflineQueueAbandonedError, OfflineQueueFullError } from './page-errors';
16
17
  import type { WireError } from './sync-protocol';
17
18
 
18
- export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
19
-
20
- export interface QueuedMutation {
21
- /** Idempotency key. Supplied by the mutator, stable across retries and reloads. */
22
- readonly key: string;
23
- /** Monotonic per client. Never renumbered — rebase replays in this order. */
24
- readonly seq: number;
25
- readonly name: string;
26
- readonly input: JsonValue;
27
- /** The action's `http` pin, when the write was made against one (`pinned-wire.ts`). */
28
- readonly http?: ActionPin | undefined;
29
- readonly enqueuedAt: number;
30
- attempts: number;
31
- status: MutationStatus;
32
- error: WireError | null;
33
- }
34
-
35
- export interface QueueState {
36
- readonly mutations: readonly QueuedMutation[];
37
- readonly nextSeq: number;
38
- }
39
-
40
- /**
41
- * One change to the durable queue: these entries written BY KEY, these keys deleted, and the
42
- * sequence floor. Never the whole queue — two tabs of one user share the store, and a whole-queue
43
- * save let the last tab to save erase the other's queued write.
44
- */
45
- export interface QueueChange {
46
- readonly puts: readonly QueuedMutation[];
47
- readonly deletes: readonly string[];
48
- readonly nextSeq: number;
49
- }
50
-
51
- /** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
52
- export interface QueueStore {
53
- load(): Promise<QueueState>;
54
- write(change: QueueChange): Promise<void>;
55
- }
56
-
57
- export class MemoryQueueStore implements QueueStore {
58
- readonly #mutations = new Map<string, QueuedMutation>();
59
- #nextSeq = 1;
60
-
61
- async load(): Promise<QueueState> {
62
- return {
63
- mutations: [...this.#mutations.values()].map((m) => ({ ...m })),
64
- nextSeq: this.#nextSeq,
65
- };
66
- }
67
-
68
- async write(change: QueueChange): Promise<void> {
69
- for (const key of change.deletes) this.#mutations.delete(key);
70
- for (const mutation of change.puts) this.#mutations.set(mutation.key, { ...mutation });
71
- this.#nextSeq = Math.max(this.#nextSeq, change.nextSeq);
72
- }
73
- }
74
-
75
- /** The one way to build the in-process queue store; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
76
- export function memoryQueueStore(): MemoryQueueStore {
77
- return new MemoryQueueStore();
78
- }
19
+ // The shapes and the memory store moved next door; their importers keep this path.
20
+ export {
21
+ MemoryQueueStore,
22
+ type MutationStatus,
23
+ memoryQueueStore,
24
+ type QueueChange,
25
+ type QueuedMutation,
26
+ type QueueState,
27
+ type QueueStore,
28
+ } from './offline-queue-store';
79
29
 
80
30
  export interface DrainReport {
81
31
  readonly sent: number;
@@ -87,9 +37,25 @@ export interface DrainReport {
87
37
 
88
38
  export type MutationSender = (mutation: QueuedMutation) => Promise<void>;
89
39
 
40
+ export interface OfflineQueueOptions {
41
+ /**
42
+ * The most writes held at once. Past it a NEW key is refused (`X_OFFLINE_QUEUE_FULL`); a key
43
+ * already held still collapses, and nothing queued is ever dropped. Absent: no ceiling.
44
+ */
45
+ readonly maxQueued?: number | undefined;
46
+ }
47
+
90
48
  export class OfflineQueue {
91
49
  readonly #store: QueueStore;
92
- #mutations: QueuedMutation[] = [];
50
+ readonly #maxQueued: number | undefined;
51
+ /**
52
+ * By key, HELD in sequence order: a key is a lookup and the queue reads in seq order with no sort.
53
+ * A scan per enqueue and ack plus a sort per `pending()` made a long offline session quadratic.
54
+ * Written through `#seat` and `#append` only, which are what keep the order true.
55
+ */
56
+ #mutations = new Map<string, QueuedMutation>();
57
+ /** The highest seq held: an append above it keeps the order without a sort. */
58
+ #tailSeq = 0;
93
59
  #nextSeq = 1;
94
60
  #collapsed = 0;
95
61
  /** The drain lane: one pass at a time, in call order. See `drain`. */
@@ -106,9 +72,12 @@ export class OfflineQueue {
106
72
  #writing = 0;
107
73
  #written = 0;
108
74
 
109
- private constructor(store: QueueStore, state: QueueState) {
75
+ private constructor(store: QueueStore, state: QueueState, options: OfflineQueueOptions) {
110
76
  this.#store = store;
111
- this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
77
+ const max = options.maxQueued;
78
+ this.#maxQueued =
79
+ max === undefined ? undefined : finiteCount('OfflineQueue', 'maxQueued', max, 1);
80
+ this.#seat(state.mutations.map((mutation) => ({ ...mutation })));
112
81
  this.#nextSeq = state.nextSeq;
113
82
  }
114
83
 
@@ -122,8 +91,8 @@ export class OfflineQueue {
122
91
  * write the old page did get through is answered from the action's idempotency store and is
123
92
  * never applied twice. That requirement is what makes this resend safe, not the key alone.
124
93
  */
125
- static async open(store: QueueStore): Promise<OfflineQueue> {
126
- const queue = new OfflineQueue(store, await store.load());
94
+ static async open(store: QueueStore, options: OfflineQueueOptions = {}): Promise<OfflineQueue> {
95
+ const queue = new OfflineQueue(store, await store.load(), options);
127
96
  await queue.#reclaimInflight();
128
97
  return queue;
129
98
  }
@@ -137,7 +106,7 @@ export class OfflineQueue {
137
106
  async reload(): Promise<void> {
138
107
  if (this.#abandoned) return;
139
108
  const state = await this.#store.load();
140
- this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
109
+ this.#seat(state.mutations.map((mutation) => ({ ...mutation })));
141
110
  this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
142
111
  await this.#reclaimInflight();
143
112
  }
@@ -162,7 +131,7 @@ export class OfflineQueue {
162
131
  #adopt(state: QueueState): void {
163
132
  const onDisk = new Map(state.mutations.map((mutation) => [mutation.key, mutation]));
164
133
  const kept: QueuedMutation[] = [];
165
- for (const mutation of this.#mutations) {
134
+ for (const mutation of this.#mutations.values()) {
166
135
  const disk = onDisk.get(mutation.key);
167
136
  onDisk.delete(mutation.key);
168
137
  // Gone from the disk: another tab's pass settled it. One on THIS tab's wire stays its own.
@@ -174,18 +143,42 @@ export class OfflineQueue {
174
143
  kept.push(mutation);
175
144
  }
176
145
  for (const disk of onDisk.values()) kept.push({ ...disk });
177
- this.#mutations = kept;
146
+ this.#seat(kept);
178
147
  this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
179
148
  }
180
149
 
150
+ /** The whole queue replaced — a load, a reload, a refresh: sorted ONCE here, never on a read. */
151
+ #seat(mutations: readonly QueuedMutation[]): void {
152
+ const ordered = [...mutations].sort((a, b) => a.seq - b.seq);
153
+ this.#mutations = new Map(ordered.map((mutation) => [mutation.key, mutation]));
154
+ this.#tailSeq = ordered.at(-1)?.seq ?? 0;
155
+ }
156
+
157
+ /** One new entry. Its seq is the queue's newest in every path today; one that is not re-seats. */
158
+ #append(mutation: QueuedMutation): void {
159
+ if (mutation.seq > this.#tailSeq) {
160
+ this.#mutations.set(mutation.key, mutation);
161
+ this.#tailSeq = mutation.seq;
162
+ return;
163
+ }
164
+ this.#seat([...this.#mutations.values(), mutation]);
165
+ }
166
+
167
+ /** Is this the entry the queue holds for its key? Identity: a re-issued key is another entry. */
168
+ #holds(mutation: QueuedMutation): boolean {
169
+ return this.#mutations.get(mutation.key) === mutation;
170
+ }
171
+
181
172
  async #reclaimInflight(): Promise<void> {
182
- const reclaimed = this.#mutations.filter((mutation) => mutation.status === 'inflight');
173
+ const reclaimed = [...this.#mutations.values()].filter(
174
+ (mutation) => mutation.status === 'inflight',
175
+ );
183
176
  for (const mutation of reclaimed) mutation.status = 'pending';
184
177
  if (reclaimed.length > 0) await this.#persist(reclaimed);
185
178
  }
186
179
 
187
180
  get size(): number {
188
- return this.#mutations.length;
181
+ return this.#mutations.size;
189
182
  }
190
183
 
191
184
  get collapsed(): number {
@@ -197,7 +190,7 @@ export class OfflineQueue {
197
190
  }
198
191
 
199
192
  find(key: string): QueuedMutation | undefined {
200
- return this.#mutations.find((mutation) => mutation.key === key);
193
+ return this.#mutations.get(key);
201
194
  }
202
195
 
203
196
  /**
@@ -205,13 +198,13 @@ export class OfflineQueue {
205
198
  * anything downstream is allowed to use, and the count a UI renders as "unsynced".
206
199
  */
207
200
  pending(): readonly QueuedMutation[] {
208
- return this.#mutations
209
- .filter((mutation) => mutation.status === 'pending' || mutation.status === 'inflight')
210
- .sort((a, b) => a.seq - b.seq);
201
+ return [...this.#mutations.values()].filter(
202
+ (mutation) => mutation.status === 'pending' || mutation.status === 'inflight',
203
+ );
211
204
  }
212
205
 
213
206
  all(): readonly QueuedMutation[] {
214
- return [...this.#mutations].sort((a, b) => a.seq - b.seq);
207
+ return [...this.#mutations.values()];
215
208
  }
216
209
 
217
210
  async enqueue(args: {
@@ -237,12 +230,21 @@ export class OfflineQueue {
237
230
  this.#collapsed += 1;
238
231
  return existing;
239
232
  }
233
+ // After the collapse, never before it: a held key — even a failed one re-issued in place — is
234
+ // no new write. Refused, never made room for: a dropped entry is an intent the user saw saved.
235
+ // Counted as `pending()` counts — still to send: a refused entry kept for the UI never drains,
236
+ // and counting it filled the queue for good, across reloads. Walked only once at the size.
237
+ const max = this.#maxQueued;
238
+ const full = max !== undefined && this.#mutations.size >= max && this.pending().length >= max;
239
+ if (existing === undefined && full) {
240
+ throw new OfflineQueueFullError({ name: args.name, maxQueued: max });
241
+ }
240
242
  // A terminally failed entry is a decision the server already made about this key, kept for the
241
243
  // UI — collapsing onto it makes an explicit idempotency key unusable for the rest of the
242
244
  // session, because nothing ever retries a denial. Re-issuing one is a NEW intent, so the old
243
245
  // entry is dropped and this one takes a new sequence at the back of the queue.
244
246
  // By identity: `existing` IS the entry for this key, found above — no second key comparison.
245
- if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
247
+ if (existing) this.#mutations.delete(existing.key);
246
248
  const mutation: QueuedMutation = {
247
249
  key: args.key,
248
250
  seq: this.#nextSeq,
@@ -255,7 +257,7 @@ export class OfflineQueue {
255
257
  error: null,
256
258
  };
257
259
  this.#nextSeq += 1;
258
- this.#mutations.push(mutation);
260
+ this.#append(mutation);
259
261
  await this.#persist([mutation]);
260
262
  return mutation;
261
263
  }
@@ -302,7 +304,7 @@ export class OfflineQueue {
302
304
  this.#epoch += 1;
303
305
  let returned = 0;
304
306
  const back: QueuedMutation[] = [];
305
- for (const mutation of this.#mutations) {
307
+ for (const mutation of this.#mutations.values()) {
306
308
  if (mutation.status !== 'inflight') continue;
307
309
  mutation.status = 'pending';
308
310
  returned += 1;
@@ -333,8 +335,7 @@ export class OfflineQueue {
333
335
  const mutation = this.find(key);
334
336
  if (!mutation) return;
335
337
  mutation.status = 'acked';
336
- // By identity: `find` already matched it, and a second comparison of the key is the same question.
337
- this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
338
+ this.#mutations.delete(key);
338
339
  await this.#persist([], [key]);
339
340
  }
340
341
 
@@ -361,14 +362,12 @@ export class OfflineQueue {
361
362
  * nothing at all.
362
363
  */
363
364
  #stillSendable(mutation: QueuedMutation): boolean {
364
- return mutation.status === 'pending' && this.#mutations.includes(mutation);
365
+ return mutation.status === 'pending' && this.#holds(mutation);
365
366
  }
366
367
 
367
368
  /** Never sent on this connection. `inflight` is excluded: it is already on a socket. */
368
369
  #sendable(): readonly QueuedMutation[] {
369
- return this.#mutations
370
- .filter((mutation) => mutation.status === 'pending')
371
- .sort((a, b) => a.seq - b.seq);
370
+ return [...this.#mutations.values()].filter((mutation) => mutation.status === 'pending');
372
371
  }
373
372
 
374
373
  /** One drain pass. Never called concurrently with itself — `drain` owns that. */
@@ -417,7 +416,7 @@ export class OfflineQueue {
417
416
  } catch (error) {
418
417
  // Settled inside `send` before it threw (the page outbox acks or fails it, then a merge may
419
418
  // raise): writing it back as `pending` would replay what the server already decided.
420
- if (this.#mutations.includes(mutation) && statusOf(mutation) !== 'failed') {
419
+ if (this.#holds(mutation) && statusOf(mutation) !== 'failed') {
421
420
  mutation.status = 'pending';
422
421
  mutation.error = toQueueError(error);
423
422
  await this.#persist([mutation]);
@@ -432,7 +431,7 @@ export class OfflineQueue {
432
431
  }
433
432
  // Only what this pass touched AND the queue still holds: an entry the server settled during the
434
433
  // pass was already deleted by `ack`, and writing it back would resurrect it.
435
- await this.#persist(touched.filter((mutation) => this.#mutations.includes(mutation)));
434
+ await this.#persist(touched.filter((mutation) => this.#holds(mutation)));
436
435
  return {
437
436
  sent,
438
437
  collapsed: this.#collapsed,
@@ -442,8 +441,9 @@ export class OfflineQueue {
442
441
  }
443
442
 
444
443
  async clear(): Promise<void> {
445
- const keys = this.#mutations.map((mutation) => mutation.key);
446
- this.#mutations = [];
444
+ const keys = [...this.#mutations.keys()];
445
+ this.#mutations = new Map();
446
+ this.#tailSeq = 0;
447
447
  await this.#persist([], keys);
448
448
  }
449
449
 
@@ -109,6 +109,21 @@ export class OfflineQueueAbandonedError extends RealtimeError {
109
109
  }
110
110
  }
111
111
 
112
+ /**
113
+ * A write made while the outbox already holds its `maxQueued` unsent writes. REFUSED to the caller
114
+ * that made it, never made room for: dropping a queued write would lose an intent the user was
115
+ * shown as saved. A key already queued is no new write and is never refused.
116
+ */
117
+ export class OfflineQueueFullError extends RealtimeError {
118
+ constructor(args: { name: string; maxQueued: number }) {
119
+ super({
120
+ code: 'X_OFFLINE_QUEUE_FULL',
121
+ cause: `the write "${args.name}" was refused: the offline queue already holds ${args.maxQueued} unsent writes (maxQueued), and none is dropped to make room — call the mutation again once the page is back online`,
122
+ fix: 'OfflineQueue.open(store, { maxQueued }) # raise the ceiling, or let the page reconnect so the queue drains',
123
+ });
124
+ }
125
+ }
126
+
112
127
  /**
113
128
  * Something that can only mean "talk to the socket" ran on the server client — a mutation, a
114
129
  * publish, a topic subscription, a dial. There is no socket during a server render and there never
@@ -8,6 +8,7 @@ import type { ClientScope, UltimateError } from '@ultimat3/core/page';
8
8
  import {
9
9
  classifyThrown,
10
10
  clientTransport,
11
+ finiteCount,
11
12
  OUTBOX_DRAIN_MESSAGE,
12
13
  onRescope,
13
14
  pageClient,
@@ -74,6 +75,11 @@ export interface OutboxOptions {
74
75
  readonly tabs?: ((heard: (scope: string | null) => void) => OutboxTabs) | undefined;
75
76
  /** Where a disk that refused the queue is said, by code. Default `console.warn`. */
76
77
  readonly warn?: ((error: UltimateError) => void) | undefined;
78
+ /**
79
+ * The most writes queued at once, on every queue this outbox opens. Past it a NEW write is
80
+ * refused (`X_OFFLINE_QUEUE_FULL`) and nothing queued is dropped. Absent: no ceiling.
81
+ */
82
+ readonly maxQueued?: number | undefined;
77
83
  }
78
84
 
79
85
  const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
@@ -88,6 +94,12 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
88
94
  const principal =
89
95
  options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
90
96
  const send = options.send ?? sendOverHttp;
97
+ // Refused HERE, where the outbox is built: inside `open` a bad ceiling would read as a refused
98
+ // disk, and the memory fallback would throw it again with nobody to catch it.
99
+ const ceiling =
100
+ options.maxQueued === undefined
101
+ ? {}
102
+ : { maxQueued: finiteCount('localOutbox', 'maxQueued', options.maxQueued, 1) };
91
103
  const overlays =
92
104
  options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
93
105
  /**
@@ -142,10 +154,10 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
142
154
  const scope = scopeKey(principal());
143
155
  let queue: OfflineQueue;
144
156
  try {
145
- queue = await OfflineQueue.open(queueStore(await options.local, scope));
157
+ queue = await OfflineQueue.open(queueStore(await options.local, scope), ceiling);
146
158
  } catch (error) {
147
159
  unavailable('the outbox could not be opened', error);
148
- queue = await OfflineQueue.open(memoryQueueStore());
160
+ queue = await OfflineQueue.open(memoryQueueStore(), ceiling);
149
161
  }
150
162
  // The principal left while the disk was being read: this queue is the one that left with it,
151
163
  // and the handler that saw the change has already chained the next open.
@@ -390,7 +402,9 @@ async function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<u
390
402
  * The page's outbox, created once per tab. In a browser it replays on open, on `online`, and on the
391
403
  * service worker's drain message; with no `document` it is a memory queue that listens for nothing.
392
404
  */
393
- export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}): PageOutbox {
405
+ export function pageOutbox(
406
+ options: Pick<OutboxOptions, 'beforeEnqueue' | 'maxQueued'> = {},
407
+ ): PageOutbox {
394
408
  const host = globalThis as OutboxHost;
395
409
  const existing = host[OUTBOX_KEY];
396
410
  // Only this function writes the slot, so what it holds is always a `PageOutbox`.
@@ -399,6 +413,7 @@ export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}):
399
413
  const outbox = localOutbox({
400
414
  local: pageLocalStore(),
401
415
  beforeEnqueue: options.beforeEnqueue,
416
+ maxQueued: options.maxQueued,
402
417
  // Every tab of this user shares the durable queue; each re-reads it when another changes it.
403
418
  ...(browser ? { tabs: (heard) => outboxTabs(globalThis as OutboxTabsWindow, heard) } : {}),
404
419
  });
@@ -124,7 +124,15 @@ export class PgConnection {
124
124
  // or role default like `America/New_York` writes a pre-1883 instant at its LMT offset,
125
125
  // `-04:56:02`. The decoder reads that too, but UTC text is the one form with no ambient zone
126
126
  // in it — and a client option outranks every server-side default, `ALTER ROLE … SET` included.
127
- options: '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3 -c TimeZone=UTC',
127
+ //
128
+ // Then the SERVER's TCP keepalives. Both sessions this opens are held idle for hours — the
129
+ // replicator's advisory lock IS its session — and a holder lost without a FIN (a failover
130
+ // moving the address, a NAT drop) kept the lock until the OS default, ~2 h 11 m: no standby
131
+ // took over. Probed after 10 s idle, every 5 s, dead after 3 misses — ~25 s. Ignored, not
132
+ // refused, on a Unix socket, where there is no TCP to probe.
133
+ options:
134
+ '-c datestyle=ISO -c intervalstyle=postgres -c extra_float_digits=3 -c TimeZone=UTC ' +
135
+ '-c tcp_keepalives_idle=10 -c tcp_keepalives_interval=5 -c tcp_keepalives_count=3',
128
136
  };
129
137
  if (options.replication !== undefined) parameters['replication'] = options.replication;
130
138
  // A handshake fails on ordinary conditions — no password, an ErrorResponse, an EOF — and on
@@ -4,10 +4,15 @@
4
4
  // stop every app on the default replica identity from starting.
5
5
 
6
6
  import { logger } from '@ultimat3/core';
7
- import { ReplicaIdentityError, ReplicationFailedError } from './errors';
7
+ import {
8
+ ReplicaIdentityError,
9
+ ReplicationFailedError,
10
+ ReplicationSlotInvalidatedError,
11
+ } from './errors';
8
12
  import type { PgConnection } from './pg-connection';
9
13
  import { assertIdentifier } from './pg-identifier';
10
14
  import { ensurePublication } from './pg-publication';
15
+ import { awaitSlotReleased } from './pg-slot-release';
11
16
 
12
17
  /**
13
18
  * The four things that are always misconfigured. `wal_level` and the slot produce an unreadable
@@ -17,13 +22,16 @@ import { ensurePublication } from './pg-publication';
17
22
  * simple queries, so the identifier charset is the injection boundary; re-asserted here rather than trusted, so the guarantee
18
23
  * travels with the function instead of living only in `start()`.
19
24
  */
25
+ /** What preflight found the slot as: there already, or created just now by this call. */
26
+ export type SlotState = 'existing' | 'created';
27
+
20
28
  export async function preflight(
21
29
  connection: PgConnection,
22
30
  slot: string,
23
31
  publication: string,
24
32
  entities: ReadonlySet<string>,
25
33
  fullIdentity: ReadonlySet<string> = new Set(),
26
- ): Promise<void> {
34
+ ): Promise<SlotState> {
27
35
  assertIdentifier('slot', slot);
28
36
  assertIdentifier('publication', publication);
29
37
  const [walLevel] = await connection.query('SHOW wal_level');
@@ -39,14 +47,19 @@ export async function preflight(
39
47
  await ensurePublication(connection, publication, entities);
40
48
  await warnPartialIdentity(connection, entities);
41
49
  await warnChannelIdentity(connection, entities, fullIdentity);
50
+ // `wal_status` in the same statement: `lost` is a slot postgres invalidated, which no restart
51
+ // can stream from — said with its own code, never as the generic failure START_REPLICATION gives.
52
+ // `active_pid` too: a walsender still on its way out holds the slot, and START_REPLICATION would
53
+ // refuse a restart right behind it with 55006 (`pg-slot-release.ts`).
42
54
  const [existing] = await connection.query(
43
- `SELECT plugin FROM pg_replication_slots WHERE slot_name = '${slot}'`,
55
+ `SELECT plugin, wal_status, active_pid FROM pg_replication_slots WHERE slot_name = '${slot}'`,
44
56
  );
45
57
  if (existing === undefined) {
46
58
  // Plain SQL rather than CREATE_REPLICATION_SLOT: the replication command exports a snapshot
47
- // that pins xmin for the session, and its option syntax changed in postgres 15.
59
+ // that pins xmin for the session, and its option syntax changed in postgres 15. The caller
60
+ // decides what a NEW slot means — a resume position it had is now a gap (`pg-replication-dial.ts`).
48
61
  await connection.query(`SELECT pg_create_logical_replication_slot('${slot}', 'pgoutput')`);
49
- return;
62
+ return 'created';
50
63
  }
51
64
  if (existing[0] !== 'pgoutput') {
52
65
  throw new ReplicationFailedError({
@@ -55,6 +68,9 @@ export async function preflight(
55
68
  fix: `SELECT pg_drop_replication_slot('${slot}'); -- then start the replicator again`,
56
69
  });
57
70
  }
71
+ if (existing[1] === 'lost') throw new ReplicationSlotInvalidatedError({ slot });
72
+ await awaitSlotReleased(connection, slot, existing[2]);
73
+ return 'existing';
58
74
  }
59
75
 
60
76
  /**
@@ -0,0 +1,95 @@
1
+ // Single responsibility: bring one replication stream up — open the walsender session, preflight
2
+ // it, and START_REPLICATION from the right place — and say whether the resume position it was
3
+ // handed still means anything. Reading the stream once it is up is `pg-replication.ts`'s.
4
+
5
+ import { logger } from '@ultimat3/core';
6
+ import type { PostgresChangeFeedOptions } from './changefeed';
7
+ import { ReplicationSlotRecreatedError } from './errors';
8
+ import { printLsn } from './pg-bytes';
9
+ import { PgConnection } from './pg-connection';
10
+ import { paramsChannelTables } from './pg-identity-tables';
11
+ import { preflight } from './pg-preflight';
12
+ import { commitPositionOf } from './pg-replication-position';
13
+ import { bunPgStream, parsePgUrl } from './pg-socket';
14
+
15
+ /** The identifiers the stream already put through `assertIdentifier`. */
16
+ export interface StreamNames {
17
+ readonly slot: string;
18
+ readonly publication: string;
19
+ readonly entities: ReadonlySet<string>;
20
+ }
21
+
22
+ /**
23
+ * A slot this stream created under a resume position, carried until a dial gets through. A dial
24
+ * that created it and then failed (START_REPLICATION refused, the socket dropped) leaves the next
25
+ * one finding it EXISTING — and the gap no smaller. Owned by the stream, written here.
26
+ */
27
+ export interface SlotGap {
28
+ createdUnder: string | null;
29
+ }
30
+
31
+ export interface DialedStream {
32
+ readonly connection: PgConnection;
33
+ /** Where START_REPLICATION began — what the stream confirms until its first commit. */
34
+ readonly confirmed: bigint;
35
+ /** The resume filter for this run: `undefined` once the slot it pointed into is gone. */
36
+ readonly from: string | undefined;
37
+ /** Why every reader must re-read now, or `null` when the stream is continuous. */
38
+ readonly resync: string | null;
39
+ }
40
+
41
+ /**
42
+ * Open, preflight, START_REPLICATION. On any failure after the socket opened, the session is closed
43
+ * before the failure is rethrown: the dial failure explains the boot, a close that also failed
44
+ * does not, and nothing is left holding the slot.
45
+ */
46
+ export async function dialReplication(
47
+ options: PostgresChangeFeedOptions,
48
+ names: StreamNames,
49
+ from: string | undefined,
50
+ gap: SlotGap,
51
+ ): Promise<DialedStream> {
52
+ const target = parsePgUrl(options.url);
53
+ const stream = await (options.stream ?? bunPgStream)(target);
54
+ const connection = await PgConnection.open({
55
+ stream,
56
+ user: target.user,
57
+ password: target.password,
58
+ database: target.database,
59
+ replication: 'database',
60
+ applicationName: `ultimate-replicator:${names.slot}`,
61
+ rng: options.rng,
62
+ ssl: target.ssl,
63
+ });
64
+ try {
65
+ const full = new Set(options.fullIdentityTables ?? paramsChannelTables());
66
+ const slot = await preflight(connection, names.slot, names.publication, names.entities, full);
67
+ // A NEW slot under a resume position is a gap: it starts at the server's current WAL, so the
68
+ // changes between the old confirm and now were never streamed — and after a failover the old
69
+ // position is another server's WAL, so it must neither start the stream nor filter it.
70
+ if (slot === 'created' && from !== undefined) gap.createdUnder ??= from;
71
+ const under = from === undefined ? null : gap.createdUnder;
72
+ const recreated = under !== null;
73
+ const resumeFrom = recreated ? undefined : from;
74
+ if (recreated) {
75
+ const said = new ReplicationSlotRecreatedError({ slot: names.slot, from: under });
76
+ // FIELDS, never interpolation; the message is the CODE alone (`pg-preflight.ts`).
77
+ logger.warn(said.code, { cause: said.cause, fix: said.fix, slot: names.slot, from: under });
78
+ }
79
+ const confirmed = resumeFrom === undefined ? 0n : commitPositionOf(resumeFrom);
80
+ await connection.startCopyBoth(
81
+ `START_REPLICATION SLOT ${names.slot} LOGICAL ${printLsn(confirmed)} ` +
82
+ `(proto_version '1', publication_names '${names.publication}', messages 'true')`,
83
+ );
84
+ // Through: the gap is the caller's to say now, and no later dial owes it again.
85
+ gap.createdUnder = null;
86
+ const resync = recreated
87
+ ? `replication slot "${names.slot}" was recreated under resume position ${under}: ` +
88
+ 'the changes in between were never streamed'
89
+ : null;
90
+ return { connection, confirmed, from: resumeFrom, resync };
91
+ } catch (failure) {
92
+ await connection.close().catch(() => undefined);
93
+ throw failure;
94
+ }
95
+ }