@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,491 @@
1
+ /**
2
+ * THE WEB PAGE OF `mason start` (`mason ui`; docs/WEB-UI.md, and .claude/rules/web-ui.md for whoever builds it out):
3
+ * one page, three sections, rendered from the run's command server's JSON (src/cli/run-server.ts) in the words the
4
+ * commands print —
5
+ *
6
+ * STATUS GET /api/state, /api/slow, /api/refused: what `mason status` prints, polled every 2 s while the tab is seen
7
+ * TABLES GET /api/state `tables`, POST /api/pick, POST /api/remove: what `mason tables`, `mason add` and `mason remove` do
8
+ * USERS GET /api/users, POST /api/users, DELETE /api/users/<name>: what `mason user list|add|remove` does
9
+ *
10
+ * Plain JavaScript, an ES module, no framework and no build step: the server serves this file as it is, under a
11
+ * `default-src 'self'` policy. The renderers are PURE FUNCTIONS OF THE JSON, returning HTML strings (every value
12
+ * escaped), exported so tests/web-page.test.ts runs them under node; `createApp` holds what the page knows and does,
13
+ * over a `fetch` it is handed; `start` (a browser only) wires them to the document. The page never holds the run's
14
+ * token: the browser's cookie carries it (src/cli/security.ts).
15
+ */
16
+
17
+ // ------------------------------------------------------------------ helpers, DOM-free
18
+
19
+ /** A value as HTML text: every character that means something in HTML escaped. */
20
+ export const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
21
+
22
+ const n = (x) => x.toLocaleString("en-US");
23
+ const firstLine = (s) => s.split("\n")[0].trim();
24
+
25
+ /** A span of seconds as a person reads it: `45 s`, `12 min`, `2 h 5 min` (src/cli/readiness.ts `duration`, held equal by the tests). */
26
+ export function duration(seconds) {
27
+ const s = Math.max(1, Math.round(seconds));
28
+ if (s < 90) return `${s} s`;
29
+ const m = Math.round(s / 60);
30
+ return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
31
+ }
32
+
33
+ /** A share (0 to 1) as the commands print it beside a bar: whole percent, rounded down (`12%`). */
34
+ export const percent = (share) => `${Math.floor(Math.max(0, Math.min(1, share)) * 100)}%`;
35
+
36
+ /** The time a copy has left, as the commands print it: `~12 min left`; "" when nothing is measured yet. */
37
+ export const leftText = (secondsLeft) => secondsLeft === null || secondsLeft === undefined ? "" : `~${duration(secondsLeft)} left`;
38
+
39
+ /** A table as the commands name it: `orders` in public, else `schema.table` (src/cli/sources.ts `entryName`). */
40
+ export const entryName = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
41
+
42
+ /**
43
+ * What a copying table's line says, and how full its bar is: src/cli/readiness.ts `copyingText`, line for line, so the
44
+ * page and `mason status` say one thing (tests/web-page.test.ts holds the two equal).
45
+ */
46
+ export function copyingText(copied, rowsEstimate, p) {
47
+ const shown = copied + (p?.rowsReading ?? 0);
48
+ const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
49
+ const past = estimate !== null && shown > estimate;
50
+ const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
51
+ const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
52
+ const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
53
+ if (p === undefined) return { fraction, text: rowsText(copied) };
54
+ const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
55
+ const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
56
+ if (p.rowsPerSecond === null) {
57
+ const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
58
+ return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to copy`}` : `${rowsText(copied)} · resumed, ${first}` };
59
+ }
60
+ const parts = [rowsText(shown)];
61
+ const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
62
+ if (allIn) parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
63
+ else parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
64
+ parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
65
+ if (!allIn && p.secondsLeft !== null) parts.push(leftText(p.secondsLeft));
66
+ return { fraction, text: parts.join(" · ") };
67
+ }
68
+
69
+ /**
70
+ * AN ADDED TABLE'S LINE, as `mason status` and `mason wait` print it after its bar (src/cli/commands.ts `tableLines`):
71
+ * its state (done, unsynced, failed, copying, waiting), how full its bar is, and what it says.
72
+ */
73
+ export function tableLine(t) {
74
+ if (t.error) return { state: "failed", fraction: 1, what: `its copy failed: ${firstLine(t.error)}` };
75
+ if (t.copy?.phase === "copying") {
76
+ const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
77
+ return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `copying ${c.text}` : c.text };
78
+ }
79
+ if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its copy to start" };
80
+ if (t.unsynced) {
81
+ return { state: "unsynced", fraction: 1, 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 ${entryName(t.table)}, or mason start --replication` };
82
+ }
83
+ return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · copied${t.copy.retired ? ` · ${t.copy.retired}` : ""}` };
84
+ }
85
+
86
+ /** A table's facts as `mason tables` says them on its line (src/cli/commands.ts `tableFacts`): its size, its key, and
87
+ * added or why it cannot be added as it is (null: neither). */
88
+ export function tableFacts(t) {
89
+ return { size: t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`, key: t.key.length ? `key ${t.key.join(", ")}` : "no key",
90
+ state: t.added ? "added" : t.reason ? `cannot be added as it is: ${t.reason}` : null };
91
+ }
92
+
93
+ /** What `mason add` prints for a table added, from `/api/pick`'s answer (src/cli/commands.ts `addedLines`): its line,
94
+ * then its notes — the key added to the columns asked, the secret-looking columns left out. */
95
+ export function addedLines(b, file) {
96
+ const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
97
+ const s = b.skippedSecrets;
98
+ return { line: `Added ${b.table} to ${file}: ${copies}${b.key ? `, copied by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`, notes: [
99
+ ...(b.addedKey.length ? [`key added: ${b.addedKey.join(", ")} (a copy always has its key)`] : []),
100
+ ...(s.length ? [`left out ${s.join(", ")}: ${s.length === 1 ? "it looks" : "they look"} like ${s.length === 1 ? "a secret" : "secrets"}, ` +
101
+ `which anyone with a user's password could read (--include ${s[0]} adds it)`] : []),
102
+ ] };
103
+ }
104
+
105
+ /** What `mason remove` prints for the tables removed (src/cli/commands.ts `removedReport`). */
106
+ export function removedReport(removed, file) {
107
+ return { headline: `Removed ${removed.join(", ")}`, details: [["from", file]],
108
+ tips: [`Mason stops copying ${removed.length === 1 ? "it" : "them"} and drops ${removed.length === 1 ? "its copy" : "their copies"}`] };
109
+ }
110
+
111
+ /**
112
+ * `POST /api/pick`'s body for a table added: the columns ticked as `--columns`, the key's as `--key`, each left out when
113
+ * none is ticked (none: every column, as `mason add <table>` copies; no key: its primary key). What the lists may hold
114
+ * is the server's to judge (src/cli/run-server.ts `pick`), never the page's.
115
+ */
116
+ export const pickBody = (table, columns = [], key = []) => ({ table, picked: true, ...(columns.length ? { columns } : {}), ...(key.length ? { key } : {}) });
117
+
118
+ /** `mason status`'s rows for the database (src/cli/commands.ts `sourceRows`): its sources file, its URL as the run masked it, how its changes arrive. */
119
+ export function sourceRows(s) {
120
+ return [
121
+ ...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
122
+ ["Database", s.source.demo ? `the demo database Mason hosts (${s.source.url} · Postgres ${s.source.version})`
123
+ : `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
124
+ ["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
125
+ `(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
126
+ : `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"}`],
127
+ ];
128
+ }
129
+
130
+ // ------------------------------------------------------------------ the renderers: JSON in, HTML out
131
+
132
+ const rowsHtml = (rows) => `<dl class="facts">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>`;
133
+ const code = (s) => `<code>${esc(s)}</code>`;
134
+
135
+ /**
136
+ * A FAILURE AS THE CLI SAYS IT (src/cli/command.ts `failureText`): what is wrong after a ✗, then `why` and `fix` as
137
+ * rows, then Next: each command and what it does. `f` is the server's `failure` ({ code, message, why?, fix?, next }).
138
+ */
139
+ export function renderFailure(f) {
140
+ const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
141
+ const next = f.next ?? [];
142
+ return `<div class="failure" role="alert"><p class="message"><span class="mark" aria-hidden="true">✗</span> ${esc(f.message)}</p>` +
143
+ (rows.length ? `<dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl>` : "") +
144
+ (next.length ? `<p class="next-head">Next:</p><ul class="next">${next.map((s) => `<li>${code(s.command)}${s.what ? ` <span>${esc(s.what)}</span>` : ""}</li>`).join("")}</ul>` : "") +
145
+ "</div>";
146
+ }
147
+
148
+ /** How many of `xs` were in the last day, or why they could not be read. */
149
+ const countLine = (label, r, count, tail) => "failure" in r
150
+ ? `<p class="count">${esc(label)}: could not read them (${esc(firstLine(r.failure.message))}; ${code("mason logs")} has the detail)</p>`
151
+ : `<p class="count">${esc(label)}: <strong>${n(count(r))}</strong> in the last day${tail(r)}</p>`;
152
+
153
+ /**
154
+ * THE STATUS SECTION from `/api/state` (and the counts of `/api/slow`, `/api/refused`; each `{ failure }` when it could
155
+ * not be read): what `mason status` prints — the database, each added table's copy (its bar, percent and the line
156
+ * `mason wait` shows), the warnings, the slow and refused counts, and readiness. Never the engine's name.
157
+ */
158
+ export function renderStatus(s, requests = {}) {
159
+ const parts = [rowsHtml([...sourceRows(s), ...s.warnings.map((w) => ["Warning", w])])];
160
+ const picked = s.tables.filter((t) => t.added);
161
+ if (!picked.length) {
162
+ parts.push(`<p class="readiness">No table added yet (${n(s.tables.length)} in the database).</p>`,
163
+ `<p class="dim">${code("mason tables")} lists them; ${code("mason add <table>")} copies one.</p>`);
164
+ return parts.join("");
165
+ }
166
+ // as tableLines orders them: the finished first, in the order they finished, then the failed, the copying, the waiting
167
+ const rank = { done: 0, unsynced: 0, failed: 1, copying: 2, waiting: 3 };
168
+ const lines = picked.map((t) => ({ t, l: tableLine(t) }))
169
+ .sort((a, b) => rank[a.l.state] - rank[b.l.state] || (a.t.copy?.updatedAt ?? "").localeCompare(b.t.copy?.updatedAt ?? "") || a.t.table.localeCompare(b.t.table));
170
+ parts.push(`<h3>Tables (${picked.length} of ${s.tables.length} added)</h3>`,
171
+ `<table class="tables"><thead><tr><th scope="col">Table</th><th scope="col">Copy</th><th scope="col"></th></tr></thead><tbody>${lines.map(({ t, l }) =>
172
+ `<tr class="t-${l.state}"><th scope="row">${code(t.table)}</th>` +
173
+ `<td class="bar"><progress max="100" value="${Math.floor(Math.max(0, Math.min(1, l.fraction)) * 100)}"></progress> <span class="pct">${percent(l.fraction)}</span></td>` +
174
+ `<td class="what">${esc(l.what)}${t.error && t.error.includes("\n") ? `<details><summary>the whole error</summary><pre>${esc(t.error)}</pre></details>` : ""}</td></tr>`).join("")}</tbody></table>`);
175
+ if (requests.slow) parts.push(countLine("Slow queries", requests.slow, (r) => r.slow.length, (r) => ` (${n(r.slowMs / 1000)} s or more)`));
176
+ if (requests.refused) parts.push(countLine("Refused queries", requests.refused, (r) => r.total ?? r.refused.length, () => ""));
177
+ if (requests.slow || requests.refused) parts.push(`<p class="dim">${code("mason status")} lists them; ${code("mason logs --requests")} every one.</p>`);
178
+ const r = s.readiness;
179
+ if (r) {
180
+ if (r.ready) parts.push(`<p class="readiness ok">Ready: every added table is copied and served.</p>`);
181
+ else if (r.failed.length) parts.push(`<p class="readiness bad">Failed:</p><ul class="bullets">${r.failed.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`);
182
+ else parts.push(`<p class="readiness">Under way:</p><ul class="bullets">${r.waiting.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`);
183
+ }
184
+ return parts.join("");
185
+ }
186
+
187
+ /** A table just added (`/api/pick`'s answer, `mason add --json`'s `added` entry): the line and notes `mason add` prints. */
188
+ export function renderTableAdded(b) {
189
+ const l = addedLines(b, b.file);
190
+ return `<div class="done" role="status"><p class="headline">${esc(l.line)}</p>${l.notes.map((x) => `<p class="note">${esc(x)}</p>`).join("")}` +
191
+ `<button type="button" data-dismiss-outcome>Dismiss</button></div>`;
192
+ }
193
+
194
+ /** Tables just removed (`/api/remove`'s answer, `mason remove --json`'s shape): what `mason remove` prints. */
195
+ export function renderTablesRemoved(b) {
196
+ const r = removedReport(b.removed, b.file);
197
+ return `<div class="done" role="status"><p class="headline">${esc(r.headline)}</p>${rowsHtml(r.details)}${r.tips.map((x) => `<p class="tip">Tip: ${esc(x)}</p>`).join("")}` +
198
+ `<button type="button" data-dismiss-outcome>Dismiss</button></div>`;
199
+ }
200
+
201
+ const tick = (name, c, on) => `<label class="tick"><input type="checkbox" name="${name}" value="${esc(c.name)}"${on ? " checked" : ""}> ${code(c.name)} <span class="dim">${esc(c.type)}</span></label>`;
202
+
203
+ /** The form that adds a table with the columns ticked (`--columns`) and, for one with no primary key, its key (`--key`).
204
+ * The ticks live in the document while the person ticks (the tables part is held still meanwhile: `createApp`);
205
+ * `ticked`, what a refused submit sent, writes them back when the refusal is said under the form. */
206
+ function pickForm(t, ticked = null) {
207
+ const name = entryName(t.table);
208
+ const boxes = (field) => t.columns.map((c) => tick(field, c, ticked?.[field]?.includes(c.name) ?? false)).join("");
209
+ return `<form class="pick" data-pick-form="${esc(t.table)}" autocomplete="off">` +
210
+ (t.key.length ? "" : `<fieldset><legend>Its key: the columns that tell its rows apart, as ${code(`mason add ${name} --key`)}</legend>${boxes("key")}</fieldset>`) +
211
+ `<fieldset><legend>Its columns, as ${code(`mason add ${name} --columns`)} (none ticked: as ${code(`mason add ${name}`)}, every column)</legend>${boxes("columns")}</fieldset>` +
212
+ `<button type="submit">Add ${esc(name)}</button> <button type="button" data-cancel-choose>Cancel</button></form>`;
213
+ }
214
+
215
+ /**
216
+ * THE DATABASE'S TABLES from `/api/state` `tables` (`mason tables --json`'s list): a line each — its size, its key, and
217
+ * added or why it cannot be added as it is, as `mason tables` prints them — with what can be done to it: Add (every
218
+ * column, as `mason add <table>`) and Columns… (the form) for a table not added, Name its key… for one with no primary
219
+ * key, Remove (asking first, `ui.removing`) for an added one. `ui.choosing` is the table whose form is open (`ui.ticked`
220
+ * its boxes ticked: { columns, key }); `ui.outcome` what the last add or remove answered ({ table, added } |
221
+ * { table, removed } | { table, failure }), under its table.
222
+ */
223
+ export function renderTables(s, ui = {}) {
224
+ const { removing = null, choosing = null, outcome = null, ticked = null } = ui;
225
+ if (!s.tables.length) return `<p class="dim">${esc(s.source.database)} has no table this user can read.</p>`;
226
+ const under = (html) => `<tr class="under"><td colspan="5">${html}</td></tr>`;
227
+ const line = (t) => {
228
+ const f = tableFacts(t);
229
+ const name = entryName(t.table);
230
+ const act = t.added
231
+ ? removing === t.table
232
+ ? `<span class="confirm">Remove ${esc(name)}? ${esc(removedReport([t.table], "").tips[0])}.</span> ` +
233
+ `<button type="button" class="danger" data-confirm-remove-table="${esc(t.table)}">Remove ${esc(name)}</button> <button type="button" data-cancel-remove-table>Cancel</button>`
234
+ : `<button type="button" data-remove-table="${esc(t.table)}">Remove</button>`
235
+ : t.reason === null
236
+ ? `<button type="button" data-add-table="${esc(t.table)}">Add</button> <button type="button" data-choose="${esc(t.table)}">Columns…</button>`
237
+ : t.key.length ? "" : `<button type="button" data-choose="${esc(t.table)}">Name its key…</button>`;
238
+ const said = outcome?.table !== t.table ? ""
239
+ : under("failure" in outcome ? renderFailure(outcome.failure) : "added" in outcome ? renderTableAdded(outcome.added) : renderTablesRemoved(outcome.removed));
240
+ return `<tr class="${t.added ? "c-added" : t.reason ? "c-cannot" : "c-not"}"><th scope="row">${code(t.table)}</th>` +
241
+ `<td class="num">${esc(f.size)}</td><td>${esc(f.key)}</td><td class="state">${esc(f.state ?? "")}</td><td class="act">${act}</td></tr>` +
242
+ (choosing === t.table && !t.added ? under(pickForm(t, ticked)) : "") + said;
243
+ };
244
+ const added = s.tables.filter((t) => t.added).length;
245
+ // a refusal for a table the list does not have (dropped since the run read the catalog): said above the list
246
+ const stray = outcome !== null && "failure" in outcome && !s.tables.some((t) => t.table === outcome.table) ? renderFailure(outcome.failure) : "";
247
+ return `${stray}<p class="count">${n(added)} of ${n(s.tables.length)} added</p>` +
248
+ `<table class="catalog"><thead><tr><th scope="col">Table</th><th scope="col">Rows</th><th scope="col">Key</th><th scope="col"></th><th scope="col"></th></tr></thead>` +
249
+ `<tbody>${s.tables.map(line).join("")}</tbody></table>`;
250
+ }
251
+
252
+ /** The heading's line: the database the run copies, and its sources file's name when that is another. */
253
+ export function renderWhere(s) {
254
+ return `${esc(s.source.database)}${s.source.name && s.source.name !== s.source.database ? ` · source ${esc(s.source.name)}` : ""}`;
255
+ }
256
+
257
+ /**
258
+ * THE USERS from `/api/users` (`mason user list --json`'s shape): a line each — its name, when it was added, whether it
259
+ * connects — with a Remove button; the one being removed asks first (`confirming`, its name).
260
+ */
261
+ export function renderUsers(list, confirming = null) {
262
+ if (!list.users.length) return `<p class="dim">No users yet.</p>`;
263
+ return `<table class="users"><thead><tr><th scope="col">User</th><th scope="col">Added</th><th scope="col"></th></tr></thead><tbody>${list.users.map((u) =>
264
+ `<tr><th scope="row">${code(u.name)}${u.live ? "" : ` <span class="warn">does not connect: the API key was rotated since it was added</span>`}</th>` +
265
+ `<td class="dim">${esc(u.addedAt.slice(0, 16).replace("T", " "))} UTC · id ${esc(u.id)}</td>` +
266
+ `<td class="act">${confirming === u.name
267
+ ? `<span class="confirm">Remove ${esc(u.name)}? It connects no more, and the sessions it holds end at their next statement.</span> ` +
268
+ `<button type="button" class="danger" data-confirm-remove="${esc(u.name)}">Remove ${esc(u.name)}</button> <button type="button" data-cancel-remove>Cancel</button>`
269
+ : `<button type="button" data-remove="${esc(u.name)}">Remove</button>`}</td></tr>`).join("")}</tbody></table>`;
270
+ }
271
+
272
+ /**
273
+ * A USER JUST ADDED (`mason user add --json`'s shape): its password, shown this once, in a box with a copy button,
274
+ * beside the URL to paste — the rows `mason user add` prints. Mason keeps no copy, so it is never shown again.
275
+ */
276
+ export function renderAdded(a) {
277
+ const rows = [["url", a.url, "added-url"], ["user", a.user], ["password", a.password, "added-password"], ["database", a.database],
278
+ ...(a.host !== undefined ? [["host", a.host], ["port", String(a.port)]] : []), ...(a.sslmode && a.sslmode !== "disable" ? [["ssl mode", a.sslmode]] : [])]
279
+ .filter(([, v]) => v !== undefined);
280
+ return `<div class="added" role="status"><p class="headline">The user ${esc(a.user)} added: its password is shown this once</p>` +
281
+ `<dl class="facts">${rows.map(([k, v, id]) => `<dt>${esc(k)}</dt><dd>${id ? `<code id="${id}">${esc(v)}</code> <button type="button" data-copy="${id}">Copy</button>` : esc(v)}</dd>`).join("")}</dl>` +
282
+ `<p class="tip">Paste it into the tool now: Mason keeps no copy of the password, and this page will not show it again. Lost, remove the user and add it again.</p>` +
283
+ `<button type="button" data-dismiss-added>I have copied it</button></div>`;
284
+ }
285
+
286
+ /**
287
+ * WHY A REQUEST TO THE RUN FAILED, as a Failure: the server's own `failure` when it sent one; else what the status
288
+ * means here — 403, a cookie this run does not take (the run started again, with a new token); no answer at all, the
289
+ * run stopped. `status` null: fetch itself failed, `text` its error.
290
+ */
291
+ export function failureOf(status, text) {
292
+ if (status !== null) {
293
+ let body = null;
294
+ // the guard answers its refusals as plain text, not JSON: read as text below
295
+ try { body = JSON.parse(text); } catch { body = null; }
296
+ if (body && typeof body.failure === "object" && body.failure !== null) return body.failure;
297
+ if (status === 403 || status === 421) {
298
+ return { code: "refused", message: "this page's cookie is not the running Mason's", why: text.trim() || `HTTP ${status}`,
299
+ fix: "Mason was started again since this page was opened, and each run has its own token: mason ui, in the project's folder, opens this page again",
300
+ next: [{ command: "mason ui", what: "this page, for the Mason that is running" }] };
301
+ }
302
+ return { code: "failed", message: (body && typeof body.error === "string" ? body.error : text.trim()) || `HTTP ${status}`, next: [{ command: "mason logs", what: "the run's log: what it was doing" }] };
303
+ }
304
+ return { code: "unreachable", message: "Mason does not answer at this address", why: `the run stopped, or runs on another port now (${text})`,
305
+ fix: "mason start in the project's folder, then mason ui opens this page again: each run has its own address and token",
306
+ next: [{ command: "mason status", what: "whether Mason runs, and why it stopped" }, { command: "mason start", what: "start it" }, { command: "mason ui", what: "this page, for the Mason that is running" }] };
307
+ }
308
+
309
+ /** The last-updated line: when the page last heard from the run, in the reader's time. */
310
+ export const updatedText = (at) => `Updated ${at.toLocaleTimeString("en-GB")}`;
311
+
312
+ // ------------------------------------------------------------------ what the page knows and does
313
+
314
+ /**
315
+ * THE PAGE'S STATE AND ACTIONS, over `fetch` (the browser's, or a test's): `poll` reads the status and the users,
316
+ * `addUser` / `askRemove` / `cancelRemove` / `confirmRemove` / `dismissAdded` are the users' buttons, `addTable` /
317
+ * `choose` / `cancelChoose` / `askRemoveTable` / `cancelRemoveTable` / `confirmRemoveTable` / `dismissOutcome` the
318
+ * tables'; `view()` is the HTML of each part of the page, from what it knows now. Nothing here touches a document.
319
+ */
320
+ export function createApp(fetchFn, now = () => new Date()) {
321
+ const m = { state: null, requests: {}, users: null, usersFailure: null, pollFailure: null, actionFailure: null, added: null, confirming: null, updatedAt: null,
322
+ removing: null, choosing: null, outcome: null, ticked: null, held: null };
323
+ // WHILE A FORM IS OPEN OR A REMOVE ASKS, THE TABLES ARE RENDERED FROM THE STATE THEY WERE OPENED OVER (`held`): the
324
+ // run reads the catalog again every few seconds, and a live database's row estimates move, so a part written from
325
+ // each poll's state would be rewritten under the person — the form's ticks gone, the confirm's button replaced under
326
+ // the click. So the list is FROZEN for as long as a form stays open or a Remove asks — however long that is, with no
327
+ // refresh in between (its rows and its added marks may be minutes old; the Status part above is written from each
328
+ // poll as ever) — and is written from the latest state again once they Add, Cancel or confirm.
329
+ const hold = () => { m.held = m.choosing !== null || m.removing !== null ? m.held ?? m.state : null; if (m.choosing === null) m.ticked = null; };
330
+ /** One request: its JSON, or a Failure (`failure`) when it was refused or nothing answered. */
331
+ const ask = async (path, init) => {
332
+ let res;
333
+ try { res = await fetchFn(path, { credentials: "same-origin", ...init }); }
334
+ catch (e) { return { failure: failureOf(null, e?.message ?? String(e)) }; }
335
+ const text = await res.text();
336
+ if (!res.ok) return { failure: failureOf(res.status, text) };
337
+ try { return { body: JSON.parse(text) }; }
338
+ catch (e) { return { failure: { code: "failed", message: `the run's answer to ${path} is not JSON: ${e.message}` } }; }
339
+ };
340
+ const readUsers = async () => {
341
+ const u = await ask("/api/users");
342
+ if ("failure" in u) { m.usersFailure = u.failure; return; }
343
+ m.users = u.body; m.usersFailure = null;
344
+ if (m.confirming !== null && !m.users.users.some((x) => x.name === m.confirming)) m.confirming = null;
345
+ };
346
+ const json = (method, body) => ({ method, headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
347
+ const poll = async () => {
348
+ const [st, slow, refused] = await Promise.all([ask("/api/state"), ask("/api/slow"), ask("/api/refused")]);
349
+ if ("failure" in st) { m.pollFailure = st.failure; return; }
350
+ m.state = st.body; m.pollFailure = null; m.updatedAt = now();
351
+ m.requests = { slow: "failure" in slow ? slow : slow.body, refused: "failure" in refused ? refused : refused.body };
352
+ // a table removed or added elsewhere (mason remove, mason add) takes its confirm or its form with it
353
+ const added = (table) => m.state.tables.some((t) => t.table === table && t.added);
354
+ if (m.removing !== null && !added(m.removing)) m.removing = null;
355
+ if (m.choosing !== null && added(m.choosing)) m.choosing = null;
356
+ hold();
357
+ await readUsers();
358
+ };
359
+ return {
360
+ model: m,
361
+ poll,
362
+ /** Add a table (`mason add`): the answer under its line, then the state read again — the Status shows its copy starting. */
363
+ async addTable(table, columns = [], key = []) {
364
+ const r = await ask("/api/pick", json("POST", pickBody(table, columns, key)));
365
+ // refused: said under the form, which is written again with the boxes the person had ticked
366
+ if ("failure" in r) { m.outcome = { table, failure: r.failure }; if (m.choosing === table) m.ticked = { columns, key }; return false; }
367
+ m.outcome = { table, added: r.body }; m.choosing = null; hold();
368
+ await poll();
369
+ return true;
370
+ },
371
+ choose(table) { m.choosing = table; m.removing = null; m.ticked = null; hold(); },
372
+ cancelChoose() { m.choosing = null; hold(); },
373
+ askRemoveTable(table) { m.removing = table; m.choosing = null; hold(); },
374
+ cancelRemoveTable() { m.removing = null; hold(); },
375
+ /** Remove a table (`mason remove`), once asked: the answer under its line, then the state read again. */
376
+ async confirmRemoveTable(table) {
377
+ const r = await ask("/api/remove", json("POST", { tables: [table] }));
378
+ m.removing = null; hold();
379
+ if ("failure" in r) { m.outcome = { table, failure: r.failure }; return false; }
380
+ m.outcome = { table, removed: r.body };
381
+ await poll();
382
+ return true;
383
+ },
384
+ dismissOutcome() { m.outcome = null; },
385
+ async addUser(name) {
386
+ m.actionFailure = null;
387
+ const r = await ask("/api/users", json("POST", { name }));
388
+ if ("failure" in r) { m.actionFailure = r.failure; return false; }
389
+ m.added = r.body;
390
+ await readUsers();
391
+ return true;
392
+ },
393
+ askRemove(name) { m.confirming = name; m.actionFailure = null; },
394
+ cancelRemove() { m.confirming = null; },
395
+ async confirmRemove(name) {
396
+ m.actionFailure = null;
397
+ const r = await ask(`/api/users/${encodeURIComponent(name)}`, json("DELETE"));
398
+ m.confirming = null;
399
+ if ("failure" in r) { m.actionFailure = r.failure; return false; }
400
+ await readUsers();
401
+ return true;
402
+ },
403
+ dismissAdded() { m.added = null; },
404
+ view() {
405
+ return {
406
+ where: m.state ? renderWhere(m.state) : "",
407
+ updated: m.updatedAt ? esc(updatedText(m.updatedAt)) : "",
408
+ poll: m.pollFailure ? renderFailure(m.pollFailure) : "",
409
+ status: m.state ? renderStatus(m.state, m.requests) : m.pollFailure ? "" : `<p class="dim">Asking Mason…</p>`,
410
+ tables: m.state ? renderTables(m.held ?? m.state, { removing: m.removing, choosing: m.choosing, outcome: m.outcome, ticked: m.ticked }) : "",
411
+ users: m.usersFailure ? renderFailure(m.usersFailure) : m.users ? renderUsers(m.users, m.confirming) : "",
412
+ added: m.added ? renderAdded(m.added) : "",
413
+ action: m.actionFailure ? renderFailure(m.actionFailure) : "",
414
+ };
415
+ },
416
+ };
417
+ }
418
+
419
+ // ------------------------------------------------------------------ the browser
420
+
421
+ /** How often the status is asked while the tab is seen. */
422
+ export const POLL_MS = 2000;
423
+
424
+ /** Wire the app to the page: each part written from `view()`, the buttons and the form, the poll while the tab is seen. */
425
+ export function start(doc, win) {
426
+ const app = createApp((path, init) => win.fetch(path, init));
427
+ // what each part was last written, compared as written: the browser's innerHTML serialises attributes its own way
428
+ // (data-x becomes data-x=""), and a part rewritten at every poll replaces the button under a click
429
+ const written = new Map();
430
+ const paint = () => {
431
+ for (const [id, html] of Object.entries(app.view())) {
432
+ const el = doc.getElementById(id);
433
+ if (el === null || written.get(id) === html) continue;
434
+ el.innerHTML = html;
435
+ written.set(id, html);
436
+ }
437
+ };
438
+ let busy = false;
439
+ const poll = async () => {
440
+ if (busy || doc.visibilityState !== "visible") return;
441
+ busy = true;
442
+ try { await app.poll(); } finally { busy = false; paint(); }
443
+ };
444
+ doc.getElementById("add-user").addEventListener("submit", async (e) => {
445
+ e.preventDefault();
446
+ const input = e.target.elements.name;
447
+ if (await app.addUser(input.value.trim())) input.value = "";
448
+ paint();
449
+ });
450
+ // a table's form is written into the tables part: its submit is caught where it bubbles to, the ticked boxes sent as
451
+ // they are (FormData's getAll: the values of the boxes ticked, in the page's order)
452
+ doc.addEventListener("submit", async (e) => {
453
+ const f = e.target;
454
+ if (f.dataset?.pickForm === undefined) return;
455
+ e.preventDefault();
456
+ const ticked = new win.FormData(f);
457
+ await app.addTable(f.dataset.pickForm, ticked.getAll("columns"), ticked.getAll("key"));
458
+ paint();
459
+ });
460
+ doc.addEventListener("click", async (e) => {
461
+ const b = e.target.closest?.("button");
462
+ if (!b) return;
463
+ if (b.dataset.remove !== undefined) app.askRemove(b.dataset.remove);
464
+ else if (b.dataset.cancelRemove !== undefined) app.cancelRemove();
465
+ else if (b.dataset.confirmRemove !== undefined) await app.confirmRemove(b.dataset.confirmRemove);
466
+ else if (b.dataset.dismissAdded !== undefined) app.dismissAdded();
467
+ else if (b.dataset.addTable !== undefined) await app.addTable(b.dataset.addTable);
468
+ else if (b.dataset.choose !== undefined) app.choose(b.dataset.choose);
469
+ else if (b.dataset.cancelChoose !== undefined) app.cancelChoose();
470
+ else if (b.dataset.removeTable !== undefined) app.askRemoveTable(b.dataset.removeTable);
471
+ else if (b.dataset.cancelRemoveTable !== undefined) app.cancelRemoveTable();
472
+ else if (b.dataset.confirmRemoveTable !== undefined) await app.confirmRemoveTable(b.dataset.confirmRemoveTable);
473
+ else if (b.dataset.dismissOutcome !== undefined) app.dismissOutcome();
474
+ else if (b.dataset.copy !== undefined) {
475
+ const text = doc.getElementById(b.dataset.copy)?.textContent ?? "";
476
+ try { await win.navigator.clipboard.writeText(text); b.textContent = "Copied"; }
477
+ catch (err) {
478
+ // the clipboard refused (a browser setting): the text is selected, for the reader's own copy
479
+ win.getSelection()?.selectAllChildren(doc.getElementById(b.dataset.copy));
480
+ b.textContent = `Select and copy it (${err?.message ?? err})`;
481
+ }
482
+ return;
483
+ }
484
+ paint();
485
+ });
486
+ doc.addEventListener("visibilitychange", () => { void poll(); });
487
+ win.setInterval(() => { void poll(); }, POLL_MS);
488
+ void poll();
489
+ }
490
+
491
+ if (typeof document !== "undefined" && typeof window !== "undefined") start(document, window);
@@ -0,0 +1,44 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Mason</title>
7
+ <!-- no favicon request: an empty one, inline (img-src allows data:) -->
8
+ <link rel="icon" href="data:,">
9
+ <link rel="stylesheet" href="/app.css">
10
+ <script type="module" src="/app.js"></script>
11
+ </head>
12
+ <body>
13
+ <header>
14
+ <h1>Mason</h1>
15
+ <p id="where" class="where"></p>
16
+ <p id="updated" class="updated" aria-live="polite"></p>
17
+ </header>
18
+ <div id="poll"></div>
19
+ <main>
20
+ <section aria-labelledby="status-h">
21
+ <h2 id="status-h">Status</h2>
22
+ <div id="status"><p class="dim">Asking Mason…</p></div>
23
+ </section>
24
+ <section aria-labelledby="tables-h">
25
+ <h2 id="tables-h">Tables</h2>
26
+ <p class="lead">The database's tables, as <code>mason tables</code> lists them. Add one and Mason copies it: it is added to the sources file, as <code>mason add</code> does; Remove takes it out and drops its copy, as <code>mason remove</code> does.</p>
27
+ <div id="tables"></div>
28
+ </section>
29
+ <section aria-labelledby="users-h">
30
+ <h2 id="users-h">Users</h2>
31
+ <p class="lead">A user of the Postgres API: a name and a password that does not expire, for a BI tool or an app. The same users as <code>mason user</code>.</p>
32
+ <div id="added"></div>
33
+ <form id="add-user" autocomplete="off">
34
+ <label for="user-name">Name</label>
35
+ <input id="user-name" name="name" required placeholder="metabase" spellcheck="false" autocapitalize="off">
36
+ <button type="submit">Add a user</button>
37
+ </form>
38
+ <div id="action"></div>
39
+ <div id="users"></div>
40
+ </section>
41
+ </main>
42
+ <noscript><p>This page needs JavaScript. <code>mason status</code>, <code>mason tables</code> and <code>mason user list</code> say the same in a terminal.</p></noscript>
43
+ </body>
44
+ </html>
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@mason-data/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.0",
4
+ "description": "mason start: point Mason at your Postgres and pick the tables to copy. Fast analytics for the database your app already has.",
5
+ "keywords": ["mason", "analytics", "postgres", "duckdb", "cli"],
6
+ "homepage": "https://masondata.ai",
7
+ "repository": { "type": "git", "url": "git+https://github.com/embeddable-hq/mason-data.git", "directory": "cli" },
8
+ "license": "Apache-2.0",
9
+ "type": "module",
10
+ "bin": { "mason": "dist/src/cli/main.js" },
11
+ "files": ["dist", "README.md", "LICENSE"],
12
+ "engines": { "node": ">=22.13" },
13
+ "scripts": {
14
+ "build": "node build.ts",
15
+ "prepack": "node build.ts"
16
+ },
17
+ "dependencies": {
18
+ "@duckdb/node-api": "^2.0.0-r.1",
19
+ "@electric-sql/pglite": "^0.5.10",
20
+ "libpg-query": "^18.1.5",
21
+ "pgsql-deparser": "18.3.8",
22
+ "yaml": "^2.6.0"
23
+ }
24
+ }