@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,257 @@
1
+ /**
2
+ * `mason doctor` (an agent's report on #409 asked for a one-shot check of what the machine lacks): what this machine
3
+ * has of what mason start needs — Node, memory, the ports — and, when a run is up or a database is named, the
4
+ * database and the run's health. Every failing check says what to do; the report ends with the next command to run.
5
+ * Read-only: nothing is started, written or changed.
6
+ */
7
+ import { failed, jsonText, nextBlock, parseCommand } from "./command.js";
8
+ import { out as styleOut, wrap } from "./style.js";
9
+ import { heldPgPort, namedPgPort, portHolder } from "./ports.js";
10
+ import { callRun, RunUnreachable } from "./control.js";
11
+ import { readiness } from "./commands.js";
12
+ import { connectSource, maskedUrl, parseSource, readSourceFacts, versionProblem } from "./preflight.js";
13
+ import { theRun } from "./runs.js";
14
+ import { projectSource } from "./project.js";
15
+ import { DEFAULT_PG_PORT } from "../api/postgres/listener.js";
16
+ import { defaultMemoryLimit, machineMemoryBytes, MEMORY_LIMIT_RE } from "../duck/store.js";
17
+ const out = (m) => { process.stdout.write(`${m}\n`); };
18
+ /**
19
+ * THE NODE THE PACKAGE NEEDS, major.minor: cli/package.json's `engines`, this check, `mason --help` and the README read
20
+ * or are held to it (tests/cli-doctor.test.ts). The package is compiled to JavaScript (cli/build.ts), so it needs no
21
+ * type stripping; 22.13 is the floor the published package was measured against (22.12 refuses a module it
22
+ * loads, 22.23 loads every module). A checkout and the image run the TypeScript itself and need 23.6 (the root
23
+ * package.json).
24
+ */
25
+ export const NODE_MIN = "22.13";
26
+ export const DOCTOR_USAGE = `usage: mason doctor [--json]
27
+
28
+ Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (Mason's copy uses up to half of it by
29
+ default; MASON_DUCKDB_MEMORY=4GiB sets it), the ports Mason listens on — with the way out for anything missing. In a
30
+ project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
31
+ database, whether every added table is copied, the statements of the last day it refused or answered slowly.
32
+
33
+ mason doctor
34
+
35
+ --json as data: { ready, verdict, checks: [{ name, ok, detail }], next: [{ command, what }] }; ok is true, false,
36
+ or null when it could not tell
37
+
38
+ Exit status: 0 everything checked is fine; 1 something is not (said, with the way out).
39
+ `;
40
+ export const DOCTOR = {
41
+ name: "doctor",
42
+ summary: "check this machine for what mason start needs (Node, memory, ports, the database, the run) and say what to do",
43
+ usage: DOCTOR_USAGE,
44
+ options: { json: { type: "boolean" } },
45
+ json: ["ready", "verdict", "checks", "next"],
46
+ };
47
+ /** The ports a mason start takes by default, and what listens on each. */
48
+ export const PORTS = [[4039, "the mason commands"], [DEFAULT_PG_PORT, "the Postgres API"]];
49
+ /** Node's version against the package's floor, major then minor. */
50
+ export function nodeCheck(version = process.versions.node) {
51
+ const [major, minor] = version.split(".").map(Number);
52
+ const [minMajor, minMinor] = NODE_MIN.split(".").map(Number);
53
+ return major > minMajor || (major === minMajor && minor >= minMinor)
54
+ ? { name: "node", ok: true, detail: `Node ${version}` }
55
+ : { name: "node", ok: false, detail: `Node ${version} is too old: mason needs ${NODE_MIN} or later (nodejs.org, or nvm install ${minMajor})` };
56
+ }
57
+ /** The machine's memory and what the copy's engine may use of it (src/duck/store.ts DEFAULT_MEMORY_LIMIT, MASON_DUCKDB_MEMORY). */
58
+ export function memoryCheck(totalBytes = machineMemoryBytes(), env = process.env) {
59
+ const gib = (totalBytes / 1024 ** 3).toFixed(1);
60
+ const set = env.MASON_DUCKDB_MEMORY;
61
+ if (set !== undefined && !MEMORY_LIMIT_RE.test(set)) {
62
+ return { name: "memory", ok: false, detail: `MASON_DUCKDB_MEMORY=${JSON.stringify(set)} is not a size: write it as 4GiB, 512MiB or 2GB (mason start refuses it too)` };
63
+ }
64
+ const share = set ?? defaultMemoryLimit(totalBytes);
65
+ const low = totalBytes < 2 * 1024 ** 3;
66
+ return {
67
+ name: "memory", ok: !low,
68
+ detail: `${gib} GiB; Mason's copy uses up to ${share.trim()}${set !== undefined ? " (MASON_DUCKDB_MEMORY)" : " (half, the default; MASON_DUCKDB_MEMORY sets it)"}` +
69
+ (low ? ". Under 2 GiB, a large table's copy and a query starve each other: expect statements refused for memory" : ""),
70
+ };
71
+ }
72
+ /**
73
+ * Mason's ports in one line, decided by the probe mason start uses (`portHolder`, src/cli/ports.ts: every address, so
74
+ * doctor and start agree on a port another program holds on the wildcard). A taken command port only moves Mason to
75
+ * another free one, so the line says that and who holds each (this project's Mason, or another program). The Postgres
76
+ * API's port is the project's (`recorded`, src/cli/ports.ts choosePgPort) or the one the developer NAMED (`MASON_PG_PORT`),
77
+ * and is not moved: held by anything but this project's run, it is the failure mason start will give.
78
+ * The ports line used to confuse: it was a "?" naming each port's role and "something else".
79
+ */
80
+ export async function portsCheck(runs, taken = portHolder, env = process.env, recorded = null) {
81
+ const named = env.MASON_PG_PORT ? namedPgPort(env.MASON_PG_PORT) : null;
82
+ if (named && "problem" in named)
83
+ return { name: "ports", ok: false, detail: `${named.problem}: ${named.fix}` };
84
+ const pgPort = named ? named.port : recorded ?? DEFAULT_PG_PORT;
85
+ const ports = PORTS.map(([p]) => p === DEFAULT_PG_PORT ? pgPort : p);
86
+ const byHolder = new Map();
87
+ for (const port of ports) {
88
+ const held = await taken(port);
89
+ if (!held)
90
+ continue;
91
+ const holder = runs.find((r) => [r.control.url, r.postgresUrl].some((u) => portOf(u) === port));
92
+ if (port === pgPort && !holder) {
93
+ const refused = heldPgPort(port, held);
94
+ return { name: "ports", ok: false, detail: `${refused.problem}: ${refused.why}. ${refused.fix}` };
95
+ }
96
+ const who = holder ? `Mason for ${holder.name}` : "another program";
97
+ byHolder.set(who, [...(byHolder.get(who) ?? []), port]);
98
+ }
99
+ if (!byHolder.size)
100
+ return { name: "ports", ok: true, detail: `${ports.join(", ")} free` };
101
+ const used = [...byHolder].map(([who, ps]) => `${ps.join(", ")} in use by ${who}`).join("; ");
102
+ return { name: "ports", ok: true, detail: `${used}: mason start will use another free port for the commands (mason status shows them)` };
103
+ }
104
+ function portOf(url) {
105
+ try {
106
+ const u = new URL(url);
107
+ return u.port ? Number(u.port) : null;
108
+ }
109
+ catch {
110
+ return null;
111
+ } // not a URL: no port to compare with
112
+ }
113
+ /** The database a URL names: reachable, its version, what this user can read. */
114
+ export async function databaseCheck(url) {
115
+ const parsed = parseSource(url);
116
+ if ("problem" in parsed)
117
+ return { name: "database", ok: false, detail: parsed.problem };
118
+ const connected = await connectSource(parsed.source);
119
+ if ("failure" in connected)
120
+ return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` };
121
+ let facts;
122
+ try {
123
+ facts = await readSourceFacts(connected.pg);
124
+ }
125
+ finally {
126
+ connected.pg.close();
127
+ }
128
+ const tooOld = versionProblem(facts);
129
+ if (tooOld)
130
+ return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` };
131
+ return { name: "database", ok: true, detail: `${maskedUrl(parsed.source.url)}: Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read` };
132
+ }
133
+ /** The run's health, from its own state: every table copied, failing, or still at work; and what it refused or answered slowly. */
134
+ export async function runChecks(run) {
135
+ const checks = [];
136
+ let state;
137
+ try {
138
+ const r = await callRun(run, "GET", "/api/state");
139
+ if (r.status !== 200)
140
+ throw new RunUnreachable(String(r.body.error ?? `HTTP ${r.status}`));
141
+ state = r.body;
142
+ }
143
+ catch (e) {
144
+ if (!(e instanceof RunUnreachable))
145
+ throw e;
146
+ return [{ name: "mason start", ok: false, detail: `${run.name}: ${e.message}` }];
147
+ }
148
+ checks.push({ name: "mason start", ok: true, detail: `${run.name} (pid ${run.pid}); Postgres ${state.source.version}${state.source.standby ? " (a read replica)" : ""} at ${state.source.url}` });
149
+ const ready = readiness(state);
150
+ const picked = state.tables.filter((t) => t.added).length;
151
+ if (ready.failed.length)
152
+ checks.push({ name: "project", ok: false, detail: `${ready.failed.length} problem(s): ${ready.failed.join("; ")} — mason wait prints each with the way out` });
153
+ else if (ready.waiting.length)
154
+ checks.push({ name: "project", ok: null, detail: `still at work: ${ready.waiting.join("; ")} (mason wait)` });
155
+ else
156
+ checks.push({ name: "project", ok: true, detail: picked ? `${picked} table(s) copied and kept current` : "no table added yet (mason tables, then mason add <table>)" });
157
+ const slow = await callRun(run, "GET", "/api/slow"), refused = await callRun(run, "GET", "/api/refused");
158
+ const slowN = slow.status === 200 ? slow.body.slow.length : null, refusedN = refused.status === 200 ? refused.body.refused.length : null;
159
+ checks.push(slowN === null || refusedN === null
160
+ ? { name: "queries", ok: null, detail: "the last day's statements could not be read (mason status)" }
161
+ : { name: "queries", ok: slowN + refusedN === 0, detail: slowN + refusedN === 0 ? "none refused or slow in the last day" : `last day: ${refusedN} refused, ${slowN} slow (mason status lists each, with what to do)` });
162
+ return checks;
163
+ }
164
+ /** What to run next, from what the checks found. */
165
+ export function nextSteps(checks, run, project) {
166
+ const bad = (name) => checks.some((c) => c.name === name && c.ok === false);
167
+ if (bad("node"))
168
+ return [["mason doctor", `again, once Node ${NODE_MIN} or later is installed`]];
169
+ if (bad("project") && run === null)
170
+ return [["mason doctor", "again, once .mason/config.json is repaired or moved aside (the detail above says how)"]];
171
+ if (bad("database"))
172
+ return [["mason start", `once the connection works (the detail above says how; the URL is in ${project ? `${project.dir}/config.json` : ".mason/config.json"})`]];
173
+ if (project === null)
174
+ return [
175
+ ["mason init", "in your app's folder: the database from DATABASE_URL in .env.local, .env or the shell"],
176
+ ["mason init postgres://user:password@localhost:5432/your_app", "or name it"],
177
+ ];
178
+ if (run === null)
179
+ return [["mason start", "Mason in the background, for this folder"]];
180
+ if (bad("mason start"))
181
+ return [["mason logs", "why Mason does not answer"], ["mason stop", "then mason start, to start it again"]];
182
+ if (bad("project"))
183
+ return [["mason wait", "each problem, with the way out"]];
184
+ if (checks.some((c) => c.name === "project" && /no table added/.test(c.detail)))
185
+ return [["mason tables", "the database's tables and their columns"], ["mason add <table>", "copy a table into Mason"]];
186
+ if (bad("queries"))
187
+ return [["mason status", "the refused and slow statements, each with what to do"]];
188
+ return [["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
189
+ }
190
+ /** The one line a first run reads: ready for the next step, or what is missing. */
191
+ export function verdictOf(checks, run, project) {
192
+ const failed = checks.filter((c) => c.ok === false);
193
+ if (run !== null) {
194
+ return failed.length
195
+ ? { ready: false, verdict: `Mason is running for ${run.name}, with ${failed.length} problem${failed.length === 1 ? "" : "s"}` }
196
+ : { ready: true, verdict: `Mason is running for ${run.name}` };
197
+ }
198
+ // the first failure's headline: its sentence before the parenthesis or the way out, which the line below carries whole
199
+ if (failed.length)
200
+ return { ready: false, verdict: `Not ready: ${failed[0].detail.split(/ \(|\. /)[0].trim()}` };
201
+ return { ready: true, verdict: project ? `Ready: mason start (the project ${project.config.name})` : "Ready: mason init, in your app's folder" };
202
+ }
203
+ /** A check's mark: ok, !! a failure, ? could not tell. */
204
+ function markOf(c, s) {
205
+ return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : s.green("ok ");
206
+ }
207
+ export async function doctor(argv, deps = {}) {
208
+ const f = parseCommand(DOCTOR, argv);
209
+ if (typeof f === "number")
210
+ return f;
211
+ if (f.positionals.length)
212
+ return failed("doctor", { message: "it takes no URL", code: "usage", fix: "in a project's folder it checks the project's database (mason init makes one)", usage: DOCTOR_USAGE }, { json: f.values.json === true });
213
+ const which = await (deps.which ?? theRun)(process.cwd());
214
+ const run = "run" in which ? which.run : null;
215
+ const project = which.project;
216
+ // THE CHECKS FOR THE MOMENT (the first run's output, every check at once, confused): before a start, whether
217
+ // the machine can run one; with a run up, the run itself — its ports are its own, and the machine is already proven
218
+ const checks = [];
219
+ if (run === null) {
220
+ checks.push(nodeCheck());
221
+ checks.push(memoryCheck());
222
+ checks.push(await portsCheck([], portHolder, process.env, project?.config.pgPort ?? null));
223
+ if (project) {
224
+ // the database the project's sources file names (src/cli/sources.ts): a file that cannot be read is the failing line
225
+ const src = projectSource(project);
226
+ checks.push("message" in src ? { name: "database", ok: false, detail: `${src.message}${src.why ? `: ${src.why.replace(/\n/g, "; ")}` : ""}. ${src.fix?.split("\n")[0] ?? ""}`.trim() }
227
+ : await databaseCheck(src.resolved));
228
+ }
229
+ // a project here whose config cannot be read: init would refuse, so "Ready: mason init" would send a person nowhere
230
+ if ("code" in which && which.code === "bad_config")
231
+ checks.push({ name: "project", ok: false, detail: `${which.problem}. ${(which.fix ?? "").replace(/^./, (c) => c.toUpperCase())}`.trim() });
232
+ }
233
+ else {
234
+ checks.push(...await runChecks(run));
235
+ }
236
+ const next = nextSteps(checks, run, project);
237
+ const { ready, verdict } = verdictOf(checks, run, project);
238
+ if (f.values.json) {
239
+ out(jsonText(DOCTOR, { ready, verdict, checks }, next));
240
+ return ready ? 0 : 1;
241
+ }
242
+ // on a terminal: the verdict green or red, each check's mark by its state, its detail wrapped under its column
243
+ const s = styleOut();
244
+ out(s.bold(ready ? s.green(verdict) : s.red(verdict)));
245
+ const w = Math.max(...checks.map((c) => c.name.length));
246
+ for (const c of checks) {
247
+ if (run !== null && c.name === "mason start" && c.ok === true)
248
+ continue; // the verdict line said it
249
+ const mark = markOf(c, s);
250
+ out(` ${mark} ${c.name.padEnd(w)} ${wrap(c.detail, s.width, 2 + 3 + 1 + w + 2)}`);
251
+ }
252
+ if (run !== null)
253
+ out(s.dim(` its copy of the data and its state in ${run.home}. mason status for the details`));
254
+ if (run === null || !ready)
255
+ out(nextBlock(next));
256
+ return ready ? 0 : 1;
257
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * THE GUIDES `mason help` PRINTS (once `mason docs`; under `mason help` because help is the more common word): OFFLINE,
3
+ * from the CLI itself, short enough to read whole. An agent using 0.1.7 found what the CLI accepts by grepping the
4
+ * package's compiled code, because the help pointed at docs the package does not ship. So the reference is here, and
5
+ * every list in it is the code's own table (the commands from the registry, the failure codes from src/cli/command.ts):
6
+ * a guide cannot list a command or a code the CLI does not have.
7
+ *
8
+ * A guide's name is never a command's (tests/cli-help.test.ts): one word never means two pages.
9
+ */
10
+ /** The guides, by the name `mason help <guide>` takes. */
11
+ export const GUIDES = {
12
+ agent: "driving Mason from a coding agent: the commands in order, --json and its shapes, exit codes, failures",
13
+ production: "going to production: the mason-data image, its settings, Docker Compose, logical replication, upgrades",
14
+ };
15
+ /** A guide's text: built at run time from the registry's and the commands' own tables. */
16
+ export async function guideText(guide) {
17
+ if (guide === "agent")
18
+ return agentGuide();
19
+ return productionGuide();
20
+ }
21
+ /**
22
+ * THE AGENT'S PAGE (two readers: a person wants feedback and short, scannable responses; an agent wants clear,
23
+ * unambiguous context and machine-readable formats; both want the next steps): what a
24
+ * coding agent driving Mason reads first — the commands in the order a project takes them, every answer's shape, the
25
+ * exit codes and the failure codes — built from the registry and src/cli/command.ts, so it cannot list a command or a
26
+ * code the CLI does not have.
27
+ */
28
+ async function agentGuide() {
29
+ // imported here, not at the top: the registry imports this file for the HELP usage
30
+ const { STEPS } = await import("./registry.js");
31
+ const { FAILURE_CODES } = await import("./command.js");
32
+ const noJson = new Set(["help"]);
33
+ const w = Math.max(...STEPS.flatMap(([, cs]) => cs.map((c) => c.name.length))) + "mason --json".length;
34
+ const commands = STEPS.map(([step, cs]) => ` ${step}\n${cs.map((c) => ` ${`mason ${c.name}${noJson.has(c.name) ? "" : " --json"}`.padEnd(w)} ${c.summary}`).join("\n")}`).join("\n");
35
+ const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(12)} ${what}`).join("\n");
36
+ return `DRIVING MASON FROM A CODING AGENT — the same commands a person runs, answered as data
37
+
38
+ Every command prints for a person, or with --json one JSON object for a program. One prints text alone: mason help
39
+ (this reference); mason logs --json is the log's lines, or with --requests the request log. Nothing a command prints
40
+ refers to a file this package does not ship.
41
+
42
+ THE COMMANDS, in the order a project takes them (mason --help lists the same):
43
+ ${commands}
44
+
45
+ EVERY ANSWER is an object with next: [{ command, what }] — the commands to run next, the same a person reads under
46
+ Next. The other keys are each command's; its help names them (mason help <command>, the --json line). Lists are wrapped
47
+ ({ tables: […], next }), never bare. mason wait --json blocks until every added table is copied and kept current, then
48
+ answers once (exit 0), or with error beside the state (exit 1 failed, 124 timed out: nothing stopped, run it again).
49
+
50
+ A FAILURE is { "error": { "code", "message", "why", "fix" }, "next": […] } on stdout ("why" and "fix" where there is
51
+ more to say than the message), and the exit code says it — the same code exits the same wherever it is found:
52
+ 0 done
53
+ 1 failed, with the reason
54
+ 2 the command line is wrong (without --json the usage follows)
55
+ 124 ran out of time: mason wait
56
+ The codes:
57
+ ${codes}
58
+
59
+ READING THE OUTPUT: the answer is on stdout, progress on stderr (mason wait's bars; nothing under --json). Colour,
60
+ wrapping and the spinner are a terminal's alone: a pipe gets plain text, and NO_COLOR turns colour off anywhere.
61
+ A Postgres tool (psql, a JDBC or ODBC driver, Metabase, Grafana) connects with what mason user add <name> --json
62
+ answers: url, host, port, database, user, password (printed once; it does not expire) — each added table a table.
63
+ Where the Postgres API listens is in mason status --json (postgres.url, host, port); never read .mason/config.json
64
+ for it. A failure that is a bug in mason says so; MASON_DEBUG=1 adds where it happened.
65
+
66
+ THE SOURCES FILE, mason/sources/<name>.yml (mason init writes it; one per project for now), is what Mason copies:
67
+ kind: postgres
68
+ url: \${DATABASE_URL} a variable from .env.local, .env or the shell; a literal URL is allowed for local dev
69
+ tables:
70
+ orders: a bare name is in public; billing.invoices names its schema
71
+ columns: [id, status] these columns (the primary key is always copied)
72
+ customers: {} every column
73
+ events:
74
+ exclude: [raw_body] every column but these
75
+ sessions:
76
+ key: [tenant_id, id] only for a table with no primary key: the unique columns to copy it by (polling only)
77
+ mason add and mason remove edit one entry of it, keep the rest as it was, and take effect at once; an edit by hand is
78
+ read at the next mason start (or the next add or remove). Column types are read from the database at every start.
79
+
80
+ A FIRST SESSION, as a shell runs it:
81
+ mason doctor --json && mason init --json && mason start --json
82
+ mason tables --json choose the tables (rows, key, whether one can be added)
83
+ mason add orders customers --json && mason wait --json
84
+ mason user add my-tool --json the URL and password a tool connects with
85
+ mason status --json each table's copy, and the last day's slow and refused statements
86
+ `;
87
+ }
88
+ const REPO = "https://github.com/embeddable-hq/mason-data/blob/main";
89
+ /**
90
+ * `mason help production` (the journey from running it locally to deploying it, on the mason-data image): the
91
+ * README's "On a server", offline and short — what travels, what the server needs, how it runs, how it is upgraded.
92
+ */
93
+ export function productionGuide() {
94
+ return `GOING TO PRODUCTION — the project folder (mason/sources/, mason/mason.yml) is all that travels: its sources file
95
+ names the tables the server copies, and its url: names the variable that holds the database's URL. Nothing in .mason/ goes with it; the server makes its own copy and state on its volume.
96
+
97
+ 1. PREPARE THE DATABASE. Mason only reads it. Polling (the default) needs a user that can SELECT the tables, and costs
98
+ the database a scan of each table per poll; it works on a read replica. Logical replication (MASON_CHANGES=replication)
99
+ needs wal_level = logical, a user with REPLICATION, and either a publication you name (PG_PUBLICATION, read as it is,
100
+ never altered) or the right to create one; the slot holds the database's WAL while Mason is down, so cap it with
101
+ max_slot_wal_keep_size. Try it locally first:
102
+ mason start --replication the change log, as the server reads it
103
+ mason start --replication --publication app_pub through a publication the database's owner made
104
+
105
+ 2. RUN IT: one container, ghcr.io/embeddable-hq/mason-data:<version> (the version mason.yml names), running mason serve:
106
+ DATABASE_URL the database to copy from (the variable the sources file's url: names)
107
+ MASON_API_KEY what the users' passwords are made with (openssl rand -hex 24)
108
+ MASON_PROJECT your mason/ folder, mounted (default ./mason)
109
+ MASON_DATA the volume whose .mason/ holds the copy and the state (default .)
110
+ MASON_PG_HOST=0.0.0.0 and TLS: MASON_PG_TLS_CERT / MASON_PG_TLS_KEY, or MASON_PG_SELF_SIGNED_TLS=1, or
111
+ MASON_PG_PLAIN=1 behind a TLS proxy of your own
112
+ Docker Compose: ${REPO}/deploy/mason-data/README.md. One container per volume, never two: DuckDB lets one process
113
+ write its file, so run one replica, with no autoscaling, and stop the old container before the new one starts.
114
+ GET /healthz on MASON_HEALTH_PORT (4140) answers 503 while it starts and 200 once it serves; the first start takes
115
+ about 15 seconds before that, then copies the tables.
116
+
117
+ 3. CONNECT THE TOOLS: beside the server (docker compose exec mason …), a user is a password a tool keeps:
118
+ mason user add metabase its URL and password, printed once
119
+ mason user list
120
+ mason user remove metabase ends it at once, without a restart
121
+ The tool connects to the Postgres API (5433) as to a read replica, each added table a table. Changing
122
+ MASON_API_KEY removes every user.
123
+
124
+ 4. CHANGE IT: ship mason/ with your release and restart the container; a table added or removed is copied or dropped
125
+ at the next start, or beside the server:
126
+ mason add public.orders copied at once, added to the mounted mason/sources/
127
+ mason remove orders taken out of the sources file, its copy dropped
128
+ mason status each table's copy, the slow and refused statements
129
+ Upgrade: read the release notes, then move mason.yml and the image together. Removing Mason for good in replication mode:
130
+ SELECT pg_drop_replication_slot('mason_dev_<name>_<source>') — the slot holds your WAL (mason status names it).
131
+
132
+ The whole account: ${REPO}/README.md
133
+ `;
134
+ }