@mason-data/cli 0.0.0-stage → 0.6.2

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 (102) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +209 -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 +442 -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 +1670 -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 +482 -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 +174 -0
  22. package/dist/src/cli/command.js +172 -0
  23. package/dist/src/cli/commands.js +1145 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/dev.js +576 -0
  26. package/dist/src/cli/doctor.js +257 -0
  27. package/dist/src/cli/guides.js +134 -0
  28. package/dist/src/cli/init.js +326 -0
  29. package/dist/src/cli/machine.js +157 -0
  30. package/dist/src/cli/main.js +83 -0
  31. package/dist/src/cli/pg-tls.js +100 -0
  32. package/dist/src/cli/pg-url.js +52 -0
  33. package/dist/src/cli/pin.js +36 -0
  34. package/dist/src/cli/ports.js +118 -0
  35. package/dist/src/cli/preflight.js +338 -0
  36. package/dist/src/cli/project.js +127 -0
  37. package/dist/src/cli/readiness.js +76 -0
  38. package/dist/src/cli/registry.js +217 -0
  39. package/dist/src/cli/replication-checks.js +120 -0
  40. package/dist/src/cli/replication-mode.js +148 -0
  41. package/dist/src/cli/run-server.js +388 -0
  42. package/dist/src/cli/runs.js +120 -0
  43. package/dist/src/cli/security.js +86 -0
  44. package/dist/src/cli/self-signed-cert.js +64 -0
  45. package/dist/src/cli/serve.js +296 -0
  46. package/dist/src/cli/sources.js +562 -0
  47. package/dist/src/cli/state.js +58 -0
  48. package/dist/src/cli/style.js +187 -0
  49. package/dist/src/cli/supervise.js +248 -0
  50. package/dist/src/cli/sync.js +94 -0
  51. package/dist/src/cli/ui.js +107 -0
  52. package/dist/src/cli/user.js +229 -0
  53. package/dist/src/cli/version.js +23 -0
  54. package/dist/src/duck/analyze.js +113 -0
  55. package/dist/src/duck/cancel.js +49 -0
  56. package/dist/src/duck/executor-worker.js +263 -0
  57. package/dist/src/duck/executor.js +526 -0
  58. package/dist/src/duck/explain.js +119 -0
  59. package/dist/src/duck/functions.js +764 -0
  60. package/dist/src/duck/gaps.js +89 -0
  61. package/dist/src/duck/jsonb.js +186 -0
  62. package/dist/src/duck/kept-route.js +219 -0
  63. package/dist/src/duck/kept.js +797 -0
  64. package/dist/src/duck/late.js +221 -0
  65. package/dist/src/duck/libc-order.js +190 -0
  66. package/dist/src/duck/pg-eval-worker.js +34 -0
  67. package/dist/src/duck/pg-eval.js +59 -0
  68. package/dist/src/duck/pgtree.js +338 -0
  69. package/dist/src/duck/plan.js +246 -0
  70. package/dist/src/duck/publication.js +207 -0
  71. package/dist/src/duck/requests.js +209 -0
  72. package/dist/src/duck/resolved.js +248 -0
  73. package/dist/src/duck/rewrite.js +4001 -0
  74. package/dist/src/duck/store.js +831 -0
  75. package/dist/src/duck/stream.js +518 -0
  76. package/dist/src/duck/sync.js +2864 -0
  77. package/dist/src/duck/to-char.js +482 -0
  78. package/dist/src/duck/token.js +31 -0
  79. package/dist/src/duck/types.js +427 -0
  80. package/dist/src/duck/users.js +231 -0
  81. package/dist/src/duck/verified.js +995 -0
  82. package/dist/src/duck/views.js +150 -0
  83. package/dist/src/lib/database-url.js +74 -0
  84. package/dist/src/lib/did-you-mean.js +43 -0
  85. package/dist/src/lib/dotenv.js +72 -0
  86. package/dist/src/lib/exit-when-written.js +20 -0
  87. package/dist/src/lib/float-equal.js +69 -0
  88. package/dist/src/lib/http-json.js +22 -0
  89. package/dist/src/lib/log.js +161 -0
  90. package/dist/src/lib/pg-numeric.js +489 -0
  91. package/dist/src/lib/port.js +22 -0
  92. package/dist/src/lib/project-file.js +49 -0
  93. package/dist/src/lib/project-files.js +43 -0
  94. package/dist/src/lib/project-version.js +76 -0
  95. package/dist/src/lib/runnable.js +14 -0
  96. package/dist/src/lib/shell-word.js +4 -0
  97. package/dist/src/lib/timezone.js +236 -0
  98. package/dist/src/lib/zone-names.js +114 -0
  99. package/dist/src/web/app.css +65 -0
  100. package/dist/src/web/app.js +336 -0
  101. package/dist/src/web/index.html +39 -0
  102. package/package.json +22 -4
@@ -0,0 +1,482 @@
1
+ /**
2
+ * A POSTGRES CLIENT, DEPENDENCY-FREE, for the change reader: exactly what Mason needs from the
3
+ * wire protocol and nothing else — startup (TLS per libpq's sslmode), password / md5 / SCRAM-SHA-256 auth,
4
+ * the simple query protocol, and the streaming replication sub-protocol (CopyBoth, XLogData, keepalives,
5
+ * standby status updates). Every value travels as TEXT: the reader turns text into ClickHouse values itself,
6
+ * so no binary type decoding lives here.
7
+ *
8
+ * Why not `pg`: the scripts here run with no node_modules on the test server, the replication sub-protocol is a
9
+ * separate package on top of it, and the part Mason uses is a few hundred lines this file can hold whole.
10
+ *
11
+ * Protocol reference (Postgres's own documentation): https://www.postgresql.org/docs/current/protocol-message-formats.html and
12
+ * https://www.postgresql.org/docs/current/protocol-replication.html.
13
+ */
14
+ import * as crypto from "node:crypto";
15
+ import * as net from "node:net";
16
+ import * as tls from "node:tls";
17
+ /** A server ErrorResponse, with its SQLSTATE: callers branch on `code` (42P01, 42710, 55006 …). */
18
+ export class PgError extends Error {
19
+ code;
20
+ detail;
21
+ constructor(fields) {
22
+ super(`${fields.S ?? "ERROR"}: ${fields.M ?? "unknown error"}${fields.D ? ` (${fields.D})` : ""}`);
23
+ this.code = fields.C ?? "";
24
+ this.detail = fields.D;
25
+ }
26
+ }
27
+ /** Microseconds between the Unix epoch and Postgres's (2000-01-01). */
28
+ const PG_EPOCH_OFFSET_US = 946684800000000n;
29
+ export class PgConnection {
30
+ socket;
31
+ buf = Buffer.alloc(0);
32
+ /** chunks received after `buf` while the message they complete is still short of `need` bytes (onData) */
33
+ chunks = [];
34
+ chunked = 0;
35
+ waiting = null;
36
+ queue = [];
37
+ /** the bytes of the messages in `queue`: what backpressure bounds (onData) */
38
+ queued = 0;
39
+ closedWith = null;
40
+ /** Server parameters (server_version, TimeZone, …) as reported at startup. */
41
+ parameters = {};
42
+ opts;
43
+ constructor(opts) { this.opts = opts; }
44
+ static async connect(opts) {
45
+ const c = new PgConnection(opts);
46
+ await c.open();
47
+ return c;
48
+ }
49
+ // ------------------------------------------------------------------ transport
50
+ /**
51
+ * THE CONNECT TIMEOUT BOUNDS THE WHOLE HANDSHAKE: it once covered the TCP connect alone, and the SSL
52
+ * answer and the startup exchange waited for ever. A socket something accepts and never answers — a port
53
+ * proxy in front of a container that is restarting, a half-open connection — hung the reader's reconnect loop
54
+ * past the test's 90 s with no "the source is away" line to say so (the ClickHouse stack's change-reader fill test,
55
+ * twice in CI). One deadline from the first SYN to ReadyForQuery; past it the socket is destroyed and the error reads as
56
+ * the source being away (reader.ts `sourceAway`), so the reconnect loop tries again.
57
+ */
58
+ async open() {
59
+ const o = this.opts;
60
+ const deadline = o.connectTimeoutMs ?? 15_000;
61
+ let pending = null;
62
+ let timer;
63
+ const timedOut = new Promise((_, reject) => {
64
+ timer = setTimeout(() => { pending?.destroy(); reject(new Error(`connecting to ${o.host}:${o.port} timed out`)); }, deadline);
65
+ });
66
+ try {
67
+ await Promise.race([this.handshake((s) => { pending = s; }), timedOut]);
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
73
+ /** TCP connect, the SSL negotiation and the startup exchange; `hold` is told the live socket, for a timeout to destroy. */
74
+ async handshake(hold) {
75
+ const o = this.opts;
76
+ const mode = o.sslmode ?? "prefer";
77
+ const raw = await new Promise((resolve, reject) => {
78
+ const s = net.connect({ host: o.host, port: o.port });
79
+ hold(s);
80
+ s.once("connect", () => resolve(s));
81
+ s.once("error", (e) => reject(new Error(`connecting to ${o.host}:${o.port}: ${socketErrorText(e)}`)));
82
+ });
83
+ let sock = raw;
84
+ if (mode !== "disable" && mode !== "allow") {
85
+ // SSLRequest: length 8, code 80877103; the server answers one byte, S or N
86
+ raw.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f]));
87
+ const answer = await new Promise((resolve, reject) => {
88
+ raw.once("data", (d) => resolve(String.fromCharCode(d[0])));
89
+ raw.once("error", reject);
90
+ });
91
+ if (answer === "S") {
92
+ const verify = mode === "verify-ca" || mode === "verify-full";
93
+ sock = await new Promise((resolve, reject) => {
94
+ const t = tls.connect({
95
+ socket: raw, rejectUnauthorized: verify,
96
+ ...(mode === "verify-full" ? { servername: o.host } : { checkServerIdentity: () => undefined }),
97
+ });
98
+ hold(t);
99
+ t.once("secureConnect", () => resolve(t));
100
+ t.once("error", reject);
101
+ });
102
+ }
103
+ else if (mode !== "prefer") {
104
+ raw.destroy();
105
+ throw new Error(`sslmode=${mode}, but ${o.host} does not accept TLS`);
106
+ }
107
+ }
108
+ this.socket = sock;
109
+ sock.on("data", (d) => this.onData(d));
110
+ sock.on("error", (e) => this.fail(e));
111
+ sock.on("close", () => this.fail(new Error("connection closed by the server")));
112
+ await this.startup();
113
+ }
114
+ fail(e) {
115
+ if (this.closedWith)
116
+ return;
117
+ this.closedWith = e;
118
+ const w = this.waiting;
119
+ this.waiting = null;
120
+ if (w)
121
+ w(e);
122
+ }
123
+ onData(d) {
124
+ // A LARGE MESSAGE ARRIVES IN MANY CHUNKS, and joining each chunk to what came before copies it over and over:
125
+ // quadratic in the message, all of it off the V8 heap, where no heap limit makes the collector hurry. A row with
126
+ // a multi-megabyte payload took the reader past its container's 512 MiB (a cgroup OOM kill, the test server's first
127
+ // copy). So chunks wait in a list until the message they belong to is complete, and join once.
128
+ this.chunks.push(d);
129
+ this.chunked += d.length;
130
+ const have = this.buf.length + this.chunked;
131
+ const need = this.buf.length >= 5 ? 1 + this.buf.readInt32BE(1) : 5;
132
+ if (have < need)
133
+ return;
134
+ this.buf = Buffer.concat([this.buf, ...this.chunks], have);
135
+ this.chunks = [];
136
+ this.chunked = 0;
137
+ while (this.buf.length >= 5) {
138
+ const len = this.buf.readInt32BE(1);
139
+ if (this.buf.length < 1 + len)
140
+ break;
141
+ const msg = { type: String.fromCharCode(this.buf[0]), body: this.buf.subarray(5, 1 + len) };
142
+ this.buf = this.buf.subarray(1 + len);
143
+ if (msg.type === "N")
144
+ continue; // NoticeResponse: informational
145
+ if (msg.type === "S") {
146
+ this.parameter(msg.body);
147
+ continue;
148
+ }
149
+ const w = this.waiting;
150
+ if (w) {
151
+ this.waiting = null;
152
+ w(msg);
153
+ }
154
+ else {
155
+ this.queue.push(msg);
156
+ this.queued += msg.body.length;
157
+ }
158
+ }
159
+ // Backpressure: a consumer that is not reading (the reader flushing into ClickHouse) must not let the
160
+ // stream pile up in memory — counted in messages AND bytes, since one row may carry megabytes. The queue
161
+ // resumes the socket when it drains.
162
+ if (this.queue.length > 1000 || this.queued > QUEUE_PAUSE_BYTES)
163
+ this.socket.pause();
164
+ }
165
+ parameter(body) {
166
+ const [k, v] = cstrings(body, 2);
167
+ if (k !== undefined)
168
+ this.parameters[k] = v ?? "";
169
+ }
170
+ next() {
171
+ const m = this.queue.shift();
172
+ if (m) {
173
+ this.queued -= m.body.length;
174
+ if (this.queue.length < 100 && this.queued < QUEUE_PAUSE_BYTES / 4 && this.socket.isPaused())
175
+ this.socket.resume();
176
+ return Promise.resolve(m);
177
+ }
178
+ if (this.closedWith)
179
+ return Promise.reject(this.closedWith);
180
+ return new Promise((resolve, reject) => {
181
+ this.waiting = (x) => x instanceof Error ? reject(x) : resolve(x);
182
+ });
183
+ }
184
+ send(type, body) {
185
+ if (this.closedWith)
186
+ return; // a status update after the server went away has nowhere to go
187
+ const head = Buffer.alloc(5);
188
+ head.write(type, 0, "latin1");
189
+ head.writeInt32BE(body.length + 4, 1);
190
+ this.socket.write(Buffer.concat([head, body]));
191
+ }
192
+ // ------------------------------------------------------------------ startup and auth
193
+ async startup() {
194
+ const o = this.opts;
195
+ const params = [
196
+ ["user", o.user], ["database", o.database], ["client_encoding", "UTF8"],
197
+ // Text values in the forms the reader parses: ISO dates in UTC, floats round-trippable
198
+ ["DateStyle", "ISO, YMD"], ["TimeZone", "UTC"], ["extra_float_digits", "3"], ["IntervalStyle", "iso_8601"],
199
+ ["application_name", o.applicationName ?? "mason"],
200
+ ];
201
+ if (o.replication)
202
+ params.push(["replication", "database"]);
203
+ const body = Buffer.concat([
204
+ int32(196608), // protocol 3.0
205
+ ...params.map(([k, v]) => Buffer.concat([cstr(k), cstr(v)])),
206
+ Buffer.from([0]),
207
+ ]);
208
+ const len = Buffer.alloc(4);
209
+ len.writeInt32BE(body.length + 4);
210
+ this.socket.write(Buffer.concat([len, body]));
211
+ for (;;) {
212
+ const m = await this.next();
213
+ if (m.type === "E")
214
+ throw new PgError(errorFields(m.body));
215
+ if (m.type === "R") {
216
+ await this.auth(m.body);
217
+ continue;
218
+ }
219
+ if (m.type === "K")
220
+ continue; // BackendKeyData
221
+ if (m.type === "Z")
222
+ return;
223
+ throw new Error(`unexpected message ${m.type} during startup`);
224
+ }
225
+ }
226
+ scram = null;
227
+ async auth(body) {
228
+ const code = body.readInt32BE(0);
229
+ const pw = this.opts.password;
230
+ switch (code) {
231
+ case 0: return; // AuthenticationOk
232
+ case 3:
233
+ this.send("p", cstr(pw));
234
+ return;
235
+ case 5: {
236
+ const salt = body.subarray(4, 8);
237
+ const inner = md5hex(Buffer.concat([Buffer.from(pw), Buffer.from(this.opts.user)]));
238
+ this.send("p", cstr("md5" + md5hex(Buffer.concat([Buffer.from(inner), salt]))));
239
+ return;
240
+ }
241
+ case 10: {
242
+ const mechanisms = cstrings(body.subarray(4));
243
+ if (!mechanisms.includes("SCRAM-SHA-256"))
244
+ throw new Error(`the server offers ${mechanisms.join(", ")}; only SCRAM-SHA-256 is supported`);
245
+ const nonce = crypto.randomBytes(18).toString("base64");
246
+ const clientFirstBare = `n=,r=${nonce}`;
247
+ this.scram = { nonce, clientFirstBare };
248
+ const first = Buffer.from(`n,,${clientFirstBare}`);
249
+ this.send("p", Buffer.concat([cstr("SCRAM-SHA-256"), int32(first.length), first]));
250
+ return;
251
+ }
252
+ case 11: { // SASLContinue
253
+ const s = this.scram;
254
+ if (!s)
255
+ throw new Error("SASLContinue without a SASL exchange");
256
+ const serverFirst = body.subarray(4).toString();
257
+ const attrs = Object.fromEntries(serverFirst.split(",").map((kv) => [kv[0], kv.slice(2)]));
258
+ const r = attrs.r ?? "", salt = Buffer.from(attrs.s ?? "", "base64"), iterations = Number(attrs.i);
259
+ if (!r.startsWith(s.nonce))
260
+ throw new Error("SCRAM: the server's nonce does not extend ours");
261
+ const salted = crypto.pbkdf2Sync(pw.normalize("NFKC"), salt, iterations, 32, "sha256");
262
+ const clientKey = hmac(salted, "Client Key");
263
+ const storedKey = crypto.createHash("sha256").update(clientKey).digest();
264
+ const clientFinalNoProof = `c=biws,r=${r}`;
265
+ const authMessage = `${s.clientFirstBare},${serverFirst},${clientFinalNoProof}`;
266
+ const signature = hmac(storedKey, authMessage);
267
+ const proof = Buffer.alloc(clientKey.length);
268
+ for (let i = 0; i < proof.length; i++)
269
+ proof[i] = clientKey[i] ^ signature[i];
270
+ s.serverSignature = hmac(hmac(salted, "Server Key"), authMessage);
271
+ this.send("p", Buffer.from(`${clientFinalNoProof},p=${proof.toString("base64")}`));
272
+ return;
273
+ }
274
+ case 12: { // SASLFinal: the server proves it knew the password too
275
+ const v = /v=([^,]+)/.exec(body.subarray(4).toString())?.[1];
276
+ if (!this.scram?.serverSignature || v !== this.scram.serverSignature.toString("base64")) {
277
+ throw new Error("SCRAM: the server's signature does not verify");
278
+ }
279
+ return;
280
+ }
281
+ default: throw new Error(`authentication method ${code} is not supported (use password, md5 or scram-sha-256)`);
282
+ }
283
+ }
284
+ // ------------------------------------------------------------------ queries
285
+ /** One statement (or several `;`-separated) through the simple query protocol; the LAST result set. */
286
+ async query(sql) {
287
+ this.send("Q", cstr(sql));
288
+ let result = { fields: [], rows: [], command: "" };
289
+ let error = null;
290
+ for (;;) {
291
+ const m = await this.next();
292
+ switch (m.type) {
293
+ case "T":
294
+ result = { fields: rowDescription(m.body), rows: [], command: "" };
295
+ break;
296
+ case "D":
297
+ result.rows.push(dataRow(m.body));
298
+ break;
299
+ case "C":
300
+ result.command = cstrings(m.body)[0] ?? "";
301
+ break;
302
+ case "E":
303
+ error = new PgError(errorFields(m.body));
304
+ break;
305
+ case "I": break; // EmptyQueryResponse
306
+ case "Z":
307
+ if (error)
308
+ throw error;
309
+ return result;
310
+ default: break;
311
+ }
312
+ }
313
+ }
314
+ /**
315
+ * One statement's rows AS THEY ARRIVE, never held together: a page of a copy may carry hundreds of megabytes of
316
+ * payloads (68 MB in 64 heap blocks of a table of wide JSON payloads), which `query` would hold whole. The socket
317
+ * pauses while the consumer is busy (onData's backpressure), so memory is bounded by the queue, not the result.
318
+ * A consumer that stops early must drain it: the connection is unusable until the server's ReadyForQuery.
319
+ */
320
+ async *rows(sql) {
321
+ this.send("Q", cstr(sql));
322
+ let error = null;
323
+ for (;;) {
324
+ const m = await this.next();
325
+ switch (m.type) {
326
+ case "D":
327
+ if (error === null)
328
+ yield dataRow(m.body);
329
+ break;
330
+ case "E":
331
+ error = new PgError(errorFields(m.body));
332
+ break;
333
+ case "Z":
334
+ if (error)
335
+ throw error;
336
+ return;
337
+ default: break;
338
+ }
339
+ }
340
+ }
341
+ // ------------------------------------------------------------------ replication
342
+ /** START_REPLICATION, then an async stream of XLogData and keepalives until the connection ends. */
343
+ async *replicate(command) {
344
+ this.send("Q", cstr(command));
345
+ for (;;) {
346
+ const m = await this.next();
347
+ if (m.type === "E")
348
+ throw new PgError(errorFields(m.body));
349
+ if (m.type === "W")
350
+ break; // CopyBothResponse
351
+ }
352
+ for (;;) {
353
+ const m = await this.next();
354
+ if (m.type === "E")
355
+ throw new PgError(errorFields(m.body));
356
+ if (m.type === "c" || m.type === "C" || m.type === "Z")
357
+ return; // the server ended the stream
358
+ if (m.type !== "d")
359
+ continue;
360
+ const b = m.body;
361
+ if (b[0] === 0x77) { // 'w' XLogData
362
+ yield { kind: "data", start: b.readBigUInt64BE(1), walEnd: b.readBigUInt64BE(9), data: b.subarray(25) };
363
+ }
364
+ else if (b[0] === 0x6b) { // 'k' primary keepalive
365
+ yield { kind: "keepalive", walEnd: b.readBigUInt64BE(1), replyRequested: b[17] === 1 };
366
+ }
367
+ }
368
+ }
369
+ /**
370
+ * A standby status update: everything up to `flushed` is durable on our side, so the server may discard
371
+ * the WAL behind it. The ONLY place the reader tells Postgres a change is done.
372
+ */
373
+ sendStatus(flushed, replyRequested = false) {
374
+ const b = Buffer.alloc(34);
375
+ b[0] = 0x72; // 'r'
376
+ b.writeBigUInt64BE(flushed, 1); // written
377
+ b.writeBigUInt64BE(flushed, 9); // flushed
378
+ b.writeBigUInt64BE(flushed, 17); // applied
379
+ b.writeBigUInt64BE(BigInt(Date.now()) * 1000n - PG_EPOCH_OFFSET_US, 25);
380
+ b[33] = replyRequested ? 1 : 0;
381
+ this.send("d", b);
382
+ }
383
+ close() {
384
+ if (this.closedWith)
385
+ return;
386
+ if (this.socket.writable)
387
+ this.send("X", Buffer.alloc(0)); // Terminate: the server ends the backend cleanly
388
+ // a read waiting for the server's next message (a replication stream between changes) ends with it, never waits on
389
+ // a socket nothing reads any more (stopping a DuckDB stream once hung on its idle START_REPLICATION)
390
+ this.fail(new Error("connection closed"));
391
+ this.socket.end();
392
+ }
393
+ }
394
+ // ------------------------------------------------------------------ helpers
395
+ /** `16/B374D848` -> its 64-bit position; throws on anything else. */
396
+ export function parseLsn(text) {
397
+ const m = /^([0-9A-Fa-f]{1,8})\/([0-9A-Fa-f]{1,8})$/.exec(text.trim());
398
+ if (!m)
399
+ throw new Error(`not a WAL position: ${JSON.stringify(text)}`);
400
+ return (BigInt(`0x${m[1]}`) << 32n) | BigInt(`0x${m[2]}`);
401
+ }
402
+ export function formatLsn(lsn) {
403
+ return `${(lsn >> 32n).toString(16).toUpperCase()}/${(lsn & 0xffffffffn).toString(16).toUpperCase()}`;
404
+ }
405
+ /** A SQL string literal (standard_conforming_strings on, the default since 9.1). */
406
+ export function pgLiteral(value) {
407
+ if (value.includes("\0"))
408
+ throw new Error("a NUL byte cannot appear in a Postgres string");
409
+ return `'${value.replace(/'/g, "''")}'`;
410
+ }
411
+ /** A quoted identifier. */
412
+ export function pgIdent(name) {
413
+ return `"${name.replace(/"/g, '""')}"`;
414
+ }
415
+ function int32(n) { const b = Buffer.alloc(4); b.writeInt32BE(n); return b; }
416
+ function cstr(s) { return Buffer.concat([Buffer.from(s, "utf8"), Buffer.from([0])]); }
417
+ function md5hex(b) { return crypto.createHash("md5").update(b).digest("hex"); }
418
+ function hmac(key, data) { return crypto.createHmac("sha256", key).update(data).digest(); }
419
+ function cstrings(b, limit = Infinity) {
420
+ const out = [];
421
+ let i = 0;
422
+ while (i < b.length && out.length < limit) {
423
+ const end = b.indexOf(0, i);
424
+ if (end < 0)
425
+ break;
426
+ if (end === i && out.length > 0 && limit === Infinity)
427
+ break; // the list terminator
428
+ out.push(b.toString("utf8", i, end));
429
+ i = end + 1;
430
+ }
431
+ return out;
432
+ }
433
+ function errorFields(b) {
434
+ const f = {};
435
+ let i = 0;
436
+ while (i < b.length && b[i] !== 0) {
437
+ const code = String.fromCharCode(b[i]);
438
+ const end = b.indexOf(0, i + 1);
439
+ f[code] = b.toString("utf8", i + 1, end);
440
+ i = end + 1;
441
+ }
442
+ return f;
443
+ }
444
+ function rowDescription(b) {
445
+ const n = b.readInt16BE(0);
446
+ const names = [];
447
+ let i = 2;
448
+ for (let k = 0; k < n; k++) {
449
+ const end = b.indexOf(0, i);
450
+ names.push(b.toString("utf8", i, end));
451
+ i = end + 1 + 18; // table oid, column no, type oid, size, modifier, format
452
+ }
453
+ return names;
454
+ }
455
+ /** onData pauses the socket above this many queued bytes, and next() resumes it below a quarter of it. */
456
+ const QUEUE_PAUSE_BYTES = 16 * 1024 * 1024;
457
+ function dataRow(b) {
458
+ const n = b.readInt16BE(0);
459
+ const out = [];
460
+ let i = 2;
461
+ for (let k = 0; k < n; k++) {
462
+ const len = b.readInt32BE(i);
463
+ i += 4;
464
+ if (len < 0) {
465
+ out.push(null);
466
+ continue;
467
+ }
468
+ out.push(b.toString("utf8", i, i + len));
469
+ i += len;
470
+ }
471
+ return out;
472
+ }
473
+ /**
474
+ * A socket's error as words: its message, or each address's where Node tried several (`localhost` is ::1 and 127.0.0.1:
475
+ * an AggregateError whose own message is EMPTY — `connecting to localhost:5432: ` said nothing, and `mason start` could
476
+ * not tell "nothing is listening" from a wrong URL), or its code.
477
+ */
478
+ export function socketErrorText(e) {
479
+ if (e instanceof AggregateError && e.errors.length > 0)
480
+ return e.errors.map((x) => x instanceof Error ? socketErrorText(x) : String(x)).join("; ");
481
+ return e.message || e.code || e.name;
482
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * THE `pgoutput` DECODER (protocol version 1): Postgres's built-in logical decoding output, the messages
3
+ * inside the replication stream's XLogData. Pure — a Buffer in, a message out — so every message shape is
4
+ * pinned by tests/cdc-pgoutput.test.ts against bytes built by hand from the protocol reference (Postgres's own documentation):
5
+ * https://www.postgresql.org/docs/current/protocol-logicalrep-message-formats.html
6
+ *
7
+ * Version 1 is enough: no streamed in-progress transactions (the server hands a transaction over only at
8
+ * its commit, which is what makes "acknowledge only complete transactions" simple), no two-phase commit.
9
+ */
10
+ class Cursor {
11
+ b;
12
+ i = 0;
13
+ constructor(b) { this.b = b; }
14
+ byte() { return String.fromCharCode(this.b[this.i++]); }
15
+ int8() { return this.b.readInt8(this.i++); }
16
+ int16() { const v = this.b.readInt16BE(this.i); this.i += 2; return v; }
17
+ int32() { const v = this.b.readInt32BE(this.i); this.i += 4; return v; }
18
+ uint32() { const v = this.b.readUInt32BE(this.i); this.i += 4; return v; }
19
+ int64() { const v = this.b.readBigUInt64BE(this.i); this.i += 8; return v; }
20
+ cstr() {
21
+ const end = this.b.indexOf(0, this.i);
22
+ if (end < 0)
23
+ throw new Error("pgoutput: unterminated string");
24
+ const s = this.b.toString("utf8", this.i, end);
25
+ this.i = end + 1;
26
+ return s;
27
+ }
28
+ bytes(n) { const v = this.b.subarray(this.i, this.i + n); this.i += n; return v; }
29
+ }
30
+ function tuple(c) {
31
+ const n = c.int16();
32
+ const out = [];
33
+ for (let k = 0; k < n; k++) {
34
+ const kind = c.byte();
35
+ if (kind === "n")
36
+ out.push({ kind: "null" });
37
+ else if (kind === "u")
38
+ out.push({ kind: "unchanged" });
39
+ else if (kind === "t") {
40
+ const len = c.int32();
41
+ out.push({ kind: "text", text: c.bytes(len).toString("utf8") });
42
+ }
43
+ else if (kind === "b")
44
+ throw new Error("pgoutput: a binary column value arrived, but the reader asks for text");
45
+ else
46
+ throw new Error(`pgoutput: unknown tuple column kind ${JSON.stringify(kind)}`);
47
+ }
48
+ return out;
49
+ }
50
+ export function decode(data) {
51
+ const c = new Cursor(data);
52
+ const tag = c.byte();
53
+ switch (tag) {
54
+ case "B": return { type: "begin", finalLsn: c.int64(), commitTime: c.int64(), xid: c.int32() };
55
+ case "C": {
56
+ c.int8();
57
+ return { type: "commit", commitLsn: c.int64(), endLsn: c.int64(), commitTime: c.int64() };
58
+ }
59
+ case "R": {
60
+ const relid = c.uint32(), namespace = c.cstr(), name = c.cstr(), replicaIdentity = c.byte();
61
+ const n = c.int16();
62
+ const columns = [];
63
+ for (let k = 0; k < n; k++) {
64
+ const flags = c.int8();
65
+ columns.push({ isKey: (flags & 1) === 1, name: c.cstr(), typeOid: c.uint32(), typmod: c.int32() });
66
+ }
67
+ return { type: "relation", relid, namespace, name, replicaIdentity, columns };
68
+ }
69
+ case "I": {
70
+ const relid = c.uint32();
71
+ expect(c.byte(), "N", "insert");
72
+ return { type: "insert", relid, tuple: tuple(c) };
73
+ }
74
+ case "U": {
75
+ const relid = c.uint32();
76
+ let marker = c.byte();
77
+ let oldKey = null;
78
+ if (marker === "K" || marker === "O") {
79
+ oldKey = tuple(c);
80
+ marker = c.byte();
81
+ }
82
+ expect(marker, "N", "update");
83
+ return { type: "update", relid, oldKey, tuple: tuple(c) };
84
+ }
85
+ case "D": {
86
+ const relid = c.uint32();
87
+ const marker = c.byte();
88
+ if (marker !== "K" && marker !== "O")
89
+ throw new Error(`pgoutput: delete carries ${marker}, expected K or O`);
90
+ return { type: "delete", relid, key: tuple(c) };
91
+ }
92
+ case "T": {
93
+ const n = c.int32();
94
+ c.int8(); // options: CASCADE / RESTART IDENTITY
95
+ const relids = [];
96
+ for (let k = 0; k < n; k++)
97
+ relids.push(c.uint32());
98
+ return { type: "truncate", relids };
99
+ }
100
+ // Origin, Type and logical decoding messages carry nothing the reader stores
101
+ default: return { type: "other", tag };
102
+ }
103
+ }
104
+ function expect(got, want, what) {
105
+ if (got !== want)
106
+ throw new Error(`pgoutput: ${what} carries ${JSON.stringify(got)} where ${want} was expected`);
107
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * THE POLL'S SQL (so that development can read a local database without WAL): the
3
+ * xmin poll `mason start` keeps a copy current with by default (src/duck/sync.ts), with plain SELECTs — no
4
+ * `wal_level=logical`, no replication slot, no publication, no REPLICATION attribute, nothing created or changed on
5
+ * the source. Each poll reads, in one snapshot, every row whose `xmin` (the transaction that wrote this version of it)
6
+ * is at or past the WATERMARK — the oldest transaction still running at the last poll (`pg_snapshot_xmin`):
7
+ * everything that committed before it was read by an earlier poll (or the copy); a transaction that was running then
8
+ * has an id at or past it, and so has every subtransaction of it (a savepoint's rows carry the SUBtransaction's id,
9
+ * which `pg_visible_in_snapshot` would misjudge, so the bound is a range, never a visibility test). A copy taken in
10
+ * several source transactions is held to its first snapshot by the complement (`xminBeforeSql`).
11
+ *
12
+ * WHAT IT COSTS THE SOURCE: a poll reads every polled table whole (no index covers `xmin`), so the interval stretches
13
+ * to four times what the last poll took (`nextPollDelayMs`).
14
+ */
15
+ /** The snapshot's `xmin` — the oldest transaction running when it was taken — from its text (`xmin:xmax:xip,…`). */
16
+ export function snapshotXmin(snapshot) {
17
+ const m = /^(\d+):(\d+):/.exec(snapshot);
18
+ if (!m)
19
+ throw new Error(`not a pg_snapshot: ${JSON.stringify(snapshot)}`);
20
+ return BigInt(m[1]);
21
+ }
22
+ /** The poll's snapshot, its xmin and xmax (64-bit transaction ids, Postgres 13+), read as the transaction's first statement. */
23
+ export const SNAPSHOT_SQL = "SELECT pg_current_snapshot()::text, pg_snapshot_xmax(pg_current_snapshot())::text";
24
+ /**
25
+ * A row's `xmin` as a 64-bit transaction id. `xmin` is the 32-bit id; its epoch is the current one, or the one
26
+ * before when its low bits are past the current id's (it was assigned before the counter last wrapped). A row older
27
+ * than one wrap (2^31 transactions) is frozen long since and reads as old either way: it is never at the watermark.
28
+ */
29
+ export function xmin8Sql(currentXid8) {
30
+ const epoch = currentXid8 >> 32n, low = currentXid8 & 0xffffffffn;
31
+ return `((${epoch}::bigint - (xmin::text::bigint > ${low})::int) << 32 | xmin::text::bigint)`;
32
+ }
33
+ /**
34
+ * A ROW WRITTEN BEFORE THE WATERMARK: the complement of `xmin8Sql(…) >= watermark` (the poll's "changed since"), for a
35
+ * copy taken in several source transactions that is held to its FIRST snapshot (src/duck/sync.ts `copy`): every
36
+ * transaction that commits after a snapshot has an id at or past its xmin (it was running then, or began later), and a
37
+ * tuple's xmin never changes, so a read in any later snapshot that keeps only these rows keeps none written after the
38
+ * first — and the catch-up poll from that watermark reads every row this leaves out.
39
+ *
40
+ * Written with `age()`, never `xmin::text::bigint` (measured on PG 18 over 3 M rows: the text casts took a
41
+ * scan from 320 to 1,640 ms of the source's CPU, `age` to 437): `age(x)` is the 32-bit distance from the transaction's
42
+ * stable latest id L (the next id, read once per transaction) to `x`, so for every row within 2^31 transactions of L,
43
+ * `age(xmin) > age(watermark)` is exactly "older than the watermark" — while L is within 2^31 of the watermark, which
44
+ * `xminBeforeHolds` checks. A row whose `age` is negative has a raw xmin more than 2^31 transactions old (frozen: a
45
+ * freeze keeps the raw id since 9.4), which `xmin8Sql` may place at or past a watermark: it is kept here AND read again by
46
+ * the catch-up (written by key, so once), never neither. `age` of a special id (frozen, bootstrap) is INT_MAX: kept.
47
+ * Normal ids never have low bits below 3 (FirstNormalTransactionId), so a watermark's below 3 reads as 3: the same rows.
48
+ */
49
+ export function xminBeforeSql(watermark) {
50
+ const low = watermark & 0xffffffffn;
51
+ return `(age(xmin) > (SELECT age('${low < 3n ? 3n : low}'::xid)) OR age(xmin) < 0)`;
52
+ }
53
+ /** `xminBeforeSql` is exact while the transaction's latest id is within 2^31 of the watermark: held to a margin of half
54
+ * that (a billion transactions), `current` being the reading transaction's snapshot xmax (`SNAPSHOT_SQL`) */
55
+ export function xminBeforeHolds(current, watermark) {
56
+ return current >= watermark && current - watermark < 2n ** 30n;
57
+ }
58
+ /**
59
+ * The wait before the next poll: MASON_POLL_MS, or four times what the last poll took when that is longer, so a
60
+ * source whose tables take a while to read spends at most a fifth of its time on polls. Never sooner than either
61
+ * (the test server: a cap of a minute polled a 22 M-row production table every 74 s for 6.5 hours under
62
+ * MASON_POLL_MS=3600000, and a poll of 20 s every 60 s is a third of the source's time): the database's load decides,
63
+ * and a slow poll's freshness is said by its own timing line.
64
+ */
65
+ export function nextPollDelayMs(pollMs, lastPollMs) {
66
+ return Math.max(pollMs, 4 * lastPollMs);
67
+ }