@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,1145 @@
1
+ /**
2
+ * THE COMMANDS BESIDE `mason start` (an interface as good for people as for coding agents).
3
+ * Each one finds the run it means (src/cli/runs.ts), asks its command server (src/cli/control.ts), and prints
4
+ * it for a person — or, with `--json`, as data a program reads. Exit codes are part of each command's contract:
5
+ *
6
+ * mason status [--json] what the run is doing: its database, its tables and their copies, the Postgres API
7
+ * mason tables [<table>…] [--json] the database's tables, their columns, keys and foreign keys
8
+ * mason add <table>… [--columns …] copy tables into Mason: every column, or the ones --columns names
9
+ * mason remove <table>… take tables out of the sources file; their copies are dropped
10
+ * mason wait [--timeout <s>] [--json] until every added table is copied: 0 ready, 1 a copy failed (said), 124 timed out
11
+ * mason logs [-n <lines>] [-f] [--start] [--requests] the run's log; --start, the start's terminal; --requests, its request log
12
+ * mason stop stop Mason for this folder's project (the copy stays)
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { endText, errorJson, failed, jsonText, nextBlock, parseCommand, reportText, withEnd } from "./command.js";
17
+ import { err as styleErr, out as styleOut, stepRenderer, wrap } from "./style.js";
18
+ import { alive, endRun, END_GRACE_MS, theRun } from "./runs.js";
19
+ import { version } from "./version.js";
20
+ import { readRestarts, signalSupervisor, stopSupervisor, supervisorGone } from "./supervise.js";
21
+ import { DUCK_FILE, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
22
+ import { callRun, RunUnreachable } from "./control.js";
23
+ import { withPlaceholders, postgresLink } from "./pg-url.js";
24
+ import { listenerText } from "./pg-tls.js";
25
+ import { SLOW_MS } from "./run-server.js";
26
+ import { catalogTable, entryName, looksSecret, tableNameProblem } from "./sources.js";
27
+ import { connectSource, maskedUrl, parseSource, readSourceFacts } from "./preflight.js";
28
+ import { reach } from "./state.js";
29
+ import { copyingText, copyProgress, duration, readiness } from "./readiness.js";
30
+ import { pathText } from "../duck/kept-route.js";
31
+ import { countsText } from "../duck/stream.js";
32
+ const out = (m) => { process.stdout.write(`${m}\n`); };
33
+ const err = (m) => { process.stderr.write(`${m}\n`); };
34
+ // ------------------------------------------------------------------ shared
35
+ /** The run a command that needs one works with (src/cli/state.ts reach), or null once the state's failure is said. */
36
+ async function runOr(spec, json) {
37
+ const w = await reach(spec, json);
38
+ return w !== null && "run" in w ? w.run : null;
39
+ }
40
+ export async function readState(run) {
41
+ const r = await callRun(run, "GET", "/api/state");
42
+ if (r.status !== 200)
43
+ throw new RunUnreachable(String(r.body.error ?? `HTTP ${r.status}`));
44
+ return r.body;
45
+ }
46
+ /** A command body that may fail to reach the run: the failure said (JSON under --json), exit 1. */
47
+ export async function guarded(cmd, json, f) {
48
+ try {
49
+ return await f();
50
+ }
51
+ catch (e) {
52
+ if (e instanceof RunUnreachable)
53
+ return failed(cmd, e.failure, { json });
54
+ throw e;
55
+ }
56
+ }
57
+ const n = (x) => x.toLocaleString("en-US");
58
+ /** A list of sentences, each a bullet; a sentence's own further lines indented under it. */
59
+ const bullets = (xs) => xs.map((x) => ` - ${x.split("\n").join("\n ")}`).join("\n");
60
+ const firstLine = (s) => s.split("\n")[0].trim();
61
+ /** A table as the commands name it: `orders` (in public), or `schema.table` — never a third part (tableNameProblem). */
62
+ export function findTable(tables, name) {
63
+ const malformed = tableNameProblem(name, tables.map((t) => t.table));
64
+ if (malformed !== null)
65
+ return { message: malformed, code: "usage" };
66
+ const exact = tables.find((t) => t.table === name) ?? tables.find((t) => t.table === `public.${name}`);
67
+ if (exact)
68
+ return exact;
69
+ const byTable = tables.filter((t) => t.table.split(".").slice(1).join(".") === name);
70
+ if (byTable.length === 1)
71
+ return byTable[0];
72
+ if (byTable.length > 1)
73
+ return { message: `${name} is in several schemas (${byTable.map((t) => t.table).join(", ")})`, code: "not_found", fix: "name it with its schema: schema.table" };
74
+ return { message: `the database has no table ${JSON.stringify(name)} this user can read`, code: "not_found", next: [["mason tables", "the tables it has, as this user reads them"]] };
75
+ }
76
+ /** A table as `mason add` and `mason tables` take it: its name alone in the public schema, else with its schema. */
77
+ export const tableArg = entryName;
78
+ /** What to run once every added table is copied: a user for the tool that will query it (src/cli/user.ts). */
79
+ export const READY_STEPS = [
80
+ ["mason user add <name>", "a user for your BI tool or psql: it prints the password and the URL to paste"],
81
+ ["mason status", "each table's copy, and the statements the Postgres API answered slowly or refused"],
82
+ ];
83
+ /** What to run next over a database's tables: a table's columns, then adding it — named after a real table not yet added.
84
+ * `from: "tables"` is mason tables itself asking: it never points at itself. */
85
+ export function addSteps(tables, from = null) {
86
+ const one = tables.find((t) => !t.added && t.reason === null);
87
+ if (!one)
88
+ return [...READY_STEPS];
89
+ const name = tableArg(one.table);
90
+ // every table first: which to add is the first question (so mason status's Next includes mason tables)
91
+ return [
92
+ ...(from === "tables" ? [] : [["mason tables", "the database's tables: rows, key, whether added"]]),
93
+ [`mason tables ${name}`, "one table's columns"],
94
+ [`mason add ${name}`, "copy it into Mason: it is added to the sources file (--columns a,b,c: those only)"],
95
+ ];
96
+ }
97
+ // ------------------------------------------------------------------ readiness (mason wait, mason status): src/cli/readiness.ts
98
+ export { copyingText, copyProgress, duration, readiness };
99
+ /** A table line's bar in its state's colour (on a terminal): green copied, red failed, yellow served but not kept current, cyan at work. */
100
+ export function paintBar(line, s) {
101
+ const colour = / its copy failed: /.test(line) ? s.red : / NOT kept current /.test(line) ? s.yellow : / 100% [\d,]+ rows · copied( · |$)/.test(line) ? s.green : s.cyan;
102
+ return line.replace(/(█+)(░*)|(░+)/, (_m, full, empty, none) => full !== undefined ? `${colour(full)}${s.dim(empty ?? "")}` : s.dim(none ?? ""));
103
+ }
104
+ /** The width of a table's progress bar, in characters. */
105
+ const BAR = 20;
106
+ const bar = (fraction) => { const full = Math.round(Math.max(0, Math.min(1, fraction)) * BAR); return "█".repeat(full) + "░".repeat(BAR - full); };
107
+ /**
108
+ * EVERY ADDED TABLE ON A LINE OF ITS OWN (after `mason add a b c` where a and c copy quickly, `mason wait` showed only
109
+ * b; it shows a line for each table, 100% for those up to date and a progress bar for those still at work, in order of
110
+ * last processed, so the ones at work are at the bottom): its bar, its percent and what it is doing. The finished
111
+ * first, in the order they finished (`copy.updatedAt`: a finished copy's state is written once, when it finishes), then
112
+ * the failed, the copying and the waiting.
113
+ */
114
+ export function tableLines(s) {
115
+ const picked = s.tables.filter((t) => t.added);
116
+ // 0 done, 1 failed, 2 copying, 3 waiting for its copy
117
+ const rank = (t) => t.error ? 1 : t.copy?.phase === "copying" ? 2 : !t.copy ? 3 : 0;
118
+ const rows = picked.map((t) => {
119
+ const r = rank(t);
120
+ let fraction, what;
121
+ // a table over the poll's row limit is served as copied, and said so on its line (src/duck/sync.ts POLL_ROW_LIMIT)
122
+ if (r === 0 && t.unsynced) {
123
+ fraction = 1;
124
+ what = `${n(t.copy?.rowsCopied ?? 0)} rows · copied, NOT kept current (${t.unsynced.reason}${t.unsynced.asOf ? `; as of ${t.unsynced.asOf.slice(0, 16).replace("T", " ")} UTC` : ""}) — mason sync ${tableArg(t.table)}, or mason start --replication`;
125
+ }
126
+ else if (r === 0) {
127
+ fraction = 1;
128
+ what = `${n(t.copy?.rowsCopied ?? 0)} rows · copied${t.copy?.retired ? ` · ${t.copy.retired}` : ""}`;
129
+ }
130
+ else if (r === 1) {
131
+ fraction = 1;
132
+ what = `its copy failed: ${firstLine(t.error)}`;
133
+ }
134
+ else if (r === 2) {
135
+ const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
136
+ fraction = c.fraction ?? 0;
137
+ // a count says it is copying; "reading the first chunk" says so itself
138
+ what = /^\d/.test(c.text) ? `copying ${c.text}` : c.text;
139
+ }
140
+ else {
141
+ fraction = 0;
142
+ what = "waiting for its copy to start";
143
+ }
144
+ return { t, r, at: t.copy?.updatedAt ?? "", line: `${bar(fraction)} ${`${Math.floor(fraction * 100)}%`.padStart(4)} ${what}` };
145
+ });
146
+ rows.sort((a, b) => a.r - b.r || a.at.localeCompare(b.at) || a.t.table.localeCompare(b.t.table));
147
+ const w = Math.max(0, ...rows.map((x) => x.t.table.length));
148
+ return rows.map((x) => `${x.t.table.padEnd(w)} ${x.line}`);
149
+ }
150
+ /** A span of seconds as the stream's lines say it: `0.3 s` under ten seconds, then as a wait reads (`duration`). */
151
+ const spanText = (seconds) => seconds < 10 ? `${Math.max(0, seconds).toFixed(1)} s` : duration(seconds);
152
+ /**
153
+ * WHERE THE STREAM IS, a thing a line (the first replication run on a server showed nothing of it in 35
154
+ * minutes): its position against the database's and the bytes between, the newest change applied and how long after
155
+ * its commit, then every added table — streamed with what it applied this run, being caught up (from when, doing what),
156
+ * not streamed yet, or left out — and why. `mason status` prints it under "Stream", `mason wait` under its tables;
157
+ * `--json` is the `StreamStatus` itself.
158
+ */
159
+ export function streamLines(st, now = Date.now()) {
160
+ const rows = [];
161
+ const since = (iso) => (now - Date.parse(iso)) / 1000;
162
+ if (!st.running) {
163
+ rows.push(["stream", st.failure === null ? "not running yet: it starts once the slot is made"
164
+ : `not running: ${st.failure.error.split("\n")[0]} · it starts again in ${spanText(Math.max(0, -since(st.failure.retryAt)))}`]);
165
+ }
166
+ const db = "lsn" in st.database
167
+ ? `the database is at ${st.database.lsn}: ${n(st.lag.bytes ?? 0)} bytes behind (read ${spanText(since(st.database.at))} ago)`
168
+ : `the database's position could not be read: ${st.database.error.split("\n")[0]}`;
169
+ rows.push(["position", `${st.position} · ${db}`]);
170
+ rows.push(["last change", st.lastBatch === null ? "none applied this run"
171
+ : `committed ${spanText(since(st.lastBatch.committedAt))} ago, applied ${spanText((Date.parse(st.lastBatch.appliedAt) - Date.parse(st.lastBatch.committedAt)) / 1000)} after its commit`]);
172
+ const total = (t) => t.applied.inserts + t.applied.updates + t.applied.deletes + t.applied.truncates;
173
+ for (const t of st.tables) {
174
+ const counts = ` · ${countsText(t.applied)} this run`;
175
+ const c = t.catchUp;
176
+ rows.push([t.table, t.state === "streamed" ? `streamed${counts}`
177
+ : t.state === "catching-up" && c !== undefined
178
+ ? `catching up by one poll ${c.from ? `from ${c.from.slice(0, 16).replace("T", " ")} UTC` : t.why?.replace(/^caught up by one poll /, "") ?? ""}: ` +
179
+ `${c.phase === "queued" ? (c.retryAt ? `its last catch-up failed, tried again after ${c.retryAt.slice(11, 16)} UTC (mason logs says why)` : "waiting its turn") : c.phase === "polling" ? "polling" : "applying what the stream held meanwhile"}` +
180
+ `${c.startedAt ? `, for ${spanText(since(c.startedAt))}` : ""} · the stream holds ${n(c.held)} change${c.held === 1 ? "" : "s"} of it meanwhile${total(t) ? counts : ""}`
181
+ : t.state === "left-out" ? `left out: ${t.why ?? ""}`
182
+ : `not streamed yet: ${t.why ?? ""}${total(t) ? counts : ""}`]);
183
+ }
184
+ const w = Math.max(...rows.map(([k]) => k.length));
185
+ return rows.map(([k, v]) => `${k.padEnd(w)} ${v}`);
186
+ }
187
+ // ------------------------------------------------------------------ mason status
188
+ const STATUS_USAGE = `usage: mason status [--json]
189
+
190
+ Status: running, not running, or hung — then this project's facts, one a line: its folder, its database and how
191
+ its changes are read, the run and its copy's file, the Postgres API and how it listens. Then each added table as
192
+ mason wait shows it; the statements the Postgres API answered slowly in the last day; and the ones it refused or
193
+ failed, each with the reason (mason logs --requests lists every one). With Mason stopped, the same rows say "not
194
+ running", how the last run ended, and the tables are the ones the sources file names. Hung is a run that is alive and does
195
+ not answer its commands (a statement may be holding its main thread): the same rows, and mason stop ends it (exit 1).
196
+
197
+ mason status
198
+ mason status --json
199
+
200
+ --json as data: { running, pid, postgres: { url, host, port, database, sslmode (require when it is TLS), listener:
201
+ { host (what it is bound to), tls (certificate, self-signed or null), fingerprint } }, source: { name (the
202
+ source's: its sources file's), file (the sources file), url, database, remote, version, standby, changes,
203
+ replication }, warnings, folder, project, tables: [{ table, rows, bytes, key, added, addedColumns, copy, error,
204
+ … }], engine: { file, bytes, pollSeconds, restarts (each time its supervisor started the
205
+ run again: { at, reason, upSeconds }) }, slowQueries, refusedQueries, refusedTotal (how many in all),
206
+ readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), next }; with Mason stopped: { running: false, project, source: { name,
207
+ file, url, error (the sources file's problem, or null) }, engine, tables: [{ table }], lastRun, lastStop:
208
+ { at, how } or null, next }; hung: the same with hung: { pid, problem, why },
209
+ exit 1. In replication mode stream: { slot, publication, running, failure, position,
210
+ applied, database: { lsn, at } or { error }, lag: { bytes, seconds }, lastBatch: { appliedAt, committedAt },
211
+ tables: [{ table, state (streamed, catching-up, waiting, left-out), why, catchUp: { phase (queued, polling,
212
+ applying), from, startedAt, held, retryAt }, applied: { inserts, updates, deletes, truncates } }] }.
213
+ jsonPaths: [{ table, column, steps, either, canonical, asText (false: a path's -> and ->> share it), name,
214
+ state (waiting, backfilling, ready, dropped), rowsTotal, rowsDone, marked, statements, slowestMs, totalMs,
215
+ fill: { rows, bytesPerRow, seconds, basis (measured: from the last fill of its column; disk or text: modelled from
216
+ the column's bytes a row on disk, or its text's before it is on disk), rowsPerSecond } (the fill's estimate it
217
+ was decided on), filled: { rows, seconds } (what its own fill measured), queued (a backfill waiting for the fill
218
+ under way), keptAt, readyAt, why }]
219
+
220
+ In replication mode a Stream section says where the stream is: its position against the database's (bytes behind),
221
+ when the newest change applied was committed and how long after it was applied, and each added table — streamed,
222
+ with the changes applied this run; being caught up by one poll; not streamed yet; or left out — and why.
223
+
224
+ Stored JSON paths: a JSON value statements keep reading (payload ->> 'customer_id' read by several statements, or by
225
+ one slow one) is stored as a column of the copy and read from there, computed once when its row is written instead
226
+ of for every row of every statement; on a table whose fill would take a minute or more, only once the statements
227
+ reading it took a tenth of the fill's estimate in all. The estimate is the rate the last fill of the same JSON
228
+ column measured, once one has run, else modelled from the column's bytes on disk; the line says which. Each path is
229
+ waiting (its fill's cost holds it back: the line says the estimate and what would store it), queued (its column
230
+ added, its fill after the one under way), backfilling (its existing rows being filled, N %) or ready (read from its
231
+ column). MASON_STORE_JSON_PATHS=off turns it off.
232
+ `;
233
+ export const STATUS = {
234
+ needs: "project",
235
+ name: "status",
236
+ summary: "what Mason is doing: the database, each table's copy, the Postgres API, the slow and refused statements",
237
+ usage: STATUS_USAGE,
238
+ options: { json: { type: "boolean" } },
239
+ json: ["running", "pid", "postgres", "source", "warnings", "folder", "project", "tables", "engine", "stream", "jsonPaths", "slowQueries", "refusedQueries", "refusedTotal", "readiness", "ui",
240
+ "lastRun", "lastStop", "hung", "next"],
241
+ };
242
+ export async function status(argv) {
243
+ const f = parseCommand(STATUS, argv);
244
+ if (typeof f === "number")
245
+ return f;
246
+ const r0 = await theRun(process.cwd());
247
+ // the first line goes straight to the point (Status: running); when the service is not running, the same rows and
248
+ // tables show, with "not running" for each service that is not
249
+ const json = f.values.json === true;
250
+ if ("problem" in r0) {
251
+ if (!r0.project)
252
+ return failed("status", r0, { json });
253
+ return stoppedStatus(r0.project, json, r0.hung === undefined ? null : { run: r0.hung, problem: r0 });
254
+ }
255
+ const run = r0.run;
256
+ return guarded("status", json, async () => {
257
+ const st = styleOut();
258
+ const s = await readState(run);
259
+ const r = readiness(s);
260
+ // the statements the Postgres API answered slowly, and the ones it refused, with the reason (src/duck/requests.ts)
261
+ const slowReply = await callRun(run, "GET", "/api/slow");
262
+ const slow = slowReply.status === 200 ? slowReply.body.slow : [];
263
+ const refusedReply = await callRun(run, "GET", "/api/refused");
264
+ const refused = refusedReply.status === 200 ? refusedReply.body.refused : [];
265
+ const refusedTotal = typeof refusedReply.body.total === "number" ? refusedReply.body.total : null;
266
+ const picked = s.tables.filter((t) => t.added);
267
+ // what to run next, the same for a person and under --json
268
+ const next = !picked.length ? addSteps(s.tables)
269
+ : r.ready ? [...READY_STEPS]
270
+ : r.failed.length ? [["mason logs", "the detail of each failure"], ["mason wait", "each failure, with the way out"]]
271
+ : [["mason wait", "until it is done"]];
272
+ // the times its supervisor started it again (src/cli/supervise.ts): a run that crashed says so, never only in a log
273
+ const restarts = restartsOf(run.home);
274
+ if (json) {
275
+ out(jsonText(STATUS, { running: true, pid: run.pid, postgres: { ...postgresLink(run.postgresUrl), listener: run.postgresListener },
276
+ ...s, engine: { ...s.engine, restarts }, slowQueries: slow, refusedQueries: refused, refusedTotal, readiness: r }, next));
277
+ return 0;
278
+ }
279
+ const rows = [
280
+ ["Project", `${run.name}, files in ${s.folder ?? "(none chosen)"}`],
281
+ ...sourceRows(s),
282
+ ["Mason", `${version()}, pid ${run.pid} · one process`],
283
+ ["Copy", `${s.engine.file}${s.engine.bytes === null ? "" : ` · ${fileSize(s.engine.bytes)}`}`],
284
+ ...restartsRows(restarts),
285
+ ["Postgres API", `${withPlaceholders(run.postgresUrl)} (mason user add <name> makes a user)`],
286
+ // what it serves: its address, TLS or plain (src/cli/pg-tls.ts)
287
+ ["Listener", listenerText(run.postgresListener, postgresLink(run.postgresUrl).port)],
288
+ // the web page (mason ui), without its one-time token: mason ui prints the URL that carries it, and opens it
289
+ ...(s.ui === true ? [["Web UI", `${run.control.url}/ (mason ui opens it)`]] : []),
290
+ ...s.warnings.map((w) => ["Warning", w]),
291
+ ];
292
+ out(reportText({ headline: `Status: ${st.green("running")}`, details: rows }, st));
293
+ out("");
294
+ if (!picked.length) {
295
+ out(withEnd(`No table added yet (${s.tables.length} in the database).`, { next }));
296
+ return 0;
297
+ }
298
+ if (s.stream) {
299
+ out(st.bold(`Stream (slot ${s.stream.slot}):`));
300
+ for (const l of streamLines(s.stream))
301
+ out(` ${l}`);
302
+ out("");
303
+ }
304
+ out(st.bold(`Tables (${picked.length} of ${s.tables.length} added):`));
305
+ for (const l of tableLines(s))
306
+ out(` ${paintBar(l, st)}`);
307
+ for (const l of jsonPathLines(s.jsonPaths ?? [], st))
308
+ out(l);
309
+ for (const l of slowLines(slowReply.status === 200 ? slow : { error: String(slowReply.body.error ?? slowReply.status) }, st))
310
+ out(l);
311
+ for (const l of refusedLines(refusedReply.status === 200 ? { refused, total: refusedTotal } : { error: String(refusedReply.body.error ?? refusedReply.status) }, st))
312
+ out(l);
313
+ out("");
314
+ if (r.ready)
315
+ out(withEnd(st.green("Ready: every added table is copied and served."), { next }));
316
+ else if (r.failed.length)
317
+ out(withEnd(`${st.red("Failed:")}\n${bullets(r.failed)}`, { next }));
318
+ else
319
+ out(withEnd(`Under way:\n${r.waiting.map((x) => ` - ${x}`).join("\n")}`, { next }));
320
+ return 0;
321
+ });
322
+ }
323
+ /**
324
+ * mason status's rows for the database the run copies: its sources file, its URL (masked by the run), and how its changes
325
+ * arrive. The web page (src/web/app.js `sourceRows`) says the same rows, held equal by tests/web-page.test.ts.
326
+ */
327
+ export function sourceRows(s) {
328
+ return [
329
+ ...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
330
+ ["Database", `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
331
+ ["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
332
+ `(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
333
+ : `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · mason start --replication reads the database's change log instead`],
334
+ ];
335
+ }
336
+ /** An SQL statement on one line, cut at `max` characters (the whole of it is mason logs --requests --json's). */
337
+ export function oneLine(sql, max = 100) {
338
+ const line = sql.replace(/\s+/g, " ").trim();
339
+ return line.length <= max ? line : `${line.slice(0, max - 1)}…`;
340
+ }
341
+ /**
342
+ * mason status's stored JSON paths (src/duck/kept.ts): each one being filled or stored, by table, with why it was
343
+ * stored; the dropped ones counted (mason status --json says why each went).
344
+ */
345
+ export function jsonPathLines(paths, st) {
346
+ const live = paths.filter((p) => p.state !== "dropped").sort((a, b) => Number(a.state === "waiting") - Number(b.state === "waiting"));
347
+ const dropped = paths.length - live.length;
348
+ if (!live.length && !dropped)
349
+ return [];
350
+ const lines = [`\n${st.bold("Stored JSON paths")} ${st.dim("(read from a column of the copy, computed once per row written; MASON_STORE_JSON_PATHS=off turns it off)")}:`];
351
+ for (const p of live) {
352
+ const pct = p.rowsTotal > 0 ? Math.min(99, Math.floor((p.rowsDone / p.rowsTotal) * 100)) : 0;
353
+ // a path its fill's cost holds back says so: its why is the decision, with the estimate
354
+ const state = p.state === "ready" ? st.green("ready") : p.state === "waiting" ? st.yellow("waiting")
355
+ : p.queued === true ? `queued, filled after the fill under way (${n(p.rowsTotal)} rows)` : `backfilling ${pct} % (${n(p.rowsDone)} of ${n(p.rowsTotal)} rows)`;
356
+ lines.push(` ${p.table} ${pathText(p)} ${state} ${st.dim(p.why)}`);
357
+ }
358
+ if (dropped)
359
+ lines.push(` ${st.dim(`${dropped} dropped (mason status --json says why)`)}`);
360
+ return lines;
361
+ }
362
+ /** mason status's slow statements (src/duck/requests.ts slow), each on one line with its times. */
363
+ export function slowLines(slow, st) {
364
+ if (!Array.isArray(slow))
365
+ return [`\n${st.bold("Slow queries")}: could not read them (${firstLine(slow.error)}; mason logs has the detail)`];
366
+ if (!slow.length)
367
+ return [];
368
+ const lines = [`\n${st.bold("Slow queries")} ${st.dim(`(the last day, still ${(SLOW_MS / 1000).toLocaleString("en-US")} s or more; slowest first)`)}:`];
369
+ for (const q of slow) {
370
+ const times = `${n(q.latestMs)} ms last time (${q.runs} run${q.runs === 1 ? "" : "s"}, median ${n(q.medianMs)} ms)`;
371
+ lines.push(` ${times}, last ${q.last.slice(0, 16).replace("T", " ")} UTC`, ` ${oneLine(q.payload)}`);
372
+ }
373
+ return lines;
374
+ }
375
+ /**
376
+ * mason status's refused statements: each distinct one once, newest first, with its SQLSTATE, how often, when last, and
377
+ * the message's first line (the whole of it is mason logs --requests --failed's). `total`: how many in all.
378
+ */
379
+ export function refusedLines(r, st) {
380
+ if ("error" in r)
381
+ return [`\n${st.bold("Refused queries")}: could not read them (${firstLine(r.error)}; mason logs has the detail)`];
382
+ if (!r.refused.length)
383
+ return [];
384
+ const lines = [`\n${st.bold("Refused queries")} ${st.dim(`(the last day${r.total === null ? "" : `: ${n(r.total)} in all`}, newest first; each message says what to change; mason logs --requests --failed lists each)`)}:`];
385
+ for (const q of r.refused) {
386
+ lines.push(` ${q.code}, ${q.runs} time${q.runs === 1 ? "" : "s"}, last ${q.last.slice(0, 16).replace("T", " ")} — ${oneLine(q.payload)}`);
387
+ lines.push(` ${firstLine(q.message)}`);
388
+ }
389
+ return lines;
390
+ }
391
+ /** A file's size, as a person reads one */
392
+ function fileSize(b) {
393
+ return b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)} GiB` : b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(1)} MiB` : `${(b / 1024).toFixed(1)} KiB`;
394
+ }
395
+ /** The restarts recorded for a run, or why they could not be read (a file nobody can read is said, never taken as none). */
396
+ function restartsOf(home) {
397
+ try {
398
+ return readRestarts(home);
399
+ }
400
+ catch (e) {
401
+ return { error: `${path.join(home, "restarts.json")} could not be read: ${e.message}` };
402
+ }
403
+ }
404
+ /** `Restarts` in mason status: how many times the run was started again, and the last one's reason (none: no row) */
405
+ function restartsRows(r) {
406
+ if (!Array.isArray(r))
407
+ return [["Restarts", r.error]];
408
+ const last = r[r.length - 1];
409
+ if (last === undefined)
410
+ return [];
411
+ return [["Restarts", `${r.length}, the last ${ago(last.at)}: ${last.reason}, after ${last.upSeconds} s up (mason logs --start has its last lines)`]];
412
+ }
413
+ /**
414
+ * mason status with Mason stopped: the same rows and tables, "not running" where something is not. The
415
+ * database is the sources file's, unchecked; the tables are the ones it names, as nothing reads their copies now — or, when
416
+ * the file cannot be read, what is wrong with it, with the way out (`source.error`).
417
+ * With a run HUNG (alive, a Mason run, silent; src/cli/runs.ts), the same rows say so — never "not running", which is
418
+ * what the test server read while its ports were held — the next step is mason stop, and the exit is 1.
419
+ */
420
+ function stoppedStatus(project, json, hung = null) {
421
+ const st = styleOut();
422
+ const src = projectSource(project);
423
+ const tables = "message" in src ? [] : src.tables.map((e) => e.table);
424
+ const database = "message" in src ? null : maskedUrl(src.resolved);
425
+ const duckFile = path.join(project.dir, DUCK_FILE);
426
+ // the copy's file, when a run has made one: its size says what the next mason start resumes with
427
+ const bytes = fs.existsSync(duckFile) ? fs.statSync(duckFile).size : null;
428
+ const restarts = restartsOf(project.dir);
429
+ const lastStop = project.config.lastStop ?? null;
430
+ const next = hung === null ? ("message" in src && src.next?.length ? src.next : [["mason start", "start Mason for this project: what it copied is kept, and polled again"]])
431
+ : hung.problem.next ?? [["mason stop", "end it"], ["mason logs", "what it was doing"]];
432
+ const state = hung === null ? "not running" : "hung";
433
+ if (json) {
434
+ out(jsonText(STATUS, { running: false, ...(hung === null ? {} : { hung: { pid: hung.run.pid, problem: hung.problem.problem, why: hung.problem.why } }),
435
+ project: { name: project.config.name, folder: project.folder },
436
+ source: { name: "message" in src ? null : src.name, file: "message" in src ? null : src.file, url: database, error: "message" in src ? errorJson(src) : null },
437
+ engine: { name: "duckdb", file: duckFile, bytes, restarts }, tables: tables.map((table) => ({ table })),
438
+ lastRun: project.config.lastRun ?? null, lastStop }, next));
439
+ return hung === null ? 0 : 1;
440
+ }
441
+ const ended = lastStop === null ? "" : `; ${ago(lastStop.at)}: ${lastStop.how}`;
442
+ out(reportText({ headline: `Status: ${hung === null ? st.yellow(state) : st.red(state)}`, details: [
443
+ ["Project", `${project.config.name}, files in ${project.folder}`],
444
+ ["Source", "message" in src ? `${src.message}${src.fix ? ` — ${src.fix.split("\n")[0]}` : ""}` : src.file],
445
+ ...(database === null ? [] : [["Database", `${database} (not checked: Mason ${hung === null ? "is not running" : "does not answer"})`]]),
446
+ ["Mason", hung === null ? `${version()} · not running${project.config.lastRun ? ` (last started ${ago(project.config.lastRun)}${ended})` : ""}`
447
+ : `${version()}, pid ${hung.run.pid} · running but does not answer its commands: ${hung.problem.why}`],
448
+ ["Copy", bytes === null ? "none yet (mason start makes it)" : `${duckFile} · ${fileSize(bytes)}`],
449
+ ...restartsRows(restarts),
450
+ ["Postgres API", hung === null ? "not running" : `${withPlaceholders(hung.run.postgresUrl)} (held by the hung run; it may not answer)`],
451
+ ] }, st));
452
+ out("");
453
+ if (!tables.length)
454
+ out("No table added yet.");
455
+ else {
456
+ out(st.bold(`Tables (${tables.length} added):`));
457
+ const w = Math.max(...tables.map((m) => m.length));
458
+ for (const m of tables)
459
+ out(` ${m.padEnd(w)} ${st.dim(`${"░".repeat(20)} — ${state === "hung" ? "not checked: Mason does not answer" : "not running"}`)}`);
460
+ }
461
+ out(endText({ tips: [], next }, st));
462
+ return hung === null ? 0 : 1;
463
+ }
464
+ function ago(iso) {
465
+ const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
466
+ return s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`;
467
+ }
468
+ // ------------------------------------------------------------------ mason tables
469
+ const TABLES_USAGE = `usage: mason tables [<table>…] [--all] [--json]
470
+
471
+ The tables of the database that this user can read, one line each: rows, primary key, whether it is added, and why
472
+ one cannot be added as it is (no primary key: --key names the columns). Name a table for its columns —
473
+ each one's type, what it references, and whether mason add would leave it out as a secret. It reads the project's
474
+ database itself: Mason need not be running.
475
+
476
+ mason tables every table, one line each
477
+ mason tables orders public.customers those tables' columns (a table's name alone, or schema.table)
478
+ mason tables --all every table with its columns, at once (long)
479
+
480
+ --all every table with its columns, not only the named ones
481
+ --json as data: { database, source: { name (the source's: its sources file's), file (the sources file) },
482
+ tables: [{ table, rows, bytes, primaryKey, comment, addable,
483
+ reason, added, addedColumns (the columns its copy keeps: null when not added), copyKey (the key: the sources
484
+ file copies it by, null: its primary key), columns: [{ name, type, nullable, comment, references }] }], next }
485
+ `;
486
+ export const TABLES = {
487
+ // the project, not the run (the hands-on review of #451, item 15): what it lists is the database's catalog and the
488
+ // project's sources file, and choosing what to add comes before anything is started
489
+ needs: "project",
490
+ name: "tables",
491
+ summary: "the database's tables: rows, key, whether added; a table's columns and what they reference",
492
+ usage: TABLES_USAGE,
493
+ options: { json: { type: "boolean" }, all: { type: "boolean" } },
494
+ json: ["database", "source", "tables", "next"],
495
+ };
496
+ export async function tables(argv) {
497
+ const f = parseCommand(TABLES, argv);
498
+ if (typeof f === "number")
499
+ return f;
500
+ const json = f.values.json === true;
501
+ const where = await reach(TABLES, json);
502
+ // (a state with no project never meets the need: reach said it)
503
+ if (where === null || where.project === null)
504
+ return 1;
505
+ const s = await databaseTables(where.project);
506
+ if ("message" in s)
507
+ return failed("tables", s, { json });
508
+ let shown = s.tables;
509
+ if (f.positionals.length) {
510
+ shown = [];
511
+ for (const name of f.positionals) {
512
+ const t = findTable(s.tables, name);
513
+ if (!("table" in t))
514
+ return failed("tables", t, { json });
515
+ shown.push(t);
516
+ }
517
+ }
518
+ // what to run next: the first table not yet added, as a whole list or as the named tables
519
+ const one = shown.find((t) => !t.added && t.reason === null);
520
+ const next = f.positionals.length
521
+ ? (one ? [[`mason add ${tableArg(one.table)}`, `copy it into Mason (--columns ${one.columns.slice(0, 2).map((c) => c.name).join(",")}: those only)`]] : [])
522
+ : addSteps(shown, "tables");
523
+ if (json) {
524
+ out(jsonText(TABLES, { database: s.database, source: { name: s.name, file: s.file }, tables: shown.map((t) => ({
525
+ table: t.table, rows: t.rows, bytes: t.bytes, primaryKey: t.key, comment: t.comment,
526
+ addable: t.reason === null, reason: t.reason, added: t.added, addedColumns: t.addedColumns, copyKey: t.copyKey,
527
+ columns: t.columns.map((c) => ({ name: c.name, type: c.type, nullable: c.nullable, comment: c.comment, references: c.references })),
528
+ })) }, next));
529
+ return 0;
530
+ }
531
+ if (!shown.length) {
532
+ out(`${s.database} has no table this user can read.`);
533
+ return 0;
534
+ }
535
+ // one line per table unless a table is named or --all asks: 54 tables were 600 lines, and choosing what to
536
+ // add needs the names, sizes and keys (an agent read them through --json and python, #409)
537
+ if (!f.positionals.length && !f.values.all) {
538
+ const w = Math.max(...shown.map((t) => t.table.length));
539
+ for (const t of shown) {
540
+ const size = t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`;
541
+ out(`${t.table.padEnd(w)} ${size.padStart(18)} ${t.key.length ? `key ${t.key.join(", ")}` : "no key"}` +
542
+ `${t.added ? " · added" : t.reason ? ` · cannot be added as it is: ${t.reason}` : ""}`);
543
+ }
544
+ out(nextBlock(next));
545
+ return 0;
546
+ }
547
+ for (const t of shown) {
548
+ const size = t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`;
549
+ const added = t.added ? ` · added (${t.addedColumns.length === t.columns.length ? "every column" : t.addedColumns.join(", ")}${t.copyKey ? `; copied by ${t.copyKey.join(", ")}` : ""})` : "";
550
+ out(`${t.table} · ${size}${t.key.length ? ` · key ${t.key.join(", ")}` : ""}${added}`);
551
+ if (t.comment)
552
+ out(` ${t.comment}`);
553
+ if (t.reason)
554
+ out(` cannot be added as it is: ${t.reason}`);
555
+ const w = Math.max(...t.columns.map((c) => c.name.length));
556
+ const tw = Math.max(...t.columns.map((c) => c.type.length));
557
+ for (const c of t.columns) {
558
+ const notes = [c.nullable ? "" : "not null", c.references ? `→ ${c.references.table}.${c.references.column}` : "",
559
+ looksSecret(c.name) && !t.key.includes(c.name) ? "(looks like a secret: left out unless named)" : "", c.comment ? `-- ${c.comment}` : ""].filter(Boolean);
560
+ out(` ${c.name.padEnd(w)} ${c.type.padEnd(tw)} ${notes.join(" ")}`.trimEnd());
561
+ }
562
+ out("");
563
+ }
564
+ // each table above ends with its blank line: the block's own is left out
565
+ if (next.length)
566
+ out(nextBlock(next).slice(1));
567
+ return 0;
568
+ }
569
+ /**
570
+ * THE PROJECT'S DATABASE'S TABLES, READ BY THE COMMAND ITSELF (the hands-on review of #451, item 15: `mason tables`
571
+ * answered "Mason is not running", though it reads only Postgres): the database's catalog (preflight.ts
572
+ * readSourceFacts, what mason start reads), each table as the run's state says it against the sources file
573
+ * (sources.ts catalogTable). A file that cannot be read, or a database that does not answer, is said with why and the way out.
574
+ */
575
+ async function databaseTables(p) {
576
+ const src = projectSource(p);
577
+ if ("message" in src)
578
+ return src;
579
+ const fix = `change the URL where it is (${src.from}) when the database's password or host changed`;
580
+ const parsed = parseSource(src.resolved);
581
+ if ("problem" in parsed)
582
+ return { message: `the database's URL (${src.from}): ${parsed.problem}`, code: "bad_source", fix };
583
+ const connected = await connectSource(parsed.source);
584
+ if ("failure" in connected) {
585
+ return { message: `could not connect to ${maskedUrl(parsed.source.url)}: ${connected.failure.message}`, code: "unreachable",
586
+ ...(connected.failure.hints.length ? { why: connected.failure.hints.join("; ") } : {}), fix,
587
+ next: [["mason doctor", "checks the machine and the project's database"]] };
588
+ }
589
+ let facts;
590
+ try {
591
+ facts = await readSourceFacts(connected.pg);
592
+ }
593
+ catch (e) {
594
+ // a catalog this user cannot read (a revoked grant, a server shutting down): said as the database's, never a crash
595
+ return { message: `could not read ${parsed.source.database}'s tables: ${e.message}`, code: "failed",
596
+ next: [["mason doctor", "checks the machine and the project's database"]] };
597
+ }
598
+ finally {
599
+ connected.pg.close();
600
+ }
601
+ return { database: parsed.source.database, name: src.name, file: src.file, tables: facts.tables.map((t) => catalogTable(t, src.tables.find((e) => e.table === t.source))) };
602
+ }
603
+ // ------------------------------------------------------------------ mason add
604
+ const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--include <column>,…] [--key <column>,…] [--json]
605
+
606
+ Copies tables into Mason: each one is added to the project's sources file (mason/sources/<name>.yml), under tables:
607
+ mason add orders every column: orders: {}
608
+ mason add orders --columns id,status,total those columns: orders: { columns: [id, status, total] }
609
+ mason add orders customers several tables, every column of each
610
+ mason add users --include password_hash every column, and one that looks like a secret
611
+ mason add event_log --key tenant_id,id a table with no primary key, copied by those columns
612
+ A table is its name (orders) or schema.table (billing.invoices). Its primary key is always copied. The rest of the
613
+ file — your comments, other tables — stays as it was.
614
+
615
+ A column that looks like a secret (password, token, api_key, private_key, …) is left out unless named, and said:
616
+ it is written as exclude: [password_hash], since anyone with a user's password can read every copied column. Name
617
+ one with --columns or --include to have it.
618
+
619
+ The copy starts at once; mason wait returns when it is done and the table is served. Adding a table again with
620
+ other columns changes its entry, and the table is copied again with them.
621
+
622
+ --columns <a,b,…> only these columns (one table at a time: the list is that table's)
623
+ --include <a,b,…> these columns as well as every other one — a column that looks like a secret, named on purpose
624
+ --key <a,b,…> for a table with no primary key: the columns that tell its rows apart, which it is copied by (one
625
+ table at a time; polling only: logical replication cannot apply a change by them)
626
+ --json as data: { added: [{ table, entry, columns (null: every one), exclude, key, addedKey (key columns
627
+ added to --columns), skippedSecrets, already (the file said so already) } or { table, error }],
628
+ file (the sources file), error (when a table was not added: { code, message, why }), next };
629
+ exit 1 when a table could not be added
630
+ `;
631
+ export const ADD = {
632
+ needs: "run",
633
+ name: "add",
634
+ summary: "copy tables into Mason, adding them to the sources file: mason add orders (--columns id,status: those only)",
635
+ usage: ADD_USAGE,
636
+ options: { json: { type: "boolean" }, columns: { type: "string" }, include: { type: "string" }, key: { type: "string" } },
637
+ json: ["added", "file", "error", "next"],
638
+ };
639
+ /**
640
+ * The tables and the columns each one's entry copies (add takes --columns, not a DSL):
641
+ * `["*"]` for every column, the --columns list for one table, and --include's columns on top of either; `key`, --key's
642
+ * list, for one table. A list names each column once; an empty one is refused.
643
+ */
644
+ export function addSpecs(tables, columns, include, key) {
645
+ if (!tables.length)
646
+ return "name a table: mason add orders (mason tables lists them)";
647
+ const list = (flag, v) => {
648
+ if (v === undefined)
649
+ return null;
650
+ const names = v.split(",").map((c) => c.trim()).filter(Boolean);
651
+ return names.length ? [...new Set(names)] : `${flag} names no column: ${flag} id,status`;
652
+ };
653
+ const cols = list("--columns", columns), inc = list("--include", include), keys = list("--key", key);
654
+ if (typeof cols === "string")
655
+ return cols;
656
+ if (typeof inc === "string")
657
+ return inc;
658
+ if (typeof keys === "string")
659
+ return keys;
660
+ if (cols !== null && tables.length > 1)
661
+ return `--columns is one table's list, and ${tables.length} tables are named: add them one at a time (mason add ${tables[0]} --columns …)`;
662
+ if (keys !== null && tables.length > 1)
663
+ return `--key is one table's key, and ${tables.length} tables are named: add them one at a time (mason add ${tables[0]} --key …)`;
664
+ const chosen = [...(cols ?? ["*"]), ...(inc ?? []).filter((c) => !(cols ?? []).includes(c))];
665
+ return tables.map((table) => ({ table, columns: chosen, key: keys }));
666
+ }
667
+ export async function add(argv) {
668
+ const f = parseCommand(ADD, argv);
669
+ if (typeof f === "number")
670
+ return f;
671
+ const json = f.values.json === true;
672
+ const specs = addSpecs(f.positionals, f.values.columns, f.values.include, f.values.key);
673
+ if (typeof specs === "string")
674
+ return failed("add", { message: specs, code: "usage", usage: ADD_USAGE }, { json });
675
+ const run = await runOr(ADD, json);
676
+ if (!run)
677
+ return 1;
678
+ return guarded("add", json, async () => {
679
+ const s = await readState(run);
680
+ const results = [];
681
+ const added = [];
682
+ let file = s.source.file ?? null;
683
+ // the tables that could not be added, each with its reason and code: the command's failure, in the failure shape
684
+ const refused = [];
685
+ // A CHECKLIST, a line per table (it reads best as a list of completed actions): a spinner
686
+ // while it is added, ✔ with what was written, its notes under it; ✘ with the reason when it could not be
687
+ const st = styleOut();
688
+ const steps = stepRenderer({ line: out, live: !json && process.stdout.isTTY ? { write: (t) => { process.stdout.write(t); } } : null, style: st });
689
+ const note = (text) => { if (!json)
690
+ steps.print(st.dim(` ${wrap(text, st.width, 4)}`)); };
691
+ const shown = (p) => { const rel = path.relative(process.cwd(), p); return rel.startsWith("..") ? p : rel; };
692
+ for (const spec of specs) {
693
+ const t = findTable(s.tables, spec.table);
694
+ // a table the database lacks is a failed line of the checklist, its reason under it, like a table Mason refused
695
+ if (!("table" in t)) {
696
+ refused.push({ table: spec.table, message: t.message, code: t.code });
697
+ results.push({ table: spec.table, error: t.message });
698
+ if (!json) {
699
+ steps.begin(`Adding ${spec.table}`);
700
+ steps.fail(`Adding ${spec.table}`);
701
+ note(`${t.message}${t.fix ? `: ${t.fix}` : ""}${t.next?.length ? ` (${t.next[0][0]})` : ""}`);
702
+ }
703
+ continue;
704
+ }
705
+ if (!json)
706
+ steps.begin(`Adding ${t.table}`);
707
+ const r = await callRun(run, "POST", "/api/pick", { table: t.table, picked: true, columns: spec.columns, ...(spec.key ? { key: spec.key } : {}) });
708
+ if (r.status !== 200) {
709
+ refused.push({ table: t.table, message: String(r.body.error), code: r.status === 404 ? "not_found" : "refused" });
710
+ results.push({ table: t.table, error: String(r.body.error) });
711
+ if (!json) {
712
+ steps.fail(`Adding ${t.table}`);
713
+ note(String(r.body.error));
714
+ }
715
+ continue;
716
+ }
717
+ const b = r.body;
718
+ file = b.file;
719
+ results.push({ table: b.table, entry: b.entry, columns: b.columns, exclude: b.exclude, key: b.key, addedKey: b.addedKey, skippedSecrets: b.skippedSecrets, already: b.already });
720
+ added.push(b.table);
721
+ if (json)
722
+ continue;
723
+ const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
724
+ steps.done(`Added ${t.table} to ${shown(b.file)}: ${copies}${b.key ? `, copied by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`);
725
+ if (b.addedKey.length)
726
+ note(`key added: ${b.addedKey.join(", ")} (a copy always has its key)`);
727
+ // a secret is readable by anyone with a user's password: left out, and said, with the way to include one
728
+ if (b.skippedSecrets.length)
729
+ note(`left out ${b.skippedSecrets.join(", ")}: ${b.skippedSecrets.length === 1 ? "it looks" : "they look"} like ${b.skippedSecrets.length === 1 ? "a secret" : "secrets"}, ` +
730
+ `which anyone with a user's password could read (--include ${b.skippedSecrets[0]} adds it)`);
731
+ }
732
+ const next = added.length ? [
733
+ ["mason wait", `until ${added.length === 1 ? "its copy finishes" : "their copies finish"} (the copy has started)`],
734
+ READY_STEPS[0],
735
+ ] : [];
736
+ if (refused.length) {
737
+ // A TABLE NOT ADDED IS THE COMMAND'S FAILURE, in the shape every failure has (found by the CLI session test of the private repo:
738
+ // exit 1 with no error, and nothing on stderr): what is wrong, each table's reason, and next — the added ones' too
739
+ const f = {
740
+ message: specs.length === 1 ? `${refused[0].table} was not added` : `${refused.length} of ${specs.length} tables were not added: ${refused.map((x) => x.table).join(", ")}`,
741
+ code: refused.every((x) => x.code === "not_found") ? "not_found" : refused.every((x) => x.code === "usage") ? "usage" : "refused",
742
+ why: refused.map((x) => specs.length === 1 ? x.message : `${x.table}: ${x.message}`).join("; "),
743
+ next: [...next, ["mason tables", "the database's tables, as this user reads them"]],
744
+ };
745
+ if (json) {
746
+ out(jsonText(ADD, { added: results, file, error: errorJson(f) }, f.next));
747
+ return 1;
748
+ }
749
+ return failed("add", f, { json: false });
750
+ }
751
+ if (json)
752
+ out(jsonText(ADD, { added: results, file }, next));
753
+ else
754
+ out(endText({ next }, st));
755
+ return 0;
756
+ });
757
+ }
758
+ // ------------------------------------------------------------------ mason remove
759
+ const REMOVE_USAGE = `usage: mason remove <table>… [--json]
760
+
761
+ Takes tables out of the project: each one's entry in the sources file (mason/sources/<name>.yml) is removed, the
762
+ rest of the file as it was. Mason then stops copying it and drops its copy.
763
+
764
+ mason remove customers
765
+ mason remove customers billing.invoices several at once
766
+
767
+ --json as data: { removed (the tables, schema.table), file (the sources file), next }
768
+ `;
769
+ export const REMOVE = {
770
+ needs: "run",
771
+ name: "remove",
772
+ summary: "take tables out of the sources file: mason remove customers (its copy is dropped)",
773
+ usage: REMOVE_USAGE,
774
+ options: { json: { type: "boolean" } },
775
+ json: ["removed", "file", "next"],
776
+ };
777
+ export async function remove(argv) {
778
+ const f = parseCommand(REMOVE, argv);
779
+ if (typeof f === "number")
780
+ return f;
781
+ const json = f.values.json === true;
782
+ const names = [...new Set(f.positionals)];
783
+ if (!names.length)
784
+ return failed("remove", { message: "name a table: mason remove customers (mason status lists the added ones)", code: "usage", usage: REMOVE_USAGE }, { json });
785
+ const run = await runOr(REMOVE, json);
786
+ if (!run)
787
+ return 1;
788
+ return guarded("remove", json, async () => {
789
+ // the run's own removal (src/cli/run-server.ts removeTables): one path, so nothing can disagree with it
790
+ const r = await callRun(run, "POST", "/api/remove", { tables: names });
791
+ if (r.status !== 200) {
792
+ const message = String(r.body.error);
793
+ return failed("remove", { message: `${names.join(", ")} ${names.length === 1 ? "was" : "were"} not removed`, code: r.status === 404 ? "not_found" : r.status === 400 ? "usage" : "refused", why: message,
794
+ next: [["mason status", "the tables added"]] }, { json });
795
+ }
796
+ const b = r.body;
797
+ const next = [["mason status", "the tables left"]];
798
+ if (json) {
799
+ out(jsonText(REMOVE, { removed: b.removed, file: b.file }, next));
800
+ return 0;
801
+ }
802
+ const rel = path.relative(process.cwd(), b.file);
803
+ out(reportText({ headline: `Removed ${b.removed.join(", ")}`, details: [["from", rel.startsWith("..") ? b.file : rel]],
804
+ tips: [`Mason stops copying ${b.removed.length === 1 ? "it" : "them"} and drops ${b.removed.length === 1 ? "its copy" : "their copies"}`], next }, styleOut()));
805
+ return 0;
806
+ });
807
+ }
808
+ // ------------------------------------------------------------------ mason wait
809
+ const WAIT_USAGE = `usage: mason wait [--timeout <seconds>] [--json]
810
+
811
+ Waits until every added table is copied and served, showing every added table on a line of its own: 100% for the
812
+ finished (in the order they finished), a bar for the ones still copying, at the bottom. Run it after mason add.
813
+
814
+ mason add orders && mason wait
815
+ mason wait --timeout 3600 a large table: wait up to an hour
816
+
817
+ --timeout <seconds> how long to wait (default 600); the copy goes on after, and mason wait again picks it up
818
+ --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error }]
819
+ (copy: { phase, rowsCopied, progress: the chunks done, reading and for how long, the rate, the
820
+ seconds left, while its chunks are read }),
821
+ stream (replication mode: where the stream is, as mason status --json says it), error (on a
822
+ failure or a timeout: { code, message, next }), next }
823
+
824
+ Exit status: 0 ready; 1 a copy failed (printed, with what to do; Mason tries it again by itself); 124 timed out.
825
+ `;
826
+ export const WAIT = {
827
+ needs: "run",
828
+ name: "wait",
829
+ summary: "wait until every added table is copied and served (exit 0; 1 on a failure, with why)",
830
+ usage: WAIT_USAGE,
831
+ options: { json: { type: "boolean" }, timeout: { type: "string" } },
832
+ json: ["ready", "failed", "waiting", "timedOut", "tables", "stream", "error", "next"],
833
+ };
834
+ export async function wait(argv) {
835
+ const f = parseCommand(WAIT, argv);
836
+ if (typeof f === "number")
837
+ return f;
838
+ const json = f.values.json === true;
839
+ const timeout = f.values.timeout === undefined ? 600 : Number(f.values.timeout);
840
+ if (!Number.isFinite(timeout) || timeout <= 0)
841
+ return failed("wait", { message: `--timeout is a number of seconds, not ${JSON.stringify(f.values.timeout)}`, code: "usage" }, { json });
842
+ const run = await runOr(WAIT, json);
843
+ if (!run)
844
+ return 1;
845
+ return guarded("wait", json, async () => {
846
+ const until = Date.now() + timeout * 1000;
847
+ // a terminal gets the block — every added table on its line (tableLines), then the stream — redrawn in place;
848
+ // anything else (a coding agent's shell, a log) the block when what is under way changes, and every 30 s, so a
849
+ // long copy neither floods it nor goes quiet. Each line ends in a newline, so whatever is printed next (Ready, a
850
+ // failure, a run that stops mid-wait) starts on a line of its own.
851
+ const live = process.stderr.isTTY === true && !json;
852
+ let said = "", saidAt = 0, drawn = 0;
853
+ const fit = (l) => { const w = (process.stderr.columns ?? 120) - 3; return l.length > w ? `${l.slice(0, w - 1)}…` : l; };
854
+ const show = (lines, next = []) => {
855
+ if (json || !lines.length)
856
+ return;
857
+ if (live) {
858
+ // back to the block's first line, each line rewritten and cleared to its end, anything left below cleared; under
859
+ // it, while tables are still at work, what can be run already (the Next section at the bottom while it works,
860
+ // so the tables already complete can be queried) — wrapped at the width by nextBlock itself
861
+ const below = next.length ? nextBlock(next, styleErr()).split("\n") : [];
862
+ process.stderr.write(`${drawn ? `\x1b[${drawn}F` : ""}${lines.map((l) => `\x1b[2K ${paintBar(fit(l), styleErr())}\n`).join("")}` +
863
+ `${below.map((l) => `\x1b[2K${l}\n`).join("")}\x1b[J`);
864
+ drawn = lines.length + below.reduce((k, l) => k + 1 + (l.match(/\n/g)?.length ?? 0), 0);
865
+ return;
866
+ }
867
+ // the same work, other numbers (a stream's positions included: they move with every change)
868
+ const what = lines.join("\n").replace(/[█░]+/g, "").replace(/\b[0-9A-F]+\/[0-9A-F]+\b/g, "#").replace(/[\d,.]+(%| rows\/s| s| min| h)?/g, "#");
869
+ if (what !== said || Date.now() - saidAt >= 30_000) {
870
+ err(lines.map((l) => ` ${l}`).join("\n"));
871
+ said = what;
872
+ saidAt = Date.now();
873
+ }
874
+ };
875
+ for (;;) {
876
+ // a copy's rate and progress are the run's own, measured per chunk (src/duck/sync.ts copyProgressNow)
877
+ const s = await readState(run);
878
+ const r = readiness(s);
879
+ const done = r.ready || r.failed.length > 0;
880
+ const picked = s.tables.filter((t) => t.added);
881
+ // the outcome and what to run next, the same for a person and under --json
882
+ const readyNext = picked.length ? [...READY_STEPS] : addSteps(s.tables);
883
+ const failure = r.failed.length
884
+ ? { message: r.failed.length === 1 ? r.failed[0] : `${r.failed.length} copies failed`, code: "failed", why: r.failed.length === 1 ? undefined : r.failed.join("\n"),
885
+ fix: "Mason tries a failed copy again by itself; mason logs says why it failed and when it tries next",
886
+ next: [["mason logs", "the detail of each failure"], ["mason status", "the rest of the project"]] }
887
+ : !done && Date.now() >= until
888
+ ? { message: `not ready after ${timeout} s`, code: "timeout", why: r.waiting.join("\n"), fix: "nothing stopped: the copies go on",
889
+ next: [["mason wait", "keep waiting (--timeout <seconds> for longer)"], ["mason status", "what Mason is doing"]] }
890
+ : null;
891
+ if (json && (done || failure)) {
892
+ out(jsonText(WAIT, { ...r, timedOut: !done, tables: picked.map((t) => ({ table: t.table, copy: t.copy, error: t.error, ...(t.unsynced ? { unsynced: t.unsynced } : {}) })),
893
+ ...(s.stream ? { stream: s.stream } : {}), ...(failure ? { error: errorJson(failure) } : {}) }, failure ? failure.next ?? [] : readyNext));
894
+ }
895
+ // the block as it ends, too: every table at 100% beside Ready (when the last change was not printed yet)
896
+ // while at work: a table already copied can be queried now; the final Next is Ready's, printed once below
897
+ const copied = picked.some((t) => t.copy?.phase === "done");
898
+ const meanwhile = done ? [] : [...(copied ? [["mason user add <name>", "a user for your BI tool or psql: a copied table can be queried now"]] : []), ["mason status", "what Mason is doing"]];
899
+ show([...tableLines(s), ...(s.stream ? ["Stream:", ...streamLines(s.stream).map((l) => ` ${l}`)] : [])], meanwhile); // done: the meanwhile Next is dropped
900
+ if (r.ready) {
901
+ const head = !picked.length ? "Nothing to wait for: no table added yet." : "Ready: every added table is copied and served.";
902
+ if (!json)
903
+ out(withEnd(picked.length ? styleOut().green(head) : head, { next: readyNext }));
904
+ return 0;
905
+ }
906
+ if (failure)
907
+ return json ? (failure.code === "timeout" ? 124 : 1) : failed("wait", failure, { json: false });
908
+ await new Promise((res) => setTimeout(res, 1000));
909
+ }
910
+ });
911
+ }
912
+ // ------------------------------------------------------------------ mason logs
913
+ const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [--failed]] [--json]
914
+
915
+ The last lines of Mason's log: every copy, poll and statement it ran, warnings and errors with their reasons —
916
+ running or not. With --requests, the run's request log instead: every statement a tool sent the
917
+ Postgres API that Mason answered, and every one it refused or failed, a line each, newest last — when, the outcome
918
+ and SQLSTATE, how long, the rows sent, the client's application_name (and the mason user's name), the statement and
919
+ the error's first line. It is kept MASON_REQUEST_LOG_DAYS days (default 7) in the run's copy, and read through the
920
+ running Mason.
921
+
922
+ mason logs
923
+ mason logs -f keep printing new lines as Mason works, until Ctrl-C
924
+ mason logs -n 200 --start what mason start printed: its steps, and the warnings and errors since
925
+ mason logs --requests --failed -n 20 the last 20 statements refused or failed
926
+
927
+ -n, --lines <n> how many lines, or with --requests how many requests (default 40)
928
+ -f, --follow keep printing new lines until Ctrl-C
929
+ --start mason start's own terminal instead (its steps, then only the warnings and errors)
930
+ --requests the Postgres API's requests, from the run's request log (Mason must be running)
931
+ --failed with --requests: only the refused and failed ones
932
+ --json as data: { lines } (the log's), or with --requests { requests: [{ requestId, startedAt, session,
933
+ applicationName, tokenName, user (the mason user), method, outcome (ok, refused, failed), sqlstate, message, detail, hint,
934
+ rows, durationMs, query, queryTruncated }], keptDays }; next
935
+ `;
936
+ export const LOGS = {
937
+ needs: "project",
938
+ name: "logs",
939
+ summary: "Mason's log: every copy, poll and statement (-f to follow it; --requests, every statement the Postgres API met)",
940
+ usage: LOGS_USAGE,
941
+ options: { lines: { type: "string", short: "n" }, follow: { type: "boolean", short: "f" }, start: { type: "boolean" },
942
+ requests: { type: "boolean" }, failed: { type: "boolean" }, json: { type: "boolean" } },
943
+ json: ["lines", "requests", "keptDays", "next"],
944
+ };
945
+ /**
946
+ * One request of the run's ledger on one line (src/duck/requests.ts): when, the outcome and SQLSTATE, how long,
947
+ * the rows sent, who (application_name, a mason user's name), the statement on one line and the error's first line.
948
+ */
949
+ export function requestLine(r, st) {
950
+ const word = r.outcome === "ok" ? "ok" : `${r.outcome} ${r.sqlstate ?? ""}`;
951
+ const paint = r.outcome === "ok" ? st.green : r.outcome === "failed" ? st.red : st.yellow;
952
+ const rows = r.rows === null ? "" : `${n(r.rows)} row${r.rows === 1 ? "" : "s"}`;
953
+ const who = `${r.applicationName || "(no application_name)"}${r.tokenName ? ` (token ${r.tokenName})` : ""}${r.user ? ` (user ${r.user})` : ""}`;
954
+ return `${r.startedAt.slice(0, 19).replace("T", " ")} ${paint(word.padEnd(13))} ${`${n(r.durationMs)} ms`.padStart(9)} ${rows.padStart(11)} ${who} ` +
955
+ `${oneLine(r.query, 120) || "(no statement)"}${r.message ? ` — ${firstLine(r.message)}` : ""}`;
956
+ }
957
+ /** mason logs --requests: the run's ledger through its command server (one process holds the copy's file) */
958
+ async function requestLogs(limit, failedOnly, json) {
959
+ const r0 = await theRun(process.cwd());
960
+ if ("problem" in r0)
961
+ return failed("logs", r0, { json });
962
+ return guarded("logs", json, async () => {
963
+ const reply = await callRun(r0.run, "GET", `/api/requests?limit=${limit}${failedOnly ? "&failed=1" : ""}`);
964
+ if (reply.status !== 200) {
965
+ return failed("logs", { message: String(reply.body.error ?? `the run answered ${reply.status}`), code: reply.status === 409 ? "refused" : "failed",
966
+ next: [["mason status", "the slow and refused queries of the last day"], ["mason logs", "Mason's log"]] }, { json });
967
+ }
968
+ const requests = reply.body.requests;
969
+ const keptDays = Number(reply.body.keptDays);
970
+ const next = failedOnly ? [["mason logs --requests", "every request, answered or not"], ["mason logs", "Mason's log, where each refusal is a line too"]]
971
+ : [["mason logs --requests --failed", "only the refused and failed ones"], ["mason status", "the slow and refused ones of the last day, grouped"]];
972
+ if (json) {
973
+ out(jsonText(LOGS, { requests, keptDays }, next));
974
+ return 0;
975
+ }
976
+ const st = styleOut();
977
+ if (!requests.length)
978
+ out(`No ${failedOnly ? "refused or failed request" : "request"} in the last ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_REQUEST_LOG_DAYS).`);
979
+ for (const r of requests)
980
+ out(requestLine(r, st));
981
+ out(endText({ next }, st));
982
+ return 0;
983
+ });
984
+ }
985
+ export async function logs(argv) {
986
+ const f = parseCommand(LOGS, argv);
987
+ if (typeof f === "number")
988
+ return f;
989
+ const json = f.values.json === true;
990
+ const lines = f.values.lines === undefined ? 40 : Number(f.values.lines);
991
+ if (!Number.isInteger(lines) || lines < 0)
992
+ return failed("logs", { message: `-n (--lines) is a number of lines, not ${JSON.stringify(f.values.lines)}`, code: "usage" }, { json });
993
+ if (f.values.failed && !f.values.requests)
994
+ return failed("logs", { message: "--failed is a filter of --requests", code: "usage", fix: "mason logs --requests --failed" }, { json });
995
+ if (f.values.requests && (f.values.start || f.values.follow))
996
+ return failed("logs", { message: `--requests lists the request log; ${f.values.start ? "--start" : "-f"} is the log file's`, code: "usage", fix: "mason logs --requests, or mason logs " + (f.values.start ? "--start" : "-f") }, { json });
997
+ if (json && f.values.follow)
998
+ return failed("logs", { message: "-f prints lines as they come, and --json is one answer", code: "usage", fix: "mason logs --json, or mason logs -f" }, { json });
999
+ if (f.values.requests) {
1000
+ if (lines < 1)
1001
+ return failed("logs", { message: "-n with --requests is a number of requests, 1 or more", code: "usage" }, { json });
1002
+ return requestLogs(lines, f.values.failed === true, json);
1003
+ }
1004
+ // a stopped run's log is still worth reading: the project's, running or not
1005
+ const t = theProject(process.cwd());
1006
+ if ("problem" in t)
1007
+ return failed("logs", t, { json });
1008
+ // the run's log by default (mason logs once showed no actual logs: it showed
1009
+ // mason start's terminal, which after the start carries only warnings and errors)
1010
+ const file = path.join(t.project.dir, f.values.start ? "dev.log" : "mason.log");
1011
+ if (!fs.existsSync(file)) {
1012
+ return failed("logs", { message: `${file} does not exist yet`, code: "nothing_yet", why: `Mason has not run for ${t.project.config.name}`, next: [["mason start", "start it, in the background"]] }, { json });
1013
+ }
1014
+ const text = fs.readFileSync(file, "utf8");
1015
+ const all = text.split("\n");
1016
+ if (all[all.length - 1] === "")
1017
+ all.pop();
1018
+ if (json) {
1019
+ out(jsonText(LOGS, { lines: all.slice(Math.max(0, all.length - lines)) }, [["mason logs --requests", "every statement the Postgres API met"]]));
1020
+ return 0;
1021
+ }
1022
+ for (const l of all.slice(Math.max(0, all.length - lines)))
1023
+ out(l);
1024
+ if (!f.values.follow)
1025
+ return 0;
1026
+ let offset = Buffer.byteLength(text);
1027
+ for (;;) {
1028
+ await new Promise((res) => setTimeout(res, 500));
1029
+ const size = fs.statSync(file).size;
1030
+ if (size < offset)
1031
+ offset = 0; // a new run started the file over
1032
+ if (size === offset)
1033
+ continue;
1034
+ const buf = Buffer.alloc(size - offset);
1035
+ const fd = fs.openSync(file, "r");
1036
+ try {
1037
+ fs.readSync(fd, buf, 0, buf.length, offset);
1038
+ }
1039
+ finally {
1040
+ fs.closeSync(fd);
1041
+ }
1042
+ offset = size;
1043
+ process.stdout.write(buf.toString());
1044
+ }
1045
+ }
1046
+ // ------------------------------------------------------------------ mason stop
1047
+ const STOP_USAGE = `usage: mason stop [--json]
1048
+
1049
+ Stops Mason for this folder's project. The copy of your data and Mason's state stay: the next mason start resumes
1050
+ in seconds, reading only what changed.
1051
+
1052
+ mason stop
1053
+
1054
+ mason destroy removes the copy and the state too (your files stay), to free the disk or begin again.
1055
+
1056
+ --json as data: { stopped: [name], killed (a run that did not answer and ignored SIGTERM), supervisor (its pid, when one was stopped), next }
1057
+ `;
1058
+ export const STOP = {
1059
+ needs: "project",
1060
+ name: "stop",
1061
+ summary: "stop Mason for this folder's project (the copy stays: the next mason start resumes)",
1062
+ usage: STOP_USAGE,
1063
+ options: { json: { type: "boolean" } },
1064
+ json: ["stopped", "killed", "supervisor", "next"],
1065
+ };
1066
+ export async function stop(argv) {
1067
+ const f = parseCommand(STOP, argv);
1068
+ if (typeof f === "number")
1069
+ return f;
1070
+ const json = f.values.json === true;
1071
+ const next = [["mason start", "resume, in seconds (in the project's folder)"], ["mason destroy", "remove its copy of the data and its state; your files stay"]];
1072
+ const r = await theRun(process.cwd());
1073
+ if ("problem" in r && r.hung !== undefined) {
1074
+ // A RUN THAT DOES NOT ANSWER (src/cli/runs.ts "alive and silent"): its supervisor first, so it is not started again
1075
+ // over the kill, then SIGTERM and, after the grace, SIGKILL; the run could not record that it stopped, so stop does
1076
+ const hung = r.hung;
1077
+ if (!json)
1078
+ err(styleErr().dim(` Mason for ${hung.name} (pid ${hung.pid}) does not answer: stopping it (SIGKILL after ${END_GRACE_MS / 1000} s) …`));
1079
+ // the supervisor is told BEFORE the run dies (it waits for the run, so it cannot leave yet): the run's exit is then a
1080
+ // stop to it, never a crash to record and restart from (found on the test server)
1081
+ const sup = signalSupervisor(r.project.dir);
1082
+ const { killed } = await endRun(hung.pid);
1083
+ if (sup !== null)
1084
+ await supervisorGone(r.project.dir, sup);
1085
+ const how = killed ? `it did not answer its commands and ignored SIGTERM for ${END_GRACE_MS / 1000} s, so it was killed` : "it did not answer its commands and left on SIGTERM";
1086
+ const recorded = readProject(hung.root);
1087
+ // what the run could not record: that it stopped, and how (mason status shows it once it is gone)
1088
+ if (isProject(recorded)) {
1089
+ recorded.config.running = null;
1090
+ recorded.config.lastStop = { at: new Date().toISOString(), how: `${killed ? "killed by mason stop (SIGKILL)" : "stopped by mason stop (SIGTERM)"}: ${how.replace(/, so it was killed$/, "")}` };
1091
+ writeConfig(recorded.dir, recorded.config);
1092
+ }
1093
+ if (json) {
1094
+ out(jsonText(STOP, { stopped: [hung.name], killed, supervisor: sup }, next));
1095
+ return 0;
1096
+ }
1097
+ out(reportText({ headline: `Stopped ${hung.name}`, details: [["how", `${how}${sup === null ? "" : `; its supervisor (pid ${sup}) will not start it again`}`],
1098
+ ["kept", "its copy of the data and its state (DuckDB recovers from its write-ahead log at the next start)"], ["log", "mason logs shows what it was doing"]] }));
1099
+ out(endText({ next }));
1100
+ return 0;
1101
+ }
1102
+ if ("problem" in r) {
1103
+ // not running is what stop wants: said, exit 0; no project here is a wrong place, exit 1
1104
+ if (r.project) {
1105
+ // a run that crashed is dead while its supervisor waits to start it again: the supervisor is what stops then
1106
+ const sup = await stopSupervisor(r.project.dir);
1107
+ if (sup !== null) {
1108
+ if (json)
1109
+ out(jsonText(STOP, { stopped: [r.project.config.name] }, next));
1110
+ else
1111
+ out(withEnd(`Stopped Mason for ${r.project.config.name}: it was between restarts, and its supervisor (pid ${sup}) will not start it again.`, { next }));
1112
+ return 0;
1113
+ }
1114
+ if (json)
1115
+ out(jsonText(STOP, { stopped: [] }, next.slice(0, 1)));
1116
+ else
1117
+ out(withEnd(`Mason is not running for ${r.project.config.name}.`, { next: next.slice(0, 1) }));
1118
+ return 0;
1119
+ }
1120
+ return failed("stop", r, { json });
1121
+ }
1122
+ const run = r.run;
1123
+ process.kill(run.pid, "SIGTERM");
1124
+ if (!json)
1125
+ err(styleErr().dim(` stopping Mason for ${run.name} (pid ${run.pid}) …`));
1126
+ // it closes the Postgres API, ends the poll or the stream, and closes its copy
1127
+ const until = Date.now() + 90_000;
1128
+ while (alive(run.pid) && Date.now() < until)
1129
+ await new Promise((res) => setTimeout(res, 250));
1130
+ if (alive(run.pid))
1131
+ return failed("stop", { message: `Mason for ${run.name} (pid ${run.pid}) is still running after 90 s`, code: "failed", next: [["mason logs", "what it is doing"]] }, { json });
1132
+ // a replication slot outlives the run that reads it, and holds the database's WAL meanwhile: said where it is left
1133
+ const recorded = readProject(run.root);
1134
+ const replicating = isProject(recorded) && recorded.config.changes === "replication";
1135
+ const named = isProject(recorded) ? recorded.config.publication ?? null : null;
1136
+ if (json) {
1137
+ out(jsonText(STOP, { stopped: [run.name] }, next));
1138
+ return 0;
1139
+ }
1140
+ out(reportText({ headline: `Stopped ${run.name}`, details: [["kept", "its copy of the data and its state"],
1141
+ ...(replicating ? [["slot", `its replication slot stays on the database, holding WAL until the next mason start (mason destroy drops it)`],
1142
+ ["publication", named ? `${named}, its owner's: read as it is, never altered (mason destroy leaves it)` : "Mason's own (mason destroy drops it)"]] : [])] }));
1143
+ out(endText({ next }));
1144
+ return 0;
1145
+ }