@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
package/src/replicator.ts CHANGED
@@ -8,51 +8,28 @@
8
8
  //
9
9
  // The lock is tied to the *session*, so a crashed replicator releases it automatically — no lease
10
10
  // renewal, no fencing token, no split brain. A process that fails to take the lock does not start
11
- // its feed and reports `/readyz` false; it retries with jittered backoff and takes over the moment
12
- // the holder dies. Scaling the replicator is therefore always vertical, and that is by design.
11
+ // its feed and reports `/readyz` false. A stream that ENDS — or a lock that is LOST — is this file's
12
+ // to answer: `running` goes false, everything is let go, and the process asks again on a backoff.
13
13
 
14
- import { isWriteDigest, logger, uuid, withSpan } from '@ultimat3/core';
14
+ import { logger, renderThrowable, uuid, withSpan } from '@ultimat3/core';
15
+ import type { AdvisoryLock } from './advisory-lock';
15
16
  import type { ChangeEvent, ChangeFeed } from './changefeed';
17
+ import { ReplicationFailedError } from './errors';
16
18
  import type { Transport } from './fanout';
17
- import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
19
+ import { busRow } from './replicator-row';
20
+ import {
21
+ type BackoffPolicy,
22
+ defaultBackoff,
23
+ policyDelay,
24
+ type Rng,
25
+ type Scheduler,
26
+ } from './thundering-herd';
18
27
 
19
28
  export const CHANGE_SUBJECT_PREFIX = 'x.change';
20
29
 
21
30
  /** Every change subject: what a `sync` node subscribes to. A changefeed subject, never a channel. */
22
31
  export const CHANGE_SUBJECT_ALL = `${CHANGE_SUBJECT_PREFIX}.>`;
23
32
 
24
- /** Session-scoped mutual exclusion. Postgres-backed in production, in-memory for `x dev`. */
25
- export interface AdvisoryLock {
26
- readonly key: string;
27
- /** `false` means another process holds it — never block, never steal. */
28
- tryAcquire(): Promise<boolean>;
29
- release(): Promise<void>;
30
- }
31
-
32
- /** Single-process default: correct for `x dev` and tests, useless across containers (by design). */
33
- export class InMemoryAdvisoryLock implements AdvisoryLock {
34
- static readonly #held = new Set<string>();
35
- readonly key: string;
36
- #mine = false;
37
-
38
- constructor(key: string) {
39
- this.key = key;
40
- }
41
-
42
- async tryAcquire(): Promise<boolean> {
43
- if (InMemoryAdvisoryLock.#held.has(this.key)) return false;
44
- InMemoryAdvisoryLock.#held.add(this.key);
45
- this.#mine = true;
46
- return true;
47
- }
48
-
49
- async release(): Promise<void> {
50
- if (!this.#mine) return;
51
- InMemoryAdvisoryLock.#held.delete(this.key);
52
- this.#mine = false;
53
- }
54
- }
55
-
56
33
  /** `x.change.<entity>.<orgId>` — tenant in the subject, so fanout filters without parsing a row. */
57
34
  export function changeSubject(change: ChangeEvent): string {
58
35
  return `${CHANGE_SUBJECT_PREFIX}.${change.entity}.${change.orgId ?? '_'}`;
@@ -67,38 +44,34 @@ export interface ReplicatorOptions {
67
44
  readonly subjectOf?: (change: ChangeEvent) => string;
68
45
  readonly backoff?: BackoffPolicy;
69
46
  readonly rng?: Rng;
47
+ /** The timer a restart waits on. Injected so a test proves the takeover loop without sleeping. */
48
+ readonly schedule?: Scheduler;
70
49
  }
71
50
 
72
51
  export interface ReplicatorStats {
73
52
  readonly published: number;
74
53
  readonly skipped: number;
75
54
  readonly outOfOrder: number;
76
- }
77
-
78
- /**
79
- * What the bus actually carries: one change, plus who published it and where in that publisher's
80
- * stream it sits.
81
- *
82
- * Fanout is core NATS — `publish`, no ack, at most once — so a consumer that reads only the change
83
- * cannot tell "nothing happened" from "eleven changes went past while I was reconnecting". An lsn
84
- * cannot answer it either: a WAL position is a byte offset, so a legitimate next change is an
85
- * arbitrary jump forwards. A per-publisher counter is the one number a gap is visible in.
86
- *
87
- * Both fields are optional on the wire, so a node reading a publisher that predates them simply
88
- * detects nothing — the same rule the `snapshot.entity` field follows, on a subject no client sees.
89
- */
90
- export interface ChangeEnvelope {
91
- readonly change: ChangeEvent;
92
- /** Monotonic within `producer`, from 1. `null` from a publisher that does not sequence. */
93
- readonly seq: number | null;
94
- /** Identifies one replicator *run*. A new one restarts `seq`, and that is not a gap. */
95
- readonly producer: string | null;
55
+ /** Streams this replicator brought back after one ended on its own. */
56
+ readonly restarts: number;
57
+ /**
58
+ * Why this replicator is not running although nothing stopped it — the stream's own reason, the
59
+ * last restart's, or the change (entity, lsn, how many times) the bus keeps refusing. `null`
60
+ * while it runs, and after a `stop()`.
61
+ */
62
+ readonly failure: string | null;
96
63
  }
97
64
 
98
65
  export interface Replicator {
99
66
  /** `false` = another replicator holds the lock; this process must stay `/readyz` false. */
100
67
  start(): Promise<boolean>;
101
68
  stop(): Promise<void>;
69
+ /**
70
+ * `true` only while this process is REPLICATING: a stream is pumping under the lock, and no
71
+ * change is waiting on a publish the bus keeps refusing. `false` the moment the stream ends or
72
+ * the lock is lost, and through every restart that has yet to publish the change it owes — the
73
+ * one fact a readiness check has to read.
74
+ */
102
75
  readonly running: boolean;
103
76
  lastLsn(): string | null;
104
77
  stats(): ReplicatorStats;
@@ -106,10 +79,39 @@ export interface Replicator {
106
79
  retryDelayMs(attempt: number): number;
107
80
  }
108
81
 
82
+ /** One `begin()`: the stream and the lock grant that belong together. */
83
+ interface Run {
84
+ /** Why this run is over — the stream ended, the lock was lost, or it was stopped. */
85
+ over: string | null;
86
+ }
87
+
88
+ /** The change a run could not publish, kept until one goes out. */
89
+ interface Refusal {
90
+ readonly entity: string;
91
+ readonly lsn: string;
92
+ readonly error: string;
93
+ times: number;
94
+ }
95
+
96
+ /**
97
+ * How long each polite goodbye of a `stop()` may take — the feed's, then the lock's — before the
98
+ * thing is abandoned instead. Both are conversations with a socket; a black-holed one never
99
+ * answers, and a SIGTERM that waited on it never finished.
100
+ */
101
+ export const STOP_DEADLINE_MS = 5_000;
102
+
103
+ const fencedOut = (reason: string): ReplicationFailedError =>
104
+ new ReplicationFailedError({
105
+ stage: 'stream',
106
+ detail: `a change arrived for a run that is over (${reason}), and was not published`,
107
+ fix: 'x doctor --json',
108
+ });
109
+
109
110
  export function createReplicator(options: ReplicatorOptions): Replicator {
110
111
  const subjectOf = options.subjectOf ?? changeSubject;
111
112
  const backoff = options.backoff ?? defaultBackoff;
112
- let running = false;
113
+ /** A stream is pumping under a lock this process holds. Internal: `running` is stricter. */
114
+ let pumping = false;
113
115
  let lastLsn: string | null = null;
114
116
  let published = 0;
115
117
  let skipped = 0;
@@ -119,33 +121,126 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
119
121
  // the old one.
120
122
  let producer = uuid();
121
123
  let seq = 0;
122
- /** The start in flight, if any — see `start()` on why `running` cannot answer that question. */
124
+ /** The start in flight, if any — see `start()` on why `pumping` cannot answer that question. */
123
125
  let starting: Promise<boolean> | undefined;
126
+ const schedule = options.schedule ?? unrefScheduler;
127
+ let restarts = 0;
128
+ let failure: string | null = null;
129
+ /** Set by `stop()`: the one thing that ends the takeover loop. */
130
+ let stopped = false;
131
+ /** The run whose stream and lock still speak for this replicator. */
132
+ let current: Run | undefined;
133
+ let cancelRetry: (() => void) | undefined;
134
+ /** Stops listening for the lock being lost. Held only while this replicator holds the lock. */
135
+ let unwatchLock: (() => void) | undefined;
136
+ const unwatch = (): void => {
137
+ unwatchLock?.();
138
+ unwatchLock = undefined;
139
+ };
140
+ /**
141
+ * Deaths since a change was last PUBLISHED. "The stream started" is not progress: a change the
142
+ * bus refuses every time kills each new stream on its first delivery, and counting from the
143
+ * start made that a redial — two dials, a preflight, a `START_REPLICATION` — at the base delay
144
+ * forever, with the WAL retained behind it.
145
+ */
146
+ let attempt = 0;
147
+ let refusal: Refusal | null = null;
124
148
 
125
- const onChange = async (raw: ChangeEvent): Promise<void> => {
126
- const change = normalize(raw);
127
- if (!change) {
128
- skipped += 1;
129
- return;
130
- }
131
- if (lastLsn !== null && change.lsn <= lastLsn) {
132
- // At-least-once delivery is the feed's contract, so a repeat is expected, not an error.
133
- outOfOrder += 1;
134
- return;
135
- }
136
- await withSpan('realtime.replicate', async () => {
137
- seq += 1;
138
- // The envelope is the change plus two fields, flat, so a consumer that only knows about the
139
- // change reads it unchanged — `parseChange` still answers on the same payload.
140
- await options.transport.publish(
141
- subjectOf(change),
142
- JSON.stringify({ ...change, seq, producer }),
143
- );
144
- lastLsn = change.lsn;
145
- published += 1;
149
+ /** One goodbye against the deadline, on the `schedule` seam. Never rejects. */
150
+ const bounded = async (
151
+ goodbye: Promise<void>,
152
+ ): Promise<
153
+ { readonly how: 'done' | 'late' } | { readonly how: 'failed'; readonly error: unknown }
154
+ > => {
155
+ let cancel = (): void => undefined;
156
+ const deadline = new Promise<{ readonly how: 'late' }>((resolve) => {
157
+ cancel = schedule(() => resolve({ how: 'late' }), STOP_DEADLINE_MS);
146
158
  });
159
+ const said = goodbye.then(
160
+ () => ({ how: 'done' as const }),
161
+ (error: unknown) => ({ how: 'failed' as const, error }),
162
+ );
163
+ const outcome = await Promise.race([said, deadline]);
164
+ cancel();
165
+ return outcome;
166
+ };
167
+
168
+ /**
169
+ * The orderly unlock, bounded. Past the deadline — or refused — the session is dropped instead:
170
+ * closing it releases the lock just the same.
171
+ */
172
+ const release = async (): Promise<void> => {
173
+ if ((await bounded(options.lock.release())).how !== 'done') options.lock.abandon();
147
174
  };
148
175
 
176
+ /** The orderly stream stop, bounded the same way. A refusal is handed back for the caller. */
177
+ const quiet = async (): Promise<unknown> => {
178
+ const outcome = await bounded(options.feed.stop());
179
+ if (outcome.how !== 'done') options.feed.abandon();
180
+ return outcome.how === 'failed' ? outcome.error : undefined;
181
+ };
182
+
183
+ const changeHandler =
184
+ (run: Run) =>
185
+ async (raw: ChangeEvent): Promise<void> => {
186
+ // Fenced by run, and REFUSED rather than dropped: after a lock loss the socket still holds
187
+ // changes, and publishing them is one of two replicators speaking. A handler that merely
188
+ // returned would let the stream count the change delivered and confirm it.
189
+ if (current !== run || run.over !== null) throw fencedOut(run.over ?? 'a newer run started');
190
+ const change = normalize(raw);
191
+ if (!change) {
192
+ skipped += 1;
193
+ return;
194
+ }
195
+ if (lastLsn !== null && change.lsn <= lastLsn) {
196
+ // At-least-once delivery is the feed's contract, so a repeat is expected, not an error.
197
+ outOfOrder += 1;
198
+ return;
199
+ }
200
+ await withSpan('realtime.replicate', async () => {
201
+ seq += 1;
202
+ try {
203
+ // The envelope is the change plus two fields, flat, so a consumer that only knows about
204
+ // the change reads it unchanged — `parseChange` still answers on the same payload.
205
+ await options.transport.publish(
206
+ subjectOf(change),
207
+ JSON.stringify({
208
+ ...change,
209
+ before: busRow(change.before),
210
+ after: busRow(change.after),
211
+ seq,
212
+ producer,
213
+ }),
214
+ );
215
+ } catch (thrown) {
216
+ const error = renderThrowable(thrown);
217
+ const same = refusal !== null && refusal.lsn === change.lsn;
218
+ refusal = {
219
+ entity: change.entity,
220
+ lsn: change.lsn,
221
+ error,
222
+ times: same ? (refusal?.times ?? 0) + 1 : 1,
223
+ };
224
+ throw thrown;
225
+ }
226
+ lastLsn = change.lsn;
227
+ published += 1;
228
+ // Progress: the next death waits the base delay again, and nothing is owed any more.
229
+ attempt = 0;
230
+ if (refusal !== null) {
231
+ refusal = null;
232
+ if (pumping) failure = null;
233
+ }
234
+ });
235
+ };
236
+
237
+ /** What `stats().failure` says while a change keeps being refused: which one, and how often. */
238
+ const describeFailure = (reason: string): string =>
239
+ refusal === null
240
+ ? reason
241
+ : `${refusal.entity} at lsn ${refusal.lsn} could not be published ` +
242
+ `(${refusal.times} time(s) in a row): ${refusal.error}`;
243
+
149
244
  const begin = async (): Promise<boolean> => {
150
245
  if (!(await options.lock.tryAcquire())) {
151
246
  logger.warn('replicator standby: advisory lock held elsewhere', { key: options.lock.key });
@@ -153,67 +248,172 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
153
248
  }
154
249
  producer = uuid();
155
250
  seq = 0;
251
+ const run: Run = { over: null };
252
+ current = run;
253
+ const end = (reason: string): void => {
254
+ if (run.over !== null) return;
255
+ run.over = reason;
256
+ // Not yet `pumping`: `begin` is still between the feed's start and its own last line, and
257
+ // reads `run.over` there. Not `current`: a stop or a newer run owns the replicator now.
258
+ if (current === run && pumping) lost(reason);
259
+ };
260
+ // The lock is a session, and a session can die while the stream stays up — or while it is
261
+ // still being dialled. Postgres then gives the lock to whoever asks next, and the loss is said
262
+ // ONCE: recorded on the run, so a start that was mid-dial reads it below instead of going on
263
+ // to stream, ready and unlocked, for the rest of the process's life.
264
+ unwatch();
265
+ unwatchLock = options.lock.onLost((reason) => end(`the advisory lock was lost: ${reason}`));
266
+ // A restart resumes where THIS process got to. `options.from` is where the first run began;
267
+ // handing it to the second would have the feed re-deliver everything published since.
268
+ const from = lastLsn ?? options.from;
269
+ const onChange = changeHandler(run);
270
+ let fed = false;
156
271
  try {
157
272
  await options.feed.start(
158
- options.from === undefined ? { onChange } : { from: options.from, onChange },
273
+ from === undefined ? { onChange, onEnd: end } : { from, onChange, onEnd: end },
159
274
  );
275
+ fed = true;
276
+ if (run.over !== null) {
277
+ throw new ReplicationFailedError({
278
+ stage: 'stream',
279
+ detail: `the run was over as it started: ${run.over}`,
280
+ fix: 'x doctor --json',
281
+ });
282
+ }
160
283
  } catch (thrown) {
161
- // A start that failed holds NOTHING. `running` stays false so the takeover loop's next
284
+ // A start that failed holds NOTHING. `pumping` stays false so the takeover loop's next
162
285
  // `start()` runs `begin` again instead of being answered `true` by a memo over a feed that
163
286
  // never pumped, and the lock goes back so a standby can take the slot rather than waiting on
164
- // a holder that is not replicating.
165
- //
166
- // The release is best-effort by design: the feed's failure is the one the caller must see,
167
- // and a session-scoped advisory lock a dead connection cannot release is released by
168
- // Postgres when that session ends.
169
- await options.lock.release().catch(() => undefined);
287
+ // a holder that is not replicating. A feed that DID start — the lock went while it dialled —
288
+ // is stopped first: it must not outlive the grant it was started under.
289
+ run.over ??= renderThrowable(thrown);
290
+ unwatch();
291
+ if (fed) await quiet();
292
+ await release();
170
293
  throw thrown;
171
294
  }
172
- // AFTER the feed is pumping, never before: `running` is what `start()` answers `true` from and
295
+ // AFTER the feed is pumping, never before: `pumping` is what `start()` answers `true` from and
173
296
  // what `stop()` reads to decide there is anything to tear down.
174
- running = true;
297
+ pumping = true;
298
+ if (refusal === null) failure = null;
175
299
  logger.info('replicator started', { source: options.feed.source, key: options.lock.key });
176
300
  return true;
177
301
  };
178
302
 
303
+ /** Memoised — see `start()`. The takeover loop calls this, never `start()`: it must not un-stop. */
304
+ const launch = (): Promise<boolean> => {
305
+ if (pumping) return Promise.resolve(true);
306
+ const inFlight = starting;
307
+ if (inFlight !== undefined) return inFlight;
308
+ // Cleared however it settles: a `false` is "another node holds the lock", and the takeover
309
+ // loop asks again a `retryDelayMs` later. A memo that stuck would make this node a permanent
310
+ // standby of a slot whose holder has already died.
311
+ const begun = begin().finally(() => {
312
+ if (starting === begun) starting = undefined;
313
+ });
314
+ starting = begun;
315
+ return begun;
316
+ };
317
+
318
+ const retryDelayMs = (count: number): number =>
319
+ // This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
320
+ policyDelay(backoff, count + 1, options.rng ?? Math.random);
321
+
322
+ /**
323
+ * The takeover loop. One timer at a time, each on a longer delay than the last until a change
324
+ * is PUBLISHED — which is what resets the count (`changeHandler`), not a stream coming up.
325
+ * `false` from `launch` is another process holding the lock, which is this loop working: that
326
+ * process replicates, and this one keeps asking in case it dies too.
327
+ */
328
+ const retry = (): void => {
329
+ if (stopped) return;
330
+ const wait = retryDelayMs(attempt);
331
+ attempt += 1;
332
+ cancelRetry = schedule(() => {
333
+ cancelRetry = undefined;
334
+ // `pumping` already: the owner called `start()` itself while this timer was armed.
335
+ if (stopped || pumping) return;
336
+ // Voided: both outcomes are handled right here, and a timer has nobody to hand a promise to.
337
+ void launch().then(
338
+ (started) => {
339
+ if (started) restarts += 1;
340
+ else retry();
341
+ },
342
+ (thrown: unknown) => {
343
+ failure = describeFailure(renderThrowable(thrown));
344
+ logger.warn('replicator.restart_failed', { key: options.lock.key, error: failure });
345
+ retry();
346
+ },
347
+ );
348
+ }, wait);
349
+ };
350
+
351
+ /**
352
+ * A stream that ended on its own, or a lock that was lost. It used to be recorded where nothing
353
+ * read it: `running` stayed `true`, the lock stayed held, and the fleet's live windows froze
354
+ * until the pod was restarted. Everything the run held is let go here and the loop above asks
355
+ * for it back.
356
+ *
357
+ * Both are ABANDONED, never asked, and synchronously: a black-holed socket is what produces a
358
+ * lost stream, and a goodbye to one — the feed's or the lock's — is a recovery that never
359
+ * reaches its retry. Being synchronous is also what leaves no window for a `start()` to be
360
+ * answered over a run still being torn down.
361
+ */
362
+ const lost = (reason: string): void => {
363
+ pumping = false;
364
+ failure = describeFailure(reason);
365
+ unwatch();
366
+ logger.error('replicator.stream_ended', { key: options.lock.key, error: failure });
367
+ // The feed first: it must be silent before the grant it ran under is given up. Anything its
368
+ // socket still held is refused by the run's own fence.
369
+ options.feed.abandon();
370
+ options.lock.abandon();
371
+ retry();
372
+ };
373
+
179
374
  return {
180
375
  /**
181
- * Deliberately NOT `async`: `running` is set after an `await` on the lock, so a guard that
376
+ * Deliberately NOT `async`: `pumping` is set after an `await` on the lock, so a guard that
182
377
  * awaited anything before registering has the same hole it is meant to close. Two overlapping
183
- * starts both passed `if (running)`, both were told they held the lock — a holder's
378
+ * starts both passed the guard, both were told they held the lock — a holder's
184
379
  * `tryAcquire()` answers `true` — and both ran `feed.start()`: one replication slot with two
185
380
  * pumps, publishing every change twice under two `seq` generations of one producer id, which
186
381
  * every sync node's `SeqGapDetector` reads as a gap and repairs by re-snapshotting the fleet.
187
382
  * Same shape as `packages/core/src/lifecycle.ts`'s `drain()`.
188
383
  */
189
384
  start(): Promise<boolean> {
190
- if (running) return Promise.resolve(true);
191
- const inFlight = starting;
192
- if (inFlight !== undefined) return inFlight;
193
- // Cleared however it settles: a `false` is "another node holds the lock", and the takeover
194
- // loop asks again a `retryDelayMs` later. A memo that stuck would make this node a permanent
195
- // standby of a slot whose holder has already died.
196
- const attempt = begin().finally(() => {
197
- if (starting === attempt) starting = undefined;
198
- });
199
- starting = attempt;
200
- return attempt;
385
+ stopped = false;
386
+ return launch();
201
387
  },
202
388
 
203
389
  async stop(): Promise<void> {
204
- // A stop racing a start waits it out: `running` is false for the whole of `begin`, so the
390
+ // A stop racing a start waits it out: `pumping` is false for the whole of `begin`, so the
205
391
  // early return below would leave the feed it is about to start pumping into a replicator
206
392
  // nothing intends to stop, with the advisory lock still held.
393
+ stopped = true;
394
+ cancelRetry?.();
395
+ cancelRetry = undefined;
207
396
  const inFlight = starting;
208
397
  if (inFlight !== undefined) await inFlight.catch(() => false);
209
- if (!running) return;
210
- running = false;
211
- await options.feed.stop();
212
- await options.lock.release();
398
+ // An orderly stop is not a failure, whatever the last stream died of.
399
+ failure = null;
400
+ refusal = null;
401
+ if (!pumping) return;
402
+ pumping = false;
403
+ if (current !== undefined) current.over ??= 'the replicator was stopped';
404
+ current = undefined;
405
+ unwatch();
406
+ // Whatever the feed said: a lock kept because the stream would not close politely is a
407
+ // slot no standby can take. Its refusal is still the one the caller is owed.
408
+ const refused = await quiet();
409
+ await release();
410
+ if (refused !== undefined) throw refused;
213
411
  },
214
412
 
215
413
  get running(): boolean {
216
- return running;
414
+ // Replicating, not merely connected: a stream that is up while the change it owes is still
415
+ // being refused has published nothing, and must not read as ready between two of its deaths.
416
+ return pumping && refusal === null;
217
417
  },
218
418
 
219
419
  lastLsn(): string | null {
@@ -221,16 +421,22 @@ export function createReplicator(options: ReplicatorOptions): Replicator {
221
421
  },
222
422
 
223
423
  stats(): ReplicatorStats {
224
- return { published, skipped, outOfOrder };
424
+ return { published, skipped, outOfOrder, restarts, failure };
225
425
  },
226
426
 
227
- retryDelayMs(attempt: number): number {
228
- // This method's contract is 0-based (`retryDelayMs(0)` is the base); core counts from 1.
229
- return policyDelay(backoff, attempt + 1, options.rng ?? Math.random);
230
- },
427
+ retryDelayMs,
231
428
  };
232
429
  }
233
430
 
431
+ /** The production timer: a pending restart must never be what keeps a draining process alive. */
432
+ const unrefScheduler: Scheduler = (fn, ms) => {
433
+ const handle = setTimeout(fn, ms);
434
+ handle.unref?.();
435
+ return () => {
436
+ clearTimeout(handle);
437
+ };
438
+ };
439
+
234
440
  /** Drops events the pipeline cannot use and hoists the tenant id out of the row. */
235
441
  export function normalize(change: ChangeEvent): ChangeEvent | null {
236
442
  // The one change that carries no row by design; nothing to hoist a tenant out of.
@@ -243,76 +449,3 @@ export function normalize(change: ChangeEvent): ChangeEvent | null {
243
449
  const orgId = row['orgId'];
244
450
  return typeof orgId === 'string' ? { ...change, orgId } : change;
245
451
  }
246
-
247
- /** Sync-node side of the bus: decode a published change back into a `ChangeEvent`. */
248
- export function parseChange(payload: string): ChangeEvent | null {
249
- return parseEnvelope(payload)?.change ?? null;
250
- }
251
-
252
- /** The same decode, keeping the two fields a gap is visible in. `parseChange` is this, narrowed. */
253
- export function parseEnvelope(payload: string): ChangeEnvelope | null {
254
- try {
255
- const parsed: unknown = JSON.parse(payload);
256
- if (typeof parsed !== 'object' || parsed === null) return null;
257
- const shape = parsed as Partial<ChangeEvent> & { seq?: unknown; producer?: unknown };
258
- if (typeof shape.entity !== 'string' || typeof shape.lsn !== 'string') return null;
259
- if (
260
- shape.op !== 'insert' &&
261
- shape.op !== 'update' &&
262
- shape.op !== 'delete' &&
263
- shape.op !== 'truncate'
264
- ) {
265
- return null;
266
- }
267
- return {
268
- change: {
269
- entity: shape.entity,
270
- op: shape.op,
271
- before: shape.before ?? null,
272
- after: shape.after ?? null,
273
- lsn: shape.lsn,
274
- txid: typeof shape.txid === 'string' ? shape.txid : '',
275
- orgId: typeof shape.orgId === 'string' ? shape.orgId : null,
276
- at: typeof shape.at === 'number' ? shape.at : 0,
277
- // Only the minted shape crosses: a frame decoder refuses anything else, so a malformed
278
- // label here would cost every member the whole frame rather than one page its echo.
279
- ...(isWriteDigest(shape.write) ? { write: shape.write } : {}),
280
- },
281
- seq: typeof shape.seq === 'number' && Number.isFinite(shape.seq) ? shape.seq : null,
282
- producer: typeof shape.producer === 'string' ? shape.producer : null,
283
- };
284
- } catch {
285
- return null;
286
- }
287
- }
288
-
289
- /**
290
- * The consume-side twin of the publisher's counter, and the only thing on a `sync` node that can
291
- * say "this node missed changes". Publish-side duplicate and out-of-order guards already existed;
292
- * there was no equivalent here, so a NATS blip during a rolling restart was eleven changes that
293
- * simply never happened as far as every subscriber on this node could tell.
294
- *
295
- * Per producer, because a replicator restart legitimately rewinds the counter. A repeat or a
296
- * reordering is *not* reported as a gap — the window's own lsn guard refuses those — and neither is
297
- * the first message of a stream: a node that joined late has missed everything by definition, and
298
- * every subscription it holds started after it did.
299
- */
300
- export class SeqGapDetector {
301
- readonly #next = new Map<string, number>();
302
-
303
- /** `true` when at least one message between the last one and this one was never delivered. */
304
- observe(envelope: ChangeEnvelope): boolean {
305
- const { producer, seq } = envelope;
306
- if (producer === null || seq === null) return false;
307
- const expected = this.#next.get(producer);
308
- // Never backwards: a redelivery must not lower the bar and turn the next legitimate message
309
- // into a gap of its own.
310
- this.#next.set(producer, Math.max(expected ?? 0, seq + 1));
311
- return expected !== undefined && seq > expected;
312
- }
313
-
314
- /** Producers this node has read. Bounded by replicator restarts, so it is swept on a drain. */
315
- forget(): void {
316
- this.#next.clear();
317
- }
318
- }
package/src/server.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // island realtime promises unbuildable. Every name here has exactly one home; the shared
5
5
  // vocabulary (the wire, the errors, `Row`, the backoff) stays on `@ultimat3/realtime`.
6
6
 
7
+ export { type AdvisoryLock, InMemoryAdvisoryLock } from './advisory-lock';
7
8
  // ---- the retained change window one node fans out from ------------------------------------------
8
9
  export {
9
10
  type ChangeBufferOptions,
@@ -116,12 +117,15 @@ export { NatsTransport, type NatsTransportOptions } from './nats-transport';
116
117
  export { PgAdvisoryLock, type PgAdvisoryLockOptions } from './pg-advisory-lock';
117
118
  // ---- the postgres replication path ------------------------------------------------------------
118
119
  export { entityRow } from './pg-entity-row';
120
+ export { paramsChannelTables } from './pg-identity-tables';
121
+ export { CHANNEL_IDENTITY_EVENT } from './pg-preflight';
119
122
  export {
120
123
  changeLsn,
121
124
  commitPositionOf,
122
125
  type ReplicationStreamStats,
123
126
  } from './pg-replication';
124
127
  export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
128
+ export { answersWeakAuth } from './pg-tls';
125
129
  // The value domain a WAL tuple lands in. Public because it is `PgOutputMessage`'s and
126
130
  // `entityRow`'s: a caller naming either type has to be able to name what is inside one.
127
131
  export { decodeValue, type PhysicalRow, type PhysicalValue } from './pg-values';
@@ -154,20 +158,20 @@ export {
154
158
  refillWindowInLane,
155
159
  } from './query-window';
156
160
  export {
157
- type AdvisoryLock,
158
161
  CHANGE_SUBJECT_PREFIX,
159
- type ChangeEnvelope,
160
162
  changeSubject,
161
163
  createReplicator,
162
- InMemoryAdvisoryLock,
163
164
  normalize,
164
- parseChange,
165
- parseEnvelope,
166
165
  type Replicator,
167
166
  type ReplicatorOptions,
168
167
  type ReplicatorStats,
169
- SeqGapDetector,
170
168
  } from './replicator';
169
+ export {
170
+ type ChangeEnvelope,
171
+ parseChange,
172
+ parseEnvelope,
173
+ SeqGapDetector,
174
+ } from './replicator-envelope';
171
175
  export {
172
176
  actorIdOf,
173
177
  CLOSE,