@mason-data/cli 0.0.0-stage → 0.7.0

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