@mason-data/cli 0.0.0-stage → 0.7.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 (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,518 @@
1
+ /**
2
+ * THE DUCKDB COPY KEPT CURRENT BY LOGICAL REPLICATION (replication for the DuckDB case, only to sync the copied tables).
3
+ * `mason start --replication` on Mason's own publication and
4
+ * slot on the developer's database (src/cli/replication-mode.ts), the slot's `pgoutput` stream (src/cdc/pgoutput.ts,
5
+ * over src/cdc/pg-wire.ts) applied to the copied tables (src/duck/sync.ts `applyChanges`) instead of polling them.
6
+ *
7
+ * WHY REPLAYING IS SAFE. Every change of a table with a primary key is a whole row set at its key, or a key removed,
8
+ * so the changes of a key applied in commit order end at the key's state whatever state they started from. The copy
9
+ * and the stream may therefore overlap: a change the copy already holds is applied again, and the row passes back
10
+ * through its older states until the stream reaches the copy's snapshot (a reader may see one of them for that long).
11
+ * What may NOT happen is a gap — and the stream runs WHILE tables are copied (a copy runs beside the
12
+ * stream, never instead of it), acknowledging positions as it goes, so the changes of a table it does not stream yet
13
+ * are skipped and never come again. So a table JOINS the stream only once caught up (Sync `swap`, `catchUp`):
14
+ * 1. at a moment J, between two applied batches (the writer's turn), the stream starts decoding the table's changes
15
+ * and HOLDING them, applying none;
16
+ * 2. ONE poll from the table's watermark reads, in a source snapshot P taken after J, every row changed since it. The
17
+ * watermark is at or before the first change the stream could have skipped: a copy's snapshot is pinned before
18
+ * its rows are read, and the stream skipped the table only from the copy's start (a re-copy: from when it left);
19
+ * a slot new to the copy carries nothing from before it, and the watermark is the table's last poll. Every skipped
20
+ * change committed before J, so before P: it is in what the poll writes;
21
+ * 3. the held changes — every change decoded after J — are applied after the poll, in commit order, and from then on
22
+ * the stream applies the table's changes as they come. Those between J and P are in both, which is the overlap
23
+ * above: safe.
24
+ * A table being copied again leaves the stream first (its changes are skipped from then on, and its new copy's
25
+ * snapshot is after that), then joins the same way. Every table's lookup is made at each change
26
+ * (`StreamTarget.tables`), so a table joins or leaves without a restart.
27
+ * Held by tests/integration/duck-stream.itest.ts: changes committed while a table copies are in DuckDB once it is
28
+ * caught up, the stream never stopped.
29
+ *
30
+ * EXCEPT AN UNCHANGED TOAST VALUE: an UPDATE that leaves a large value as it was carries no value for it (pgoutput's
31
+ * `u`). The row DuckDB holds keeps its own; a row DuckDB does not hold (the copy had it deleted, or a TRUNCATE in the
32
+ * batch) is read from the source by its key, its value as it is now — later changes in the stream bring it on.
33
+ *
34
+ * WHAT IS ACKNOWLEDGED: a batch of whole transactions is applied in ONE DuckDB transaction with the position it ends
35
+ * at (`_mason.stream_state`), and only then is the position sent to Postgres (`sendStatus`). A run that dies between
36
+ * reads the unacknowledged tail again, and skips the commits its own record says it applied.
37
+ *
38
+ * A LARGE TRANSACTION IS APPLIED IN PIECES (a migration's UPDATE of tens of millions of rows is one
39
+ * transaction). Folded whole, it sat in Node's heap until its commit, then went into DuckDB as one transaction, whose
40
+ * own state grows until the commit (`PIECE_CHANGES` says what was measured). So once `flushRows` changes are folded
41
+ * inside a transaction they are applied and COMMITTED without a position (`apply(batch, null)`), and the fold starts
42
+ * again; the position is recorded, and acknowledged, only with the transaction's last piece. A run that dies between
43
+ * pieces has recorded no commit at or past this one, so it reads the transaction again from its BEGIN and applies it
44
+ * whole: what the earlier pieces wrote is written again, which replaying makes safe (above). A piece's partial row (an
45
+ * unchanged-TOAST update) whose earlier row went in an earlier piece is applied as an UPDATE of the columns it carries,
46
+ * as one whose row the copy holds always is.
47
+ */
48
+ import { decode } from "../cdc/pgoutput.js";
49
+ import { PgConnection, formatLsn, pgIdent, pgLiteral } from "../cdc/pg-wire.js";
50
+ const lit = (s) => `'${s.replace(/'/g, "''")}'`;
51
+ /**
52
+ * A DECODED TUPLE AS THE COPY'S COLUMNS (`columns`, the copy's order), from the relation's: a column the publication sends
53
+ * and the copy lacks is dropped; a column of the copy the tuple leaves unchanged (TOAST) is `undefined`, kept by the row
54
+ * DuckDB holds. pgoutput's relation is what ARRIVES, never what is written: the copy's own record decides a write's
55
+ * shape (the test server: a held change written in the shape of the moment named a column its copy did not have).
56
+ */
57
+ export function projectTuple(relation, tuple, columns) {
58
+ return columns.map((name) => {
59
+ const at = relation.findIndex((c) => c.name === name);
60
+ if (at < 0)
61
+ return undefined;
62
+ const v = tuple[at];
63
+ return v.kind === "text" ? v.text : v.kind === "null" ? null : undefined;
64
+ });
65
+ }
66
+ /** the copy's columns a relation does not carry, but for those Postgres never publishes (`generated`, read from the
67
+ * source instead): what refuses the table (`StreamTarget.refuse`) */
68
+ export function missingFrom(relation, table) {
69
+ return table.columns.filter((name) => !relation.some((c) => c.name === name) && !(table.generated ?? []).includes(name));
70
+ }
71
+ export function pgArrayTree(text) {
72
+ let s = text;
73
+ const eq = s.indexOf("=");
74
+ if (s.startsWith("[") && eq > 0)
75
+ s = s.slice(eq + 1);
76
+ let i = 0;
77
+ const fail = () => { throw new Error(`malformed array literal: ${text}`); };
78
+ const space = () => { while (i < s.length && /\s/.test(s[i]))
79
+ i++; };
80
+ const list = () => {
81
+ if (s[i] !== "{")
82
+ fail();
83
+ i++;
84
+ const out = [];
85
+ space();
86
+ if (s[i] === "}") {
87
+ i++;
88
+ return out;
89
+ }
90
+ for (;;) {
91
+ space();
92
+ if (s[i] === "{")
93
+ out.push(list());
94
+ else if (s[i] === '"') {
95
+ i++;
96
+ let v = "";
97
+ while (i < s.length && s[i] !== '"') {
98
+ if (s[i] === "\\")
99
+ i++;
100
+ v += s[i++];
101
+ }
102
+ if (s[i] !== '"')
103
+ fail();
104
+ i++;
105
+ out.push(v);
106
+ }
107
+ else {
108
+ let v = "";
109
+ while (i < s.length && s[i] !== "," && s[i] !== "}")
110
+ v += s[i++];
111
+ v = v.trim();
112
+ out.push(v.toUpperCase() === "NULL" ? null : v);
113
+ }
114
+ space();
115
+ if (s[i] === ",") {
116
+ i++;
117
+ continue;
118
+ }
119
+ if (s[i] === "}") {
120
+ i++;
121
+ return out;
122
+ }
123
+ fail();
124
+ }
125
+ };
126
+ space();
127
+ const tree = list();
128
+ space();
129
+ if (i !== s.length)
130
+ fail();
131
+ return tree;
132
+ }
133
+ /**
134
+ * A Postgres value's TEXT (as pgoutput sends it) as a DuckDB expression of the copy's column type (`DESCRIBE`'s
135
+ * spelling): what the copy stores for the same value, held by tests/integration/duck-stream.itest.ts against a copy
136
+ * of every type the oracle's type matrix has. A bytea is Postgres's hex output (`\x…`), an array its `{…}` literal.
137
+ */
138
+ export function duckValue(text, type) {
139
+ if (text === null)
140
+ return "NULL";
141
+ if (type.endsWith("[]"))
142
+ return duckList(pgArrayTree(text), type);
143
+ if (type === "VARCHAR")
144
+ return lit(text);
145
+ if (type === "BLOB") {
146
+ if (!text.startsWith("\\x"))
147
+ throw new Error(`a bytea arrived as ${JSON.stringify(text.slice(0, 20))}, not in hex (bytea_output must be hex)`);
148
+ return `unhex(${lit(text.slice(2))})`;
149
+ }
150
+ if (type === "INTERVAL" && text.startsWith("P"))
151
+ return `CAST(${lit(intervalWords(text))} AS INTERVAL)`;
152
+ return `CAST(${lit(text)} AS ${type})`;
153
+ }
154
+ const ISO_INTERVAL = /^P(?:(-?[\d.]+)Y)?(?:(-?[\d.]+)M)?(?:(-?[\d.]+)W)?(?:(-?[\d.]+)D)?(?:T(?:(-?[\d.]+)H)?(?:(-?[\d.]+)M)?(?:(-?[\d.]+)S)?)?$/;
155
+ /**
156
+ * An interval as Mason's connections spell it (IntervalStyle iso_8601, src/cdc/pg-wire.ts: `P-10M-3DT-4H-5M-6.789S`) in
157
+ * the words DuckDB's INTERVAL reads (`-10 months -3 days -4 hours -5 minutes -6.789 seconds`), each part with its sign
158
+ */
159
+ export function intervalWords(text) {
160
+ const m = ISO_INTERVAL.exec(text);
161
+ if (m === null || text === "P" || text.endsWith("T"))
162
+ throw new Error(`an interval arrived as ${JSON.stringify(text)}, which is not ISO 8601`);
163
+ const [, y, mo, w, d, h, mi, sec] = m;
164
+ const days = w === undefined ? d : String(Number(w) * 7 + Number(d ?? 0));
165
+ const parts = [[y, "years"], [mo, "months"], [days, "days"], [h, "hours"], [mi, "minutes"], [sec, "seconds"]]
166
+ .filter(([v]) => v !== undefined).map(([v, unit]) => `${v} ${unit}`);
167
+ return parts.length ? parts.join(" ") : "0 seconds";
168
+ }
169
+ function duckList(tree, type) {
170
+ const element = type.slice(0, -2);
171
+ const items = tree.map((x) => Array.isArray(x) ? duckList(x, element) : duckValue(x, element));
172
+ return `CAST([${items.join(", ")}] AS ${type})`;
173
+ }
174
+ export const noCounts = () => ({ inserts: 0, updates: 0, deletes: 0, truncates: 0 });
175
+ export function addCounts(to, from) {
176
+ to.inserts += from.inserts;
177
+ to.updates += from.updates;
178
+ to.deletes += from.deletes;
179
+ to.truncates += from.truncates;
180
+ }
181
+ /** `1,204 changes (1,000 inserts, 200 updates, 4 deletes)`: what the log and `mason status` say of a table's counts */
182
+ export function countsText(c) {
183
+ const n = (x) => x.toLocaleString("en-US");
184
+ const total = c.inserts + c.updates + c.deletes + c.truncates;
185
+ const parts = [[c.inserts, "insert"], [c.updates, "update"], [c.deletes, "delete"], [c.truncates, "truncate"]]
186
+ .filter(([k]) => k > 0).map(([k, w]) => `${n(k)} ${w}${k === 1 ? "" : "s"}`);
187
+ return `${n(total)} change${total === 1 ? "" : "s"}${parts.length ? ` (${parts.join(", ")})` : ""}`;
188
+ }
189
+ /** a table's changes, none yet */
190
+ export const noChanges = (columns = null) => ({ columns, truncated: false, keys: new Map(), counts: noCounts() });
191
+ /** The change folded into what the batch holds for its key: a later whole row or delete replaces it, a later partial
192
+ * row keeps the earlier row's values where it carries none. */
193
+ export function fold(t, key, change) {
194
+ const k = JSON.stringify(key);
195
+ const was = t.keys.get(k)?.change;
196
+ if (change.kind === "row" && !change.refetch && change.values.includes(undefined) && was?.kind === "row" && !was.refetch) {
197
+ change = { kind: "row", values: change.values.map((v, i) => v === undefined ? was.values[i] : v) };
198
+ }
199
+ t.keys.set(k, { key, change });
200
+ }
201
+ /**
202
+ * THE MOST CHANGES ONE DUCKDB TRANSACTION OF THE STREAM APPLIES: a batch is applied once it holds this many, and a
203
+ * transaction of more is applied in pieces of this many (the module's header). In Node's heap, 607,000 changes of one
204
+ * transaction folded whole ran a sync out of 96 MB; in pieces of this many it ran in 48 MB
205
+ * (tests/integration/duck-stream.itest.ts).
206
+ *
207
+ * WHAT A PIECE COSTS DUCKDB (measured on DuckDB 2.0.0, a laptop's NVMe, warm). No copy has an index
208
+ * (src/duck/sync.ts `copy`), so a piece is written by key: its keys deleted by ONE join against the table's
209
+ * key column, then its rows inserted, and its unchanged-TOAST rows set by ONE `UPDATE … FROM` (src/duck/sync.ts
210
+ * `writeByKey`, `updateCarried`) — each a scan of the key column, the price of no index. 10,000 changes into 1 M uuid
211
+ * rows at 64 MB: 4 ms; into 10 M at 2 GB: 20 ms; into 195 M at 2.7 GiB (the test server's table and limit): 0.35–0.86 s to
212
+ * delete, 1.5–1.8 s to update — and on the test server, whose 3 GB key column does not fit DuckDB's 2.7 GiB and whose EBS
213
+ * volume reads 125 MB/s, a scan can be tens of seconds. So a table over the poll's row limit is applied by TIME as well
214
+ * (`applySize`). When the copy had a primary key, the key set this number: a deleted key pinned its part of the index
215
+ * until the commit (at 64 MB on 1 M uuid keys, 50,000 deletes in one transaction ran out after 15,500), and a write of
216
+ * random keys needed the whole index resident.
217
+ */
218
+ export const PIECE_CHANGES = 10_000;
219
+ /**
220
+ * A BATCH THAT CHANGES A BIG TABLE (one over src/duck/sync.ts POLL_ROW_LIMIT, `big` in `StreamTarget.tables`) is
221
+ * applied at most every `BIG_APPLY_MS`, and a transaction's piece holds up to `BIG_PIECE_CHANGES`: each application
222
+ * scans that table's key column (`PIECE_CHANGES` says what that costs), so fewer, larger ones — a 12 M-row migration is
223
+ * 240 scans where it would be 1,200. The other tables in such a batch wait with it (seconds of staleness, said in
224
+ * docs/DESIGN.md); the piece's rows in Node's heap are five times a small table's, which the 48 MB a piece of
225
+ * 10,000 ran in leaves room for.
226
+ */
227
+ export const BIG_APPLY_MS = 5_000;
228
+ export const BIG_PIECE_CHANGES = 50_000;
229
+ /** how long a batch may wait and how many changes it may hold before it is applied (`flushMs` / `flushRows` for one
230
+ * of small tables; at least `BIG_APPLY_MS` / `BIG_PIECE_CHANGES` once it holds a big table's change) */
231
+ export function applySize(big, flushMs, flushRows) {
232
+ return big ? { ms: Math.max(flushMs, BIG_APPLY_MS), rows: Math.max(flushRows, BIG_PIECE_CHANGES) } : { ms: flushMs, rows: flushRows };
233
+ }
234
+ /** A later batch's changes of a table folded into what is held of it (Sync `catchUp`): what they mean once applied in
235
+ * commit order after it, as `fold` does within a batch */
236
+ export function foldInto(held, later) {
237
+ held.columns ??= later.columns;
238
+ if (later.truncated) {
239
+ held.truncated = true;
240
+ held.keys.clear();
241
+ }
242
+ for (const { key, change } of later.keys.values())
243
+ fold(held, key, change);
244
+ addCounts(held.counts, later.counts);
245
+ }
246
+ /**
247
+ * The stream: START_REPLICATION from the slot's confirmed position, each transaction's changes folded per table and
248
+ * key, a batch applied once it has waited `flushMs` (or holds `flushRows` changes) and ends at a commit, its position
249
+ * then acknowledged; a transaction of more than `flushRows` changes is applied in pieces of that many (the module's
250
+ * header). A batch holding a big table's change waits and holds more (`applySize`). The source going away is waited out (a reconnect resumes from the slot); anything else ends the
251
+ * stream with its error (`done`), for the sync to say and act on.
252
+ */
253
+ export class ReplicationStream {
254
+ repl = null;
255
+ stopping = false;
256
+ acked = 0n;
257
+ /** the furthest the server has said its WAL is, with nothing of ours pending: what `caughtUp` compares with */
258
+ seen = 0n;
259
+ done;
260
+ connect;
261
+ slot;
262
+ publication;
263
+ target;
264
+ log;
265
+ flushMs;
266
+ flushRows;
267
+ constructor(o) {
268
+ this.connect = o.connect;
269
+ this.slot = o.slot;
270
+ this.publication = o.publication;
271
+ this.target = o.target;
272
+ this.log = o.log;
273
+ this.flushMs = o.flushMs ?? 500;
274
+ this.flushRows = o.flushRows ?? PIECE_CHANGES;
275
+ this.acked = this.target.applied();
276
+ this.done = this.run();
277
+ }
278
+ /** the stream ended, its changes not yet applied dropped (the slot sends them again at the next start) */
279
+ async stop() {
280
+ this.stopping = true;
281
+ this.repl?.close();
282
+ await this.done;
283
+ }
284
+ /** the position applied or passed over: everything the source had written at `lsn` is in DuckDB once it is past it */
285
+ position() { return this.seen > this.acked ? this.seen : this.acked; }
286
+ /** ask the server where it is now: it answers with a keepalive at once (`caughtUp` waits on it) */
287
+ poke() {
288
+ try {
289
+ this.repl?.sendStatus(this.acked, true);
290
+ }
291
+ catch (e) {
292
+ this.log(`replication: could not ask the source for its position (${e.message})`);
293
+ }
294
+ }
295
+ async run() {
296
+ let attempts = 0;
297
+ while (!this.stopping) {
298
+ try {
299
+ this.repl = await this.connect();
300
+ }
301
+ catch (e) {
302
+ if (this.stopping)
303
+ break;
304
+ const wait = Math.min(30_000, 1_000 * 2 ** Math.min(attempts++, 5));
305
+ this.log(`replication: the source is away (${e.message}); trying again in ${wait / 1000} s`);
306
+ await new Promise((r) => setTimeout(r, wait));
307
+ continue;
308
+ }
309
+ // a stop that came while connecting closed nothing (there was no connection yet): this one is closed instead
310
+ if (this.stopping) {
311
+ this.repl.close();
312
+ this.repl = null;
313
+ break;
314
+ }
315
+ if (attempts)
316
+ this.log(`replication: the source is back; resuming from ${formatLsn(this.acked)}`);
317
+ attempts = 0;
318
+ try {
319
+ await this.streamOnce(this.repl);
320
+ if (this.stopping)
321
+ break;
322
+ this.log("replication: the source ended the stream (a shutdown); reconnecting");
323
+ }
324
+ catch (e) {
325
+ if (this.stopping)
326
+ break;
327
+ if (!away(e)) {
328
+ this.repl.close();
329
+ this.repl = null;
330
+ return e;
331
+ }
332
+ this.log(`replication: the source went away (${e.message}); reconnecting`);
333
+ }
334
+ finally {
335
+ this.repl?.close();
336
+ this.repl = null;
337
+ }
338
+ await new Promise((r) => setTimeout(r, 1_000));
339
+ }
340
+ return null;
341
+ }
342
+ async streamOnce(repl) {
343
+ const relations = new Map();
344
+ // the streamed tables refused by a relation lacking a copied column, by their `tables()` entry: once each
345
+ const refused = new WeakSet();
346
+ let batch = new Map();
347
+ let rows = 0, firstAt = 0;
348
+ // the batch holds a change of a big table: applied by `applySize`'s larger bounds
349
+ let big = false;
350
+ const size = () => applySize(big, this.flushMs, this.flushRows);
351
+ let inTxn = false, skipping = false;
352
+ let lastEnd = this.acked;
353
+ // when the open transaction committed (its begin carries it), and the newest commit folded into the batch
354
+ let txnCommit = new Date(0), lastCommit = new Date(0);
355
+ // the open transaction's pieces applied so far, and its changes by table (for the log line once it commits)
356
+ let pieces = 0;
357
+ const txnChanges = new Map();
358
+ const flush = async () => {
359
+ if (lastEnd <= this.acked)
360
+ return;
361
+ if (batch.size || pieces)
362
+ await this.target.apply(batch, lastEnd, lastCommit);
363
+ this.acked = lastEnd;
364
+ repl.sendStatus(this.acked);
365
+ batch = new Map();
366
+ rows = 0;
367
+ firstAt = 0;
368
+ pieces = 0;
369
+ big = false;
370
+ };
371
+ // a piece of the open transaction: applied and committed, the position left where it is
372
+ const piece = async () => {
373
+ await this.target.apply(batch, null, txnCommit);
374
+ pieces++;
375
+ batch = new Map();
376
+ rows = 0;
377
+ firstAt = 0;
378
+ big = false;
379
+ };
380
+ // feedback while idle: Postgres drops a walsender it has not heard from in wal_sender_timeout (60 s). A send on a
381
+ // socket that has closed is said here; the stream's own read meets the close and reconnects. It asks for a reply: a
382
+ // walsender that hears from us sends no keepalive of its own, so an idle stream's position would never pass the WAL
383
+ // the database writes for other tables (and the slot would hold it), and `mason status` would read it as behind
384
+ const status = setInterval(() => {
385
+ try {
386
+ repl.sendStatus(this.acked, true);
387
+ }
388
+ catch (e) {
389
+ this.log(`replication: the status update could not be sent (${e.message})`);
390
+ }
391
+ }, 10_000);
392
+ const command = `START_REPLICATION SLOT ${pgIdent(this.slot)} LOGICAL 0/0 (proto_version '1', publication_names ${pgLiteral(`"${this.publication.replace(/"/g, '""')}"`)})`;
393
+ try {
394
+ for await (const m of repl.replicate(command)) {
395
+ if (m.kind === "keepalive") {
396
+ // nothing of ours pending: the WAL up to the server's position holds no change we lack, and an idle slot
397
+ // must not hold the database's WAL for tables nobody reads
398
+ if (!inTxn && rows === 0 && lastEnd <= this.acked) {
399
+ if (m.walEnd > this.seen)
400
+ this.seen = m.walEnd;
401
+ if (m.walEnd > this.acked) {
402
+ this.acked = m.walEnd;
403
+ repl.sendStatus(this.acked);
404
+ }
405
+ }
406
+ else if (m.replyRequested)
407
+ repl.sendStatus(this.acked);
408
+ if (!inTxn && rows > 0 && Date.now() - firstAt >= size().ms)
409
+ await flush();
410
+ continue;
411
+ }
412
+ const msg = decode(m.data);
413
+ switch (msg.type) {
414
+ case "begin":
415
+ inTxn = true;
416
+ pieces = 0;
417
+ txnChanges.clear();
418
+ txnCommit = pgTime(msg.commitTime);
419
+ // a commit the copy's own record says it applied (a run that died before its acknowledgement)
420
+ skipping = msg.finalLsn < this.target.applied();
421
+ break;
422
+ case "commit":
423
+ inTxn = false;
424
+ lastEnd = msg.endLsn;
425
+ lastCommit = pgTime(msg.commitTime);
426
+ if (pieces > 0) {
427
+ const n = [...txnChanges.values()].reduce((a, b) => a + b, 0);
428
+ this.log(`replication: ${[...txnChanges.keys()].join(", ")}: ${n.toLocaleString("en-US")} change(s) in one transaction, applied in ${pieces + (rows > 0 ? 1 : 0)} pieces`);
429
+ // its last piece, with its position: at once (the earlier pieces are in, unacknowledged)
430
+ await flush();
431
+ }
432
+ else if (rows > 0 && (Date.now() - firstAt >= size().ms || rows >= size().rows))
433
+ await flush();
434
+ else if (rows === 0) {
435
+ this.acked = lastEnd > this.acked ? lastEnd : this.acked;
436
+ this.seen = this.acked;
437
+ }
438
+ break;
439
+ case "relation":
440
+ relations.set(msg.relid, { source: `${msg.namespace}.${msg.name}`, columns: msg.columns });
441
+ break;
442
+ case "insert":
443
+ case "update":
444
+ case "delete":
445
+ case "truncate": {
446
+ if (skipping)
447
+ break;
448
+ const changed = msg.type === "truncate" ? msg.relids : [msg.relid];
449
+ for (const relid of changed) {
450
+ const rel = relations.get(relid);
451
+ // looked up at each change: a table caught up since the stream started is streamed from here on
452
+ const table = rel === undefined ? undefined : this.target.tables().get(rel.source);
453
+ if (rel === undefined || table === undefined)
454
+ continue; // a table the copy does not hold, or not yet caught up
455
+ // a column the copy holds that the relation does not carry (a DDL on the database, a narrower publication):
456
+ // its changes cannot be written, so the table is refused and leaves the stream; the others go on
457
+ const missing = missingFrom(rel.columns, table);
458
+ if (missing.length) {
459
+ if (!refused.has(table)) {
460
+ refused.add(table);
461
+ await this.target.refuse(rel.source, missing);
462
+ }
463
+ continue;
464
+ }
465
+ if (table.big === true)
466
+ big = true;
467
+ const t = batch.get(rel.source) ?? noChanges(table.columns);
468
+ batch.set(rel.source, t);
469
+ txnChanges.set(rel.source, (txnChanges.get(rel.source) ?? 0) + 1);
470
+ t.counts[msg.type === "insert" ? "inserts" : msg.type === "update" ? "updates" : msg.type === "delete" ? "deletes" : "truncates"]++;
471
+ if (msg.type === "truncate") {
472
+ t.truncated = true;
473
+ t.keys.clear();
474
+ continue;
475
+ }
476
+ // a generated column (Postgres publishes none before 18) is read from the source
477
+ const absent = table.columns.some((name) => !rel.columns.some((c) => c.name === name));
478
+ const values = (tuple) => projectTuple(rel.columns, tuple, table.columns);
479
+ const keyOf = (vals) => table.key.map((k) => {
480
+ const v = vals[table.columns.indexOf(k)];
481
+ if (v === undefined)
482
+ throw new Error(`${rel.source}.${k}: a key value arrived unchanged-TOAST, which the stream cannot place`);
483
+ return v;
484
+ });
485
+ if (msg.type === "delete")
486
+ fold(t, keyOf(values(msg.key)), { kind: "delete" });
487
+ else {
488
+ const vals = values(msg.tuple);
489
+ if (msg.type === "update" && msg.oldKey)
490
+ fold(t, keyOf(values(msg.oldKey)), { kind: "delete" });
491
+ fold(t, keyOf(vals), { kind: "row", values: vals, ...(absent ? { refetch: true } : {}) });
492
+ }
493
+ }
494
+ if (rows === 0)
495
+ firstAt = Date.now();
496
+ rows++;
497
+ if (inTxn && rows >= size().rows)
498
+ await piece();
499
+ break;
500
+ }
501
+ default: break;
502
+ }
503
+ }
504
+ }
505
+ finally {
506
+ clearInterval(status);
507
+ }
508
+ }
509
+ }
510
+ /** pgoutput's commit time (microseconds since 2000-01-01 UTC) as a date */
511
+ export function pgTime(us) {
512
+ return new Date(946_684_800_000 + Number(us / 1000n));
513
+ }
514
+ /** the source being away — closed, refused, reset, a shutdown — which a reconnect outlasts; anything else is the stream's */
515
+ function away(e) {
516
+ const m = e instanceof Error ? e.message : String(e);
517
+ return /connection closed|ECONNREFUSED|ECONNRESET|EPIPE|ETIMEDOUT|timed out|terminating connection|the database system is (shutting down|starting up)|server closed|is active for PID/i.test(m);
518
+ }