@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,100 @@
1
+ /**
2
+ * THE RUN'S POSTGRES LISTENER, REACHABLE FROM ANOTHER MACHINE (docs/POSTGRES-API.md "Connecting"). On
3
+ * a test server a hosted BI tool had to reach the run, which listened on 127.0.0.1 with no TLS, and a TLS proxy was
4
+ * written beside Mason for it. Now `mason start` reads where and how its listener listens with the one reader the
5
+ * ClickHouse stack's Query API used too (`pgListenerSettings`, src/api/postgres/listener.ts): `MASON_PG_HOST` (default
6
+ * 127.0.0.1), the port `mason start` chose (`MASON_PG_PORT`'s, or a free one), `MASON_PG_TLS_CERT` / `MASON_PG_TLS_KEY`, and the plain-listener rule
7
+ * (src/api/postgres/plain.ts): off loopback it needs TLS, or `MASON_PG_PLAIN=1` for a team that terminates TLS in a
8
+ * proxy of its own.
9
+ *
10
+ * `--self-signed-tls` (named for what the developer gets, in the variables' own word, TLS) is a certificate made for
11
+ * the project in its `.mason/` (0600), recorded in its config and reused by every later start with the flag; it is
12
+ * handed to the same reader through the same two variables, never a second path. With TLS the listener requires it of
13
+ * every client (src/api/postgres/server.ts: a plain startup is refused, 28000).
14
+ */
15
+ import * as crypto from "node:crypto";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { isLoopback } from "../api/postgres/plain.js";
19
+ import { pgListenerSettings } from "../api/postgres/listener.js";
20
+ import { selfSignedCertificate } from "./self-signed-cert.js";
21
+ /** the self-signed certificate's files, in the project's `.mason/` */
22
+ export const SELF_SIGNED_FILES = { cert: "postgres-tls.crt", key: "postgres-tls.key" };
23
+ /**
24
+ * Where and how the run's listener listens: the shell's `MASON_PG_*` with the port `mason start` chose and, under
25
+ * `--self-signed-tls`, the project's own files as the certificate — or why not, as a Problem (what, why, the way out).
26
+ * Reads no file: `mason start` asks it before it spawns the run, and the run before it connects to the database.
27
+ */
28
+ export function runListenerSettings(shell, o) {
29
+ if (o.selfSigned && (shell.MASON_PG_TLS_CERT || shell.MASON_PG_TLS_KEY)) {
30
+ return { problem: "--self-signed-tls makes a certificate, and MASON_PG_TLS_CERT / MASON_PG_TLS_KEY name one: one or the other", code: "usage",
31
+ fix: "drop --self-signed-tls to serve your certificate, or unset MASON_PG_TLS_CERT and MASON_PG_TLS_KEY to serve a self-signed one" };
32
+ }
33
+ const s = pgListenerSettings({
34
+ MASON_PG_HOST: shell.MASON_PG_HOST, MASON_PG_PORT: String(o.port), MASON_PG_PLAIN: shell.MASON_PG_PLAIN,
35
+ MASON_PG_TLS_CERT: o.selfSigned ? path.join(o.home, SELF_SIGNED_FILES.cert) : shell.MASON_PG_TLS_CERT,
36
+ MASON_PG_TLS_KEY: o.selfSigned ? path.join(o.home, SELF_SIGNED_FILES.key) : shell.MASON_PG_TLS_KEY,
37
+ }, "127.0.0.1");
38
+ if (!("problem" in s))
39
+ return s;
40
+ return { problem: s.problem, code: "refused", ...(s.why ? { why: s.why } : {}),
41
+ // the run makes a certificate where the service cannot: said beside the variables
42
+ ...(s.fix ? { fix: s.kind === "plain" ? `${s.fix}. Or mason start --self-signed-tls: a certificate made for this project in .mason/` : s.fix } : {}),
43
+ ...(s.kind === "plain" ? { next: [["mason start --self-signed-tls", "TLS from a certificate made in .mason/ (clients connect with sslmode=require)"]] } : {}) };
44
+ }
45
+ /**
46
+ * The project's self-signed certificate: the one in `.mason/` when it is still good (it parses, its key is its own, it
47
+ * has not expired), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
48
+ */
49
+ export function ensureSelfSigned(home, project, now = new Date()) {
50
+ const certFile = path.join(home, SELF_SIGNED_FILES.cert), keyFile = path.join(home, SELF_SIGNED_FILES.key);
51
+ const kept = keptCertificate(certFile, keyFile, now);
52
+ if ("fingerprint" in kept)
53
+ return { fingerprint: kept.fingerprint, said: null };
54
+ const made = selfSignedCertificate(`mason-${project}`, { now });
55
+ for (const [file, text] of [[keyFile, made.key], [certFile, made.cert]]) {
56
+ fs.writeFileSync(file, text, { mode: 0o600 });
57
+ fs.chmodSync(file, 0o600); // a file that was there keeps its mode through writeFileSync
58
+ }
59
+ return { fingerprint: new crypto.X509Certificate(made.cert).fingerprint256, said: kept.why };
60
+ }
61
+ function keptCertificate(certFile, keyFile, now) {
62
+ if (!fs.existsSync(certFile) || !fs.existsSync(keyFile))
63
+ return { why: `made a self-signed certificate: .mason/${SELF_SIGNED_FILES.cert}` };
64
+ try {
65
+ const x = new crypto.X509Certificate(fs.readFileSync(certFile));
66
+ if (!x.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(keyFile))))
67
+ return { why: `made a new self-signed certificate: .mason/${SELF_SIGNED_FILES.key} was not the key of .mason/${SELF_SIGNED_FILES.cert}` };
68
+ if (new Date(x.validTo).getTime() <= now.getTime())
69
+ return { why: `made a new self-signed certificate: the one in .mason/ expired ${x.validTo}` };
70
+ return { fingerprint: x.fingerprint256 };
71
+ }
72
+ catch (e) {
73
+ return { why: `made a new self-signed certificate: the one in .mason/ could not be read (${e.message})` };
74
+ }
75
+ }
76
+ /** The certificate's SHA-256 fingerprint, for a client that checks it by hand; or why it is no certificate. */
77
+ export function certificateFingerprint(pem) {
78
+ try {
79
+ return new crypto.X509Certificate(pem).fingerprint256;
80
+ }
81
+ catch (e) {
82
+ return { problem: `MASON_PG_TLS_CERT is not a PEM certificate: ${e.message}` };
83
+ }
84
+ }
85
+ /** What the listener serves, one line: its address, then TLS (whose certificate) or plain (and on what network). */
86
+ export function listenerText(l, port) {
87
+ const every = l.host === "0.0.0.0" || l.host === "::";
88
+ const address = `${l.host.includes(":") ? `[${l.host}]` : l.host}:${port}${every ? " (every address of this machine)" : ""}`;
89
+ if (l.tls !== null) {
90
+ return `${address}, TLS required: ${l.tls === "self-signed" ? `a self-signed certificate (.mason/${SELF_SIGNED_FILES.cert})` : "the certificate MASON_PG_TLS_CERT names"}` +
91
+ `${l.fingerprint ? `, SHA-256 ${l.fingerprint}` : ""}`;
92
+ }
93
+ return `${address}, ${isLoopback(l.host) ? "plain: this machine only" : "PLAIN under MASON_PG_PLAIN=1: a password travels in clear on that network"}`;
94
+ }
95
+ /** Where a tool on another machine reaches it, when the recorded URL's host is not that: every address is localhost here. */
96
+ export function elsewhereTip(l) {
97
+ return l.host === "0.0.0.0" || l.host === "::"
98
+ ? "It listens on every address of this machine: from another machine, put this machine's address or name in place of localhost."
99
+ : null;
100
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * THE POSTGRES API'S URL, as the commands print it (`mason connect`'s helpers before it went: `mason user` is the one
3
+ * credential). A run records where its listener is as `postgresql://<host>:<port>/<database>` with no credential
4
+ * (src/cli/dev.ts); `mason user add` prints the URL a tool pastes, with the user and its password in it; `mason start`
5
+ * and `mason status` print it with the credential's PLACE shown, never a credential. WHAT A PERSON SEES BEGINS
6
+ * `postgres://` (a printed `postgresql://` read as inconsistent with everything else the CLI shows): the scheme every
7
+ * example, usage line and `mason init` uses; the recorded form keeps `postgresql://` (both are libpq's), and
8
+ * `withoutPlaceholders` maps the shown form back to it.
9
+ */
10
+ import { shellWord } from "../lib/shell-word.js";
11
+ import { elsewhereTip } from "./pg-tls.js";
12
+ /** The parts of a recorded `postgresql://<host>:<port>/<database>`. A URL that is not one is a bug in what wrote it, said so. */
13
+ export function postgresLink(recorded) {
14
+ let u;
15
+ try {
16
+ u = new URL(recorded);
17
+ }
18
+ catch {
19
+ throw new Error(`the recorded Postgres API ${JSON.stringify(recorded)} is not a URL`);
20
+ }
21
+ if (u.protocol !== "postgresql:" || !u.port || u.pathname.length < 2)
22
+ throw new Error(`the recorded Postgres API ${JSON.stringify(recorded)} is not postgresql://<host>:<port>/<database>`);
23
+ // a bracketed IPv6 host is spelled as the driver takes it
24
+ const sslmode = u.searchParams.get("sslmode");
25
+ if (sslmode !== null && sslmode !== "require")
26
+ throw new Error(`the recorded Postgres API ${JSON.stringify(recorded)} has sslmode=${sslmode}: a run records require or none`);
27
+ return { url: recorded, host: u.hostname.replace(/^\[|\]$/g, ""), port: Number(u.port), database: decodeURIComponent(u.pathname.slice(1)), ...(sslmode ? { sslmode } : {}) };
28
+ }
29
+ /** The connection URL a tool takes: the link with a user and its password, each escaped for a URL; with `password`
30
+ * null, the user alone (psql takes the password from PGPASSWORD). */
31
+ export function connectionUrl(link, user, password) {
32
+ const host = link.host.includes(":") ? `[${link.host}]` : link.host;
33
+ return `postgres://${encodeURIComponent(user)}${password === null ? "" : `:${encodeURIComponent(password)}`}@${host}:${link.port}/${encodeURIComponent(link.database)}` +
34
+ (link.sslmode ? `?sslmode=${link.sslmode}` : "");
35
+ }
36
+ /** The recorded URL with the credentials' PLACE shown — `postgres://<user>:<password>@host:port/db` — for a line that
37
+ * must never carry a credential (mason start, mason status). */
38
+ export const withPlaceholders = (recorded) => recorded.replace(/^postgresql:\/\//, "postgres://<user>:<password>@");
39
+ /** The recorded URL back from its placeholder form (mason start's relay reads its own line). */
40
+ export const withoutPlaceholders = (shown) => shown.replace(/^postgres:\/\/<user>:<password>@/, "postgresql://");
41
+ /** The psql line for a URL, quoted for a shell. */
42
+ export const psqlLine = (url) => `psql ${shellWord(url)}`;
43
+ /** What a tool connecting to the listener should know: it listens on every address; its certificate is self-signed. */
44
+ export function listenerTips(l) {
45
+ if (l === null)
46
+ return [];
47
+ const elsewhere = elsewhereTip(l);
48
+ return [
49
+ ...(elsewhere ? [elsewhere] : []),
50
+ ...(l.tls === "self-signed" ? [`The certificate is self-signed: sslmode=require encrypts and checks no certificate; to check it by hand, its SHA-256 is ${l.fingerprint ?? "in mason status"}.`] : []),
51
+ ];
52
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * THE PROJECT'S VERSION PIN, as the mason command holds it (src/lib/project-version.ts has the rule and the image's
3
+ * side): `mason init` writes `mason.yml` with this command's version, `mason start` writes it into a project made
4
+ * before there was one, and `mason init` and `mason start` refuse a command of another release line — what the
5
+ * developer runs is then what production's image is held to.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { parseVersion, pinFileText, pinProblem, readPin, versionText, PIN_FILE } from "../lib/project-version.js";
10
+ /** Why this mason command may not run the project in `folder`, or null (a checkout's `dev` checks nothing). */
11
+ export function cliPinProblem(folder, cliVersion) {
12
+ const running = parseVersion(cliVersion);
13
+ if (running === null)
14
+ return null;
15
+ const read = readPin(folder);
16
+ if ("problem" in read)
17
+ return read.problem;
18
+ if (read.pin === null)
19
+ return null;
20
+ const p = versionText(read.pin);
21
+ return pinProblem(read.pin, running, read.file, "this mason command", `Run the mason command it names: npm i -g @mason-data/cli@${p} (or npx @mason-data/cli@${p} …).`);
22
+ }
23
+ /**
24
+ * Write `folder`'s mason.yml with this command's version when it has none; the file written, or null when there was one
25
+ * already (or this is a checkout's `dev`, which has no version to write).
26
+ */
27
+ export function ensurePin(folder, cliVersion) {
28
+ if (parseVersion(cliVersion) === null)
29
+ return null;
30
+ const file = path.join(folder, PIN_FILE);
31
+ if (fs.existsSync(file))
32
+ return null;
33
+ fs.mkdirSync(folder, { recursive: true });
34
+ fs.writeFileSync(file, pinFileText(cliVersion));
35
+ return file;
36
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * WHETHER A PORT IS FREE, DECIDED IN ONE PLACE (#499 finding 5): `mason start` picks its ports here and
3
+ * `mason doctor` reports them from here, so the two cannot disagree.
4
+ *
5
+ * A port is free only when no address a client may use is held. A probe of 127.0.0.1 alone binds beside a process on
6
+ * the wildcard (macOS allows it; OrbStack publishes a container's port on *:5433), and `::` (v6only) binds beside one on
7
+ * ::1 alone (macOS again: Node binds `localhost` there) — so 127.0.0.1:5433 reached Mason while a container, or a driver
8
+ * trying ::1 first, reached another server. An address family the machine lacks (no IPv6) holds nothing.
9
+ *
10
+ * A probe binds and lets go: it says who holds a port NOW, never that it stays free until the service listens on it.
11
+ */
12
+ import * as net from "node:net";
13
+ import { DEFAULT_PG_PORT, pgListenerAddress } from "../api/postgres/listener.js";
14
+ export const PROBE_ADDRESSES = ["127.0.0.1", "0.0.0.0", "::", "::1"];
15
+ /** The address that holds `port`, or null when every address binds. */
16
+ export async function portHolder(port) {
17
+ for (const address of PROBE_ADDRESSES) {
18
+ const code = await new Promise((resolve) => {
19
+ const srv = net.createServer();
20
+ srv.once("error", (e) => resolve(e.code ?? "ERROR"));
21
+ srv.listen({ port, host: address, ipv6Only: address === "::" }, () => srv.close(() => resolve(null)));
22
+ });
23
+ if (code === null || code === "EADDRNOTAVAIL" || code === "EAFNOSUPPORT")
24
+ continue;
25
+ return { address, code };
26
+ }
27
+ return null;
28
+ }
29
+ /** Why a held port cannot be listened on, in a developer's words. */
30
+ export function heldText(port, held) {
31
+ return held.code === "EACCES"
32
+ ? `port ${port} needs root here (a bind on ${held.address} answered EACCES): a port below 1024 is the system's`
33
+ : `something holds port ${port} on ${held.address} (${held.code})`;
34
+ }
35
+ /**
36
+ * A port free on every address, preferring `want`; `avoid` are ports already chosen for another listener of the same
37
+ * start (probed and let go, so a probe would call them free). `skipped` says who held `want` when another was chosen.
38
+ */
39
+ export async function pickPort(want, avoid = new Set()) {
40
+ const held = avoid.has(want) ? { address: "this mason start", code: "chosen for another listener" } : await portHolder(want);
41
+ if (!held)
42
+ return { port: want };
43
+ for (let attempt = 0; attempt < 20; attempt++) {
44
+ // the OS picks on the wildcard, so the port it returns is free there; the probe then checks the other addresses
45
+ const port = await new Promise((resolve, reject) => {
46
+ const srv = net.createServer();
47
+ srv.once("error", reject);
48
+ srv.listen(0, "0.0.0.0", () => { const a = srv.address(); srv.close(() => resolve(a.port)); });
49
+ });
50
+ if (!avoid.has(port) && !(await portHolder(port)))
51
+ return { port, skipped: held };
52
+ }
53
+ throw new Error(`no free TCP port found on this machine after 20 tries (${heldText(want, held)}). Free some ports, then run mason start again`);
54
+ }
55
+ /** A free port, preferring `want` (`pickPort`). */
56
+ export async function freePort(want, avoid) {
57
+ return (await pickPort(want, avoid)).port;
58
+ }
59
+ const PG_PORT_WAY_OUT = "set another MASON_PG_PORT (1024-65535), or unset it and mason start picks a free port";
60
+ /** `MASON_PG_PORT` as the shell gives it, before any probe: a port to listen on, or why it is not one. */
61
+ export function namedPgPort(named) {
62
+ const parsed = pgListenerAddress({ MASON_PG_PORT: named }, "127.0.0.1");
63
+ if ("problem" in parsed)
64
+ return { problem: parsed.problem, code: "refused", fix: PG_PORT_WAY_OUT };
65
+ if (parsed.port === 0)
66
+ return { problem: "MASON_PG_PORT=0 is not a port the Postgres API can be reached on", code: "refused",
67
+ why: "0 asks the system for any free port, and mason start would record port 0", fix: PG_PORT_WAY_OUT };
68
+ return { port: parsed.port };
69
+ }
70
+ /** The refusal for a named `MASON_PG_PORT` that is held: what, why (who holds it), the way out. */
71
+ export function heldPgPort(port, held) {
72
+ return held.code === "EACCES"
73
+ ? { problem: `MASON_PG_PORT=${port} needs root`, code: "refused", why: heldText(port, held), fix: PG_PORT_WAY_OUT }
74
+ : { problem: `MASON_PG_PORT=${port} is in use`, code: "refused", why: `${heldText(port, held)}, so the Postgres API cannot listen there`,
75
+ fix: `stop what holds it (lsof -nP -iTCP:${port} -sTCP:LISTEN names it), or ${PG_PORT_WAY_OUT}` };
76
+ }
77
+ /** The refusal for the PROJECT'S recorded port that is held: it is never swapped, because every tool's saved connection names it. */
78
+ export function heldProjectPort(port, held) {
79
+ const moved = `MASON_PG_PORT=<n> mason start moves this project to another port (every tool's saved connection must change with it)`;
80
+ return held.code === "EACCES"
81
+ ? { problem: `this project's Postgres API port ${port} needs root`, code: "refused", why: heldText(port, held), fix: moved }
82
+ : { problem: `this project's Postgres API port ${port} is in use`, code: "refused",
83
+ why: `${heldText(port, held)}, so the Postgres API cannot listen there; the port is the project's since its first start, so a tool's saved connection keeps working`,
84
+ fix: `stop what holds it (lsof -nP -iTCP:${port} -sTCP:LISTEN names it), or ${moved}` };
85
+ }
86
+ /**
87
+ * THE POSTGRES API'S PORT, THE PROJECT'S OWN (it must not change at every restart, even on a laptop, or every BI
88
+ * tool's saved credentials need updating each time). `recorded` is
89
+ * the port the project's first start chose (`ProjectConfig.pgPort`): every later start uses it, or is REFUSED by name
90
+ * when it is held (`heldProjectPort`), never moved to another port by itself. `MASON_PG_PORT` set in the shell is the
91
+ * developer naming it, or moving the project to it: used if free on every address, else REFUSED by name, and said as a
92
+ * move when it differs from the recorded one. With neither, 5433 is preferred and the next free port taken, and `said`
93
+ * names the address that held 5433 (often a second Postgres: an application's own stack); the caller records the choice.
94
+ * `avoid`: ports this start has already chosen. Free at the probe, not reserved (above): a later holder is the
95
+ * service's EADDRINUSE to say.
96
+ */
97
+ export async function choosePgPort(named, avoid = new Set(), recorded = null) {
98
+ if (named !== undefined && named !== "") {
99
+ const n = namedPgPort(named);
100
+ if ("problem" in n)
101
+ return n;
102
+ const held = avoid.has(n.port) ? { address: "this mason start", code: "chosen for another listener" } : await portHolder(n.port);
103
+ if (held)
104
+ return heldPgPort(n.port, held);
105
+ const moved = recorded !== null && recorded !== n.port ? `, moved from ${recorded}: every tool's saved connection must change to it` : "";
106
+ return { port: n.port, said: `Postgres API: port ${n.port} (from MASON_PG_PORT)${moved}` };
107
+ }
108
+ if (recorded !== null) {
109
+ const held = avoid.has(recorded) ? { address: "this mason start", code: "chosen for another listener" } : await portHolder(recorded);
110
+ if (held)
111
+ return heldProjectPort(recorded, held);
112
+ return { port: recorded };
113
+ }
114
+ const { port, skipped } = await pickPort(DEFAULT_PG_PORT, avoid);
115
+ return skipped
116
+ ? { port, said: `Postgres API: ${heldText(DEFAULT_PG_PORT, skipped)}, often another Postgres, so using ${port}, this project's port from now on (MASON_PG_PORT=<n> mason start moves it)` }
117
+ : { port };
118
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * BEFORE `mason start` STARTS ANYTHING: can it read the database, and should it? Every failure says what to change,
3
+ * because the landing page's command is meant to be pasted and edited — a wrong password, a
4
+ * database that does not exist, nothing listening, a URL whose password needs encoding. Then, for a database that is
5
+ * not on this machine, what polling it would cost and where (src/cdc/poll.ts): a copy over the network, a scan of
6
+ * every table each poll, a serverless compute kept awake. Read-only: nothing here writes to the database.
7
+ *
8
+ * The connection is the change reader's own (src/cdc/pg-wire.ts: TCP, password / md5 / SCRAM, TLS by sslmode), so
9
+ * what passes here is what the service will use.
10
+ */
11
+ import * as os from "node:os";
12
+ import { PgConnection, PgError } from "../cdc/pg-wire.js";
13
+ import { parseDatabaseUrl } from "../lib/database-url.js";
14
+ /** A host on this machine: polling it costs nothing that leaves the machine. */
15
+ export function isLocalHost(host) {
16
+ const h = host.toLowerCase();
17
+ return h === "localhost" || h === "::1" || h === "0.0.0.0" || /^127\./.test(h) || h === "host.docker.internal" || h.endsWith(".localhost");
18
+ }
19
+ /** The URL with its password masked: what is printed and shown, never the secret. */
20
+ export function maskedUrl(url) {
21
+ try {
22
+ const u = new URL(url);
23
+ if (u.password)
24
+ u.password = "****";
25
+ return u.toString();
26
+ }
27
+ catch {
28
+ return url.replace(/:\/\/([^:@/]*):[^@/]*@/, "://$1:****@");
29
+ }
30
+ }
31
+ /**
32
+ * Parse the URL `mason start` was given. The user defaults to the OS user (libpq's default, and Homebrew's and
33
+ * Postgres.app's superuser); the database never defaults — guessing it would read the wrong one.
34
+ */
35
+ export function parseSource(url) {
36
+ let u;
37
+ try {
38
+ u = new URL(url);
39
+ }
40
+ catch {
41
+ return { problem: `${JSON.stringify(url)} is not a URL. The form is postgres://user:password@localhost:5432/your_app — a password ` +
42
+ `containing @ : / # ? or % must be URL-encoded (encodeURIComponent; @ is %40).` };
43
+ }
44
+ if (u.protocol !== "postgres:" && u.protocol !== "postgresql:")
45
+ return { problem: `the URL's scheme is ${u.protocol.replace(/:$/, "")}; it must be postgres:// or postgresql://` };
46
+ if (!u.username)
47
+ u.username = encodeURIComponent(os.userInfo().username);
48
+ if (!u.pathname.replace(/^\//, "")) {
49
+ return { problem: `the URL names no database. Add it after the port: postgres://${decodeURIComponent(u.username)}@${u.host || "localhost:5432"}/your_app ` +
50
+ `(the one your app uses — its DATABASE_URL usually says).` };
51
+ }
52
+ try {
53
+ const parts = parseDatabaseUrl(u.toString());
54
+ const remote = !isLocalHost(parts.host);
55
+ // a database elsewhere is read over the network: never in the clear unless the URL says so outright
56
+ if (remote && !u.searchParams.has("sslmode"))
57
+ parts.sslmode = "require";
58
+ return { source: { ...parts, url: u.toString(), remote } };
59
+ }
60
+ catch (e) {
61
+ return { problem: e.message.replace(/^DATABASE_URL/, "the URL") };
62
+ }
63
+ }
64
+ /** What a failed connection means, and what to change. */
65
+ export function explainConnectError(e, s) {
66
+ const where = `${s.host}:${s.port}`;
67
+ const msg = e?.message ?? String(e);
68
+ if (e instanceof PgError) {
69
+ if (e.code === "28P01" || e.code === "28000") {
70
+ return {
71
+ message: `${where} refused user ${s.user}: ${msg}`,
72
+ hints: [
73
+ "Homebrew and Postgres.app: the user is your macOS username, with no password — postgres://" + os.userInfo().username + "@localhost:5432/your_app",
74
+ "Docker's postgres image: the user is postgres (or POSTGRES_USER), the password POSTGRES_PASSWORD in your docker-compose.yml",
75
+ "A Linux package (apt, dnf): the postgres user has no password until you set one (sudo -u postgres psql -c \"\\password postgres\"); Mason connects over TCP, so peer authentication does not apply",
76
+ "Your app's DATABASE_URL (in .env) is a URL that works: pass that",
77
+ ],
78
+ };
79
+ }
80
+ if (e.code === "3D000")
81
+ return { message: `${where} has no database ${JSON.stringify(s.database)}`, hints: [] };
82
+ return { message: `${where}: ${msg}`, hints: [] };
83
+ }
84
+ if (/ECONNREFUSED/.test(msg)) {
85
+ return {
86
+ message: `nothing is listening on ${where}`,
87
+ hints: [
88
+ "Is Postgres running? (brew services list, docker ps, or Postgres.app's window)",
89
+ "Supabase's local stack listens on 54322; a second Postgres on a machine is often on 5433",
90
+ ],
91
+ };
92
+ }
93
+ if (/ENOTFOUND|EAI_AGAIN/.test(msg))
94
+ return { message: `the host ${s.host} does not resolve`, hints: ["Check the host name in the URL"] };
95
+ if (/timed out|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/.test(msg)) {
96
+ return {
97
+ message: `${where} did not answer`,
98
+ hints: s.remote
99
+ ? ["A database in a private network (a VPC) is not reachable from this machine without a tunnel: ssh -L 5433:<db host>:5432 <bastion>, then postgres://…@localhost:5433/…"]
100
+ : ["Is Postgres running, and listening on that port?"],
101
+ };
102
+ }
103
+ if (/does not accept TLS/.test(msg)) {
104
+ return { message: msg, hints: ["A database on another machine is read over TLS by default; add ?sslmode=disable to the URL only on a network you trust"] };
105
+ }
106
+ return { message: `${where}: ${msg}`, hints: [] };
107
+ }
108
+ /** Connect as the service will. The failure is explained; for a missing database, the ones that exist are listed. */
109
+ export async function connectSource(s) {
110
+ const opts = { host: s.host, port: s.port, database: s.database, user: s.user, password: s.password, sslmode: s.sslmode, connectTimeoutMs: 10_000, applicationName: "mason-dev" };
111
+ try {
112
+ return { pg: await PgConnection.connect(opts) };
113
+ }
114
+ catch (e) {
115
+ const failure = explainConnectError(e, s);
116
+ if (e instanceof PgError && e.code === "3D000") {
117
+ try {
118
+ const pg = await PgConnection.connect({ ...opts, database: "postgres" });
119
+ try {
120
+ const names = (await pg.query("SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname")).rows.map((r) => r[0]);
121
+ failure.hints.push(`The databases on ${s.host}:${s.port}: ${names.join(", ")}`);
122
+ }
123
+ finally {
124
+ pg.close();
125
+ }
126
+ }
127
+ catch (inner) {
128
+ // listing is a courtesy: the refusal above stands either way, and says why the list is missing
129
+ failure.hints.push(`(could not list the databases there: ${inner.message})`);
130
+ }
131
+ }
132
+ return { failure };
133
+ }
134
+ }
135
+ /** The source's plain views (never a materialized one: that is a table of its own), each definition printed under a
136
+ * search_path of pg_catalog alone, which qualifies every table, type and function a view reads (`public.orders`):
137
+ * `SET LOCAL` holds for the rest of this one Query and ends with it, whatever happens */
138
+ export const VIEWS_SQL = `SET LOCAL search_path = pg_catalog; SELECT coalesce(json_agg(v ORDER BY v.oid), '[]'::json)::text FROM (
139
+ SELECT c.oid, n.nspname AS schema, c.relname AS name, pg_get_viewdef(c.oid) AS definition, obj_description(c.oid, 'pg_class') AS comment
140
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
141
+ WHERE c.relkind = 'v' AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
142
+ AND has_table_privilege(c.oid, 'SELECT')
143
+ ) v`;
144
+ /** One catalog read: every ordinary or partitioned table outside the system schemas, its key, size, comments and FKs. */
145
+ export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text FROM (
146
+ SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid,
147
+ -- a PARTITIONED table's rows and bytes are its leaves' (its own are -1 / 0 and ~0 bytes); "never analysed" is
148
+ -- relpages = 0 AND reltuples <= 0 on any leaf, which Postgres 13 (reltuples 0) and 14+ (-1) both read
149
+ CASE WHEN c.relkind = 'p'
150
+ THEN (SELECT coalesce(sum(greatest(l.reltuples, 0)), 0)::bigint FROM pg_partition_tree(c.oid) pt JOIN pg_class l ON l.oid = pt.relid WHERE pt.isleaf)
151
+ ELSE greatest(c.reltuples, 0)::bigint END AS reltuples,
152
+ CASE WHEN c.relkind = 'p'
153
+ THEN coalesce((SELECT bool_or(l.relpages = 0 AND l.reltuples <= 0) FROM pg_partition_tree(c.oid) pt JOIN pg_class l ON l.oid = pt.relid WHERE pt.isleaf), false)
154
+ ELSE c.relpages = 0 AND c.reltuples <= 0 END AS unanalysed,
155
+ CASE WHEN c.relkind = 'p'
156
+ THEN (SELECT coalesce(sum(pg_total_relation_size(pt.relid)), 0) FROM pg_partition_tree(c.oid) pt WHERE pt.isleaf)
157
+ ELSE pg_total_relation_size(c.oid) END AS bytes,
158
+ obj_description(c.oid, 'pg_class') AS comment,
159
+ (SELECT coalesce(json_agg(a.attname ORDER BY k.n), '[]'::json)
160
+ FROM pg_index i CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, n)
161
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
162
+ WHERE i.indrelid = c.oid AND i.indisprimary) AS key,
163
+ (SELECT coalesce(json_agg(json_build_object('name', ic.relname, 'constraint', EXISTS (SELECT FROM pg_constraint u WHERE u.conindid = i.indexrelid AND u.contype = 'u'),
164
+ -- indnullsnotdistinct is Postgres 15's: read through the row, which an older server's lacks
165
+ 'nulls_not_distinct', coalesce((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false),
166
+ 'columns', (SELECT json_agg(a.attname ORDER BY k.n) FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, n)
167
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum)) ORDER BY ic.relname), '[]'::json)
168
+ FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid
169
+ WHERE i.indrelid = c.oid AND i.indisunique AND NOT i.indisprimary AND i.indpred IS NULL AND i.indexprs IS NULL
170
+ AND i.indnkeyatts = i.indnatts) AS unique_keys,
171
+ (SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod),
172
+ 'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid', a.atttypid,
173
+ 'type_array_oid', (SELECT typarray FROM pg_type WHERE oid = a.atttypid),
174
+ 'default', pg_get_expr(d.adbin, d.adrelid), 'identity', a.attidentity, 'generated', a.attgenerated,
175
+ -- the sequence a serial or identity column owns: asked only of a column with a default or an identity
176
+ 'sequence', CASE WHEN d.adbin IS NOT NULL OR a.attidentity <> '' THEN pg_get_serial_sequence(format('%I.%I', n.nspname, c.relname), a.attname) END) ORDER BY a.attnum), '[]'::json)
177
+ FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
178
+ WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped) AS columns,
179
+ (SELECT coalesce(json_agg(json_build_object('column', a.attname, 'target', tn.nspname || '.' || tc.relname, 'target_column', ta.attname)), '[]'::json)
180
+ FROM pg_constraint f
181
+ JOIN pg_attribute a ON a.attrelid = f.conrelid AND a.attnum = f.conkey[1]
182
+ JOIN pg_class tc ON tc.oid = f.confrelid JOIN pg_namespace tn ON tn.oid = tc.relnamespace
183
+ JOIN pg_attribute ta ON ta.attrelid = f.confrelid AND ta.attnum = f.confkey[1]
184
+ WHERE f.conrelid = c.oid AND f.contype = 'f' AND cardinality(f.conkey) = 1) AS foreign_keys
185
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
186
+ WHERE c.relkind IN ('r', 'p') AND NOT c.relispartition
187
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
188
+ AND has_table_privilege(c.oid, 'SELECT')
189
+ ) t`;
190
+ /** What a never-analysed table counted to, by oid: `mason start` reads the catalog every 10 s, and a table is counted
191
+ * once per run, never per read (review of #404: a remote one over the network each time). */
192
+ const COUNTED = new Map();
193
+ /** The database's facts: its version, whether it is a standby, and every table this user can read. */
194
+ export async function readSourceFacts(pg, counted = COUNTED) {
195
+ const r = (await pg.query("SELECT current_setting('server_version_num'), current_setting('server_version'), pg_is_in_recovery()::text")).rows[0];
196
+ const rows = JSON.parse((await pg.query(TABLES_SQL)).rows[0]?.[0] ?? "null");
197
+ const tables = [];
198
+ for (const t of rows ?? []) {
199
+ let count = t.unanalysed ? counted.get(t.oid) ?? null : Number(t.reltuples);
200
+ // never analysed: count it when it is small enough to count in a moment, once; a larger one's size is unknown (null)
201
+ if (count === null && t.unanalysed && Number(t.bytes) < 64 * 1024 * 1024) {
202
+ count = Number((await pg.query(`SELECT count(*) FROM ${pgQualified(t.schema, t.table)}`)).rows[0]?.[0] ?? "0");
203
+ counted.set(t.oid, count);
204
+ }
205
+ tables.push({
206
+ source: `${t.schema}.${t.table}`, schema: t.schema, table: t.table, key: t.key, rows: count, bytes: Number(t.bytes), comment: t.comment,
207
+ columns: t.columns.map((c) => ({ name: c.name, type: c.type, notNull: c.not_null, comment: c.comment,
208
+ ...(c.type_oid >= 16384 ? { typeOid: c.type_oid, ...(c.type_array_oid > 0 ? { typeArrayOid: c.type_array_oid } : {}) } : {}),
209
+ // a generated column's pg_attrdef row is its expression, never a default
210
+ ...(c.default !== null && c.generated === "" ? { default: c.default } : {}), ...(c.identity === "a" || c.identity === "d" ? { identity: c.identity } : {}),
211
+ ...(c.generated === "s" ? { generated: true } : {}), ...(c.sequence !== null ? { sequence: c.sequence } : {}) })),
212
+ foreignKeys: t.foreign_keys.map((f) => ({ column: f.column, target: f.target, targetColumn: f.target_column })),
213
+ uniqueKeys: t.unique_keys.map((u) => ({ name: u.name, columns: u.columns, constraint: u.constraint, nullsNotDistinct: u.nulls_not_distinct })),
214
+ ...(t.row_type_oid > 0 ? { rowTypeOid: t.row_type_oid } : {}),
215
+ });
216
+ }
217
+ // the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
218
+ const db = JSON.parse((await pg.query("SELECT to_jsonb(d)::text FROM pg_database d WHERE datname = current_database()")).rows[0]?.[0] ?? "{}");
219
+ // datcollversion (Postgres 15+): the library's version of it (glibc's for libc), which moves when the OS's does
220
+ const collation = { provider: db.datlocprovider ?? "c", collate: db.datcollate ?? "C", locale: db.datlocale ?? db.daticulocale ?? null, version: db.datcollversion ?? null };
221
+ const views = JSON.parse((await pg.query(VIEWS_SQL)).rows[0]?.[0] ?? "[]")
222
+ .map((v) => ({ schema: v.schema, name: v.name, definition: v.definition, comment: v.comment }));
223
+ return { version: Number(r[0]), versionText: r[1] ?? "", standby: r[2] === "true", tables, systemIdentifier: await systemIdentifier(pg), collation, views };
224
+ }
225
+ /** The server's system identifier, or null when this user may not read it (only that refusal: any other error is real). */
226
+ export async function systemIdentifier(pg) {
227
+ try {
228
+ return (await pg.query("SELECT system_identifier::text FROM pg_control_system()")).rows[0]?.[0] ?? null;
229
+ }
230
+ catch (e) {
231
+ // 42501 insufficient_privilege: a server that revoked pg_control_system from PUBLIC. The identity is then unknown,
232
+ // which mason start says it cannot check; it is never guessed
233
+ if (e instanceof PgError && e.code === "42501")
234
+ return null;
235
+ throw e;
236
+ }
237
+ }
238
+ const pgQualified = (schema, table) => `"${schema.replace(/"/g, '""')}"."${table.replace(/"/g, '""')}"`;
239
+ /** Hosts whose compute sleeps when idle and bills while awake: polling keeps it awake. */
240
+ export function serverlessHost(host) {
241
+ if (/\.neon\.tech$/i.test(host))
242
+ return "Neon";
243
+ if (/\.aws\.neon\.build$/i.test(host))
244
+ return "Neon";
245
+ if (/\.supabase\.(co|com)$/i.test(host))
246
+ return null; // always on: no sleep to keep awake
247
+ if (/serverless/i.test(host) && /\.rds\.amazonaws\.com$/i.test(host))
248
+ return "Aurora Serverless";
249
+ return null;
250
+ }
251
+ /** Why a table cannot be picked, or null: no primary key (a row version needs one). Its size is never a reason, here or
252
+ * on a database elsewhere (the tables worth copying are the big ones). */
253
+ export function unpickableReason(t) {
254
+ if (t.key.length === 0)
255
+ return "no primary key: Mason keeps one row per key, so it can only copy a table that has one";
256
+ return null;
257
+ }
258
+ /** The warnings `mason start` prints, and `mason status` shows, before anything is copied. */
259
+ export function sourceWarnings(s, f) {
260
+ const out = [];
261
+ if (!s.remote)
262
+ return out;
263
+ out.push(remoteWarning(s));
264
+ if (!f.standby)
265
+ out.push("this is a PRIMARY (pg_is_in_recovery() = false): the database your app writes to. A read replica takes the reads instead");
266
+ const sleeper = serverlessHost(s.host);
267
+ if (sleeper)
268
+ out.push(`${sleeper} suspends an idle compute and bills while it runs: polling every few seconds, or an open replication connection, keeps it running`);
269
+ return out;
270
+ }
271
+ /** The warning a database on another machine gets at every start: what the copy is and how it is kept current (true of
272
+ * both modes — the mode is the start's, not known here: a poll scans each table, replication reads the change log). */
273
+ export function remoteWarning(s) {
274
+ return `${s.host} is not this machine. mason start will copy the tables you pick to this machine (into its copy's file, ` +
275
+ "with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log with --replication";
276
+ }
277
+ /**
278
+ * THE CONFIRMATION A DATABASE ON ANOTHER MACHINE NEEDS (the refusal read as a failed connection and then a fix: only a
279
+ * careful reading showed it was a warning that wanted a yes, and what the risks were). It is not
280
+ * a failed step — the check passed — but a stop before anything is written or started, said as one failure a reader
281
+ * scans: what stopped (the message), what would happen and the risk (why), the one way to say yes (fix, next).
282
+ */
283
+ export function remoteConfirmation(host, o) {
284
+ return { code: "failed", exit: 1,
285
+ message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and copying from it needs your say-so`,
286
+ why: "mason start would copy the tables you pick to this machine: a file under .mason/, with no access control beyond this machine's\n" +
287
+ "and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log with --replication",
288
+ fix: o.wayOut, next: o.next };
289
+ }
290
+ /** `pg_current_snapshot()` and xid8, which polling reads, arrived in Postgres 13. */
291
+ export function versionProblem(f) {
292
+ return f.version >= 130000 ? null : `mason start needs Postgres 13 or later (pg_current_snapshot, which it polls with); this is ${f.versionText}`;
293
+ }
294
+ /**
295
+ * THE ONE CHECK OF A DATABASE (init checks the Postgres connection too, and fails when it cannot connect): connect as
296
+ * the service will, read its version and tables, say the warnings, and refuse a database on another machine unless
297
+ * `remote` says it is meant — `mason init` before it writes anything, `mason start` before it starts anything. Every
298
+ * refusal is said through `say`, with the way out; the result is the exit code then. A database elsewhere that only
299
+ * lacks the confirmation is answered as `NeedsRemote`: the caller says the confirmation, in its own command's words,
300
+ * after the check's facts.
301
+ */
302
+ export async function checkSource(url, o) {
303
+ const parsed = parseSource(url);
304
+ if ("problem" in parsed) {
305
+ o.say(`${o.cmd}: ${parsed.problem}`);
306
+ return { exit: 2 };
307
+ }
308
+ const source = parsed.source;
309
+ // progress, not a finding: a caller with a checklist of its own (mason start) says it there
310
+ (o.progress ?? o.say)(`${o.cmd}: connecting to ${maskedUrl(source.url)} …`);
311
+ const connected = await connectSource(source);
312
+ if ("failure" in connected) {
313
+ o.say(`${o.cmd}: could not connect: ${connected.failure.message}`);
314
+ for (const h of connected.failure.hints)
315
+ o.say(` - ${h}`);
316
+ return { exit: 1 };
317
+ }
318
+ let facts;
319
+ try {
320
+ facts = await readSourceFacts(connected.pg);
321
+ }
322
+ finally {
323
+ connected.pg.close();
324
+ }
325
+ const tooOld = versionProblem(facts);
326
+ if (tooOld) {
327
+ o.say(`${o.cmd}: ${tooOld}`);
328
+ return { exit: 1 };
329
+ }
330
+ const warnings = sourceWarnings(source, facts);
331
+ o.say(` Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read. ` +
332
+ "Mason only reads it: no setting, slot or publication is needed or made.");
333
+ for (const w of warnings)
334
+ o.say(` ! ${w}`);
335
+ if (source.remote && !o.remote)
336
+ return { needsRemote: { source, facts, warnings } };
337
+ return { source, facts, warnings };
338
+ }