@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,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
+ }