@mason-data/cli 0.0.0-stage → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,336 @@
|
|
|
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, two 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
|
+
* USERS GET /api/users, POST /api/users, DELETE /api/users/<name>: what `mason user list|add|remove` does
|
|
8
|
+
*
|
|
9
|
+
* Plain JavaScript, an ES module, no framework and no build step: the server serves this file as it is, under a
|
|
10
|
+
* `default-src 'self'` policy. The renderers are PURE FUNCTIONS OF THE JSON, returning HTML strings (every value
|
|
11
|
+
* escaped), exported so tests/web-page.test.ts runs them under node; `createApp` holds what the page knows and does,
|
|
12
|
+
* over a `fetch` it is handed; `start` (a browser only) wires them to the document. The page never holds the run's
|
|
13
|
+
* token: the browser's cookie carries it (src/cli/security.ts).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ------------------------------------------------------------------ helpers, DOM-free
|
|
17
|
+
|
|
18
|
+
/** A value as HTML text: every character that means something in HTML escaped. */
|
|
19
|
+
export const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
20
|
+
|
|
21
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
22
|
+
const firstLine = (s) => s.split("\n")[0].trim();
|
|
23
|
+
|
|
24
|
+
/** 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). */
|
|
25
|
+
export function duration(seconds) {
|
|
26
|
+
const s = Math.max(1, Math.round(seconds));
|
|
27
|
+
if (s < 90) return `${s} s`;
|
|
28
|
+
const m = Math.round(s / 60);
|
|
29
|
+
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A share (0 to 1) as the commands print it beside a bar: whole percent, rounded down (`12%`). */
|
|
33
|
+
export const percent = (share) => `${Math.floor(Math.max(0, Math.min(1, share)) * 100)}%`;
|
|
34
|
+
|
|
35
|
+
/** The time a copy has left, as the commands print it: `~12 min left`; "" when nothing is measured yet. */
|
|
36
|
+
export const leftText = (secondsLeft) => secondsLeft === null || secondsLeft === undefined ? "" : `~${duration(secondsLeft)} left`;
|
|
37
|
+
|
|
38
|
+
/** A table as the commands name it: `orders` in public, else `schema.table` (src/cli/sources.ts `entryName`). */
|
|
39
|
+
export const entryName = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What a copying table's line says, and how full its bar is: src/cli/readiness.ts `copyingText`, line for line, so the
|
|
43
|
+
* page and `mason status` say one thing (tests/web-page.test.ts holds the two equal).
|
|
44
|
+
*/
|
|
45
|
+
export function copyingText(copied, rowsEstimate, p) {
|
|
46
|
+
const shown = copied + (p?.rowsReading ?? 0);
|
|
47
|
+
const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
|
|
48
|
+
const past = estimate !== null && shown > estimate;
|
|
49
|
+
const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
|
|
50
|
+
const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
|
|
51
|
+
const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
|
|
52
|
+
if (p === undefined) return { fraction, text: rowsText(copied) };
|
|
53
|
+
const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
|
|
54
|
+
const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
|
|
55
|
+
if (p.rowsPerSecond === null) {
|
|
56
|
+
const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
|
|
57
|
+
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to copy`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
58
|
+
}
|
|
59
|
+
const parts = [rowsText(shown)];
|
|
60
|
+
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
61
|
+
if (allIn) parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
|
|
62
|
+
else parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
|
|
63
|
+
parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
|
|
64
|
+
if (!allIn && p.secondsLeft !== null) parts.push(leftText(p.secondsLeft));
|
|
65
|
+
return { fraction, text: parts.join(" · ") };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* AN ADDED TABLE'S LINE, as `mason status` and `mason wait` print it after its bar (src/cli/commands.ts `tableLines`):
|
|
70
|
+
* its state (done, unsynced, failed, copying, waiting), how full its bar is, and what it says.
|
|
71
|
+
*/
|
|
72
|
+
export function tableLine(t) {
|
|
73
|
+
if (t.error) return { state: "failed", fraction: 1, what: `its copy failed: ${firstLine(t.error)}` };
|
|
74
|
+
if (t.copy?.phase === "copying") {
|
|
75
|
+
const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
|
|
76
|
+
return { state: "copying", fraction: c.fraction ?? 0, what: /^\d/.test(c.text) ? `copying ${c.text}` : c.text };
|
|
77
|
+
}
|
|
78
|
+
if (!t.copy) return { state: "waiting", fraction: 0, what: "waiting for its copy to start" };
|
|
79
|
+
if (t.unsynced) {
|
|
80
|
+
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` };
|
|
81
|
+
}
|
|
82
|
+
return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · copied${t.copy.retired ? ` · ${t.copy.retired}` : ""}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `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. */
|
|
86
|
+
export function sourceRows(s) {
|
|
87
|
+
return [
|
|
88
|
+
...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
|
|
89
|
+
["Database", `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
|
|
90
|
+
["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
|
|
91
|
+
`(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
|
|
92
|
+
: `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · mason start --replication reads the database's change log instead`],
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ------------------------------------------------------------------ the renderers: JSON in, HTML out
|
|
97
|
+
|
|
98
|
+
const rowsHtml = (rows) => `<dl class="facts">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>`;
|
|
99
|
+
const code = (s) => `<code>${esc(s)}</code>`;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A FAILURE AS THE CLI SAYS IT (src/cli/command.ts `failureText`): what is wrong after a ✗, then `why` and `fix` as
|
|
103
|
+
* rows, then Next: each command and what it does. `f` is the server's `failure` ({ code, message, why?, fix?, next }).
|
|
104
|
+
*/
|
|
105
|
+
export function renderFailure(f) {
|
|
106
|
+
const rows = [...(f.why ? [["why", f.why]] : []), ...(f.fix ? [["fix", f.fix]] : [])];
|
|
107
|
+
const next = f.next ?? [];
|
|
108
|
+
return `<div class="failure" role="alert"><p class="message"><span class="mark" aria-hidden="true">✗</span> ${esc(f.message)}</p>` +
|
|
109
|
+
(rows.length ? `<dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl>` : "") +
|
|
110
|
+
(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>` : "") +
|
|
111
|
+
"</div>";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** How many of `xs` were in the last day, or why they could not be read. */
|
|
115
|
+
const countLine = (label, r, count, tail) => "failure" in r
|
|
116
|
+
? `<p class="count">${esc(label)}: could not read them (${esc(firstLine(r.failure.message))}; ${code("mason logs")} has the detail)</p>`
|
|
117
|
+
: `<p class="count">${esc(label)}: <strong>${n(count(r))}</strong> in the last day${tail(r)}</p>`;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* THE STATUS SECTION from `/api/state` (and the counts of `/api/slow`, `/api/refused`; each `{ failure }` when it could
|
|
121
|
+
* not be read): what `mason status` prints — the database, each added table's copy (its bar, percent and the line
|
|
122
|
+
* `mason wait` shows), the warnings, the slow and refused counts, and readiness. Never the engine's name.
|
|
123
|
+
*/
|
|
124
|
+
export function renderStatus(s, requests = {}) {
|
|
125
|
+
const parts = [rowsHtml([...sourceRows(s), ...s.warnings.map((w) => ["Warning", w])])];
|
|
126
|
+
const picked = s.tables.filter((t) => t.added);
|
|
127
|
+
if (!picked.length) {
|
|
128
|
+
parts.push(`<p class="readiness">No table added yet (${n(s.tables.length)} in the database).</p>`,
|
|
129
|
+
`<p class="dim">${code("mason tables")} lists them; ${code("mason add <table>")} copies one.</p>`);
|
|
130
|
+
return parts.join("");
|
|
131
|
+
}
|
|
132
|
+
// as tableLines orders them: the finished first, in the order they finished, then the failed, the copying, the waiting
|
|
133
|
+
const rank = { done: 0, unsynced: 0, failed: 1, copying: 2, waiting: 3 };
|
|
134
|
+
const lines = picked.map((t) => ({ t, l: tableLine(t) }))
|
|
135
|
+
.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));
|
|
136
|
+
parts.push(`<h3>Tables (${picked.length} of ${s.tables.length} added)</h3>`,
|
|
137
|
+
`<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 }) =>
|
|
138
|
+
`<tr class="t-${l.state}"><th scope="row">${code(t.table)}</th>` +
|
|
139
|
+
`<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>` +
|
|
140
|
+
`<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>`);
|
|
141
|
+
if (requests.slow) parts.push(countLine("Slow queries", requests.slow, (r) => r.slow.length, (r) => ` (${n(r.slowMs / 1000)} s or more)`));
|
|
142
|
+
if (requests.refused) parts.push(countLine("Refused queries", requests.refused, (r) => r.total ?? r.refused.length, () => ""));
|
|
143
|
+
if (requests.slow || requests.refused) parts.push(`<p class="dim">${code("mason status")} lists them; ${code("mason logs --requests")} every one.</p>`);
|
|
144
|
+
const r = s.readiness;
|
|
145
|
+
if (r) {
|
|
146
|
+
if (r.ready) parts.push(`<p class="readiness ok">Ready: every added table is copied and served.</p>`);
|
|
147
|
+
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>`);
|
|
148
|
+
else parts.push(`<p class="readiness">Under way:</p><ul class="bullets">${r.waiting.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`);
|
|
149
|
+
}
|
|
150
|
+
return parts.join("");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The heading's line: the database the run copies, and its sources file's name when that is another. */
|
|
154
|
+
export function renderWhere(s) {
|
|
155
|
+
return `${esc(s.source.database)}${s.source.name && s.source.name !== s.source.database ? ` · source ${esc(s.source.name)}` : ""}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* THE USERS from `/api/users` (`mason user list --json`'s shape): a line each — its name, when it was added, whether it
|
|
160
|
+
* connects — with a Remove button; the one being removed asks first (`confirming`, its name).
|
|
161
|
+
*/
|
|
162
|
+
export function renderUsers(list, confirming = null) {
|
|
163
|
+
if (!list.users.length) return `<p class="dim">No users yet.</p>`;
|
|
164
|
+
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) =>
|
|
165
|
+
`<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>` +
|
|
166
|
+
`<td class="dim">${esc(u.addedAt.slice(0, 16).replace("T", " "))} UTC · id ${esc(u.id)}</td>` +
|
|
167
|
+
`<td class="act">${confirming === u.name
|
|
168
|
+
? `<span class="confirm">Remove ${esc(u.name)}? It connects no more, and the sessions it holds end at their next statement.</span> ` +
|
|
169
|
+
`<button type="button" class="danger" data-confirm-remove="${esc(u.name)}">Remove ${esc(u.name)}</button> <button type="button" data-cancel-remove>Cancel</button>`
|
|
170
|
+
: `<button type="button" data-remove="${esc(u.name)}">Remove</button>`}</td></tr>`).join("")}</tbody></table>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A USER JUST ADDED (`mason user add --json`'s shape): its password, shown this once, in a box with a copy button,
|
|
175
|
+
* beside the URL to paste — the rows `mason user add` prints. Mason keeps no copy, so it is never shown again.
|
|
176
|
+
*/
|
|
177
|
+
export function renderAdded(a) {
|
|
178
|
+
const rows = [["url", a.url, "added-url"], ["user", a.user], ["password", a.password, "added-password"], ["database", a.database],
|
|
179
|
+
...(a.host !== undefined ? [["host", a.host], ["port", String(a.port)]] : []), ...(a.sslmode && a.sslmode !== "disable" ? [["ssl mode", a.sslmode]] : [])]
|
|
180
|
+
.filter(([, v]) => v !== undefined);
|
|
181
|
+
return `<div class="added" role="status"><p class="headline">The user ${esc(a.user)} added: its password is shown this once</p>` +
|
|
182
|
+
`<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>` +
|
|
183
|
+
`<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>` +
|
|
184
|
+
`<button type="button" data-dismiss-added>I have copied it</button></div>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* WHY A REQUEST TO THE RUN FAILED, as a Failure: the server's own `failure` when it sent one; else what the status
|
|
189
|
+
* means here — 403, a cookie this run does not take (the run started again, with a new token); no answer at all, the
|
|
190
|
+
* run stopped. `status` null: fetch itself failed, `text` its error.
|
|
191
|
+
*/
|
|
192
|
+
export function failureOf(status, text) {
|
|
193
|
+
if (status !== null) {
|
|
194
|
+
let body = null;
|
|
195
|
+
// the guard answers its refusals as plain text, not JSON: read as text below
|
|
196
|
+
try { body = JSON.parse(text); } catch { body = null; }
|
|
197
|
+
if (body && typeof body.failure === "object" && body.failure !== null) return body.failure;
|
|
198
|
+
if (status === 403 || status === 421) {
|
|
199
|
+
return { code: "refused", message: "this page's cookie is not the running Mason's", why: text.trim() || `HTTP ${status}`,
|
|
200
|
+
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",
|
|
201
|
+
next: [{ command: "mason ui", what: "this page, for the Mason that is running" }] };
|
|
202
|
+
}
|
|
203
|
+
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" }] };
|
|
204
|
+
}
|
|
205
|
+
return { code: "unreachable", message: "Mason does not answer at this address", why: `the run stopped, or runs on another port now (${text})`,
|
|
206
|
+
fix: "mason start in the project's folder, then mason ui opens this page again: each run has its own address and token",
|
|
207
|
+
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" }] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The last-updated line: when the page last heard from the run, in the reader's time. */
|
|
211
|
+
export const updatedText = (at) => `Updated ${at.toLocaleTimeString("en-GB")}`;
|
|
212
|
+
|
|
213
|
+
// ------------------------------------------------------------------ what the page knows and does
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* THE PAGE'S STATE AND ACTIONS, over `fetch` (the browser's, or a test's): `poll` reads the status and the users,
|
|
217
|
+
* `addUser` / `askRemove` / `cancelRemove` / `confirmRemove` / `dismissAdded` are the users' buttons; `view()` is the
|
|
218
|
+
* HTML of each part of the page, from what it knows now. Nothing here touches a document.
|
|
219
|
+
*/
|
|
220
|
+
export function createApp(fetchFn, now = () => new Date()) {
|
|
221
|
+
const m = { state: null, requests: {}, users: null, usersFailure: null, pollFailure: null, actionFailure: null, added: null, confirming: null, updatedAt: null };
|
|
222
|
+
/** One request: its JSON, or a Failure (`failure`) when it was refused or nothing answered. */
|
|
223
|
+
const ask = async (path, init) => {
|
|
224
|
+
let res;
|
|
225
|
+
try { res = await fetchFn(path, { credentials: "same-origin", ...init }); }
|
|
226
|
+
catch (e) { return { failure: failureOf(null, e?.message ?? String(e)) }; }
|
|
227
|
+
const text = await res.text();
|
|
228
|
+
if (!res.ok) return { failure: failureOf(res.status, text) };
|
|
229
|
+
try { return { body: JSON.parse(text) }; }
|
|
230
|
+
catch (e) { return { failure: { code: "failed", message: `the run's answer to ${path} is not JSON: ${e.message}` } }; }
|
|
231
|
+
};
|
|
232
|
+
const readUsers = async () => {
|
|
233
|
+
const u = await ask("/api/users");
|
|
234
|
+
if ("failure" in u) { m.usersFailure = u.failure; return; }
|
|
235
|
+
m.users = u.body; m.usersFailure = null;
|
|
236
|
+
if (m.confirming !== null && !m.users.users.some((x) => x.name === m.confirming)) m.confirming = null;
|
|
237
|
+
};
|
|
238
|
+
const json = (method, body) => ({ method, headers: { "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
239
|
+
return {
|
|
240
|
+
model: m,
|
|
241
|
+
async poll() {
|
|
242
|
+
const [st, slow, refused] = await Promise.all([ask("/api/state"), ask("/api/slow"), ask("/api/refused")]);
|
|
243
|
+
if ("failure" in st) { m.pollFailure = st.failure; return; }
|
|
244
|
+
m.state = st.body; m.pollFailure = null; m.updatedAt = now();
|
|
245
|
+
m.requests = { slow: "failure" in slow ? slow : slow.body, refused: "failure" in refused ? refused : refused.body };
|
|
246
|
+
await readUsers();
|
|
247
|
+
},
|
|
248
|
+
async addUser(name) {
|
|
249
|
+
m.actionFailure = null;
|
|
250
|
+
const r = await ask("/api/users", json("POST", { name }));
|
|
251
|
+
if ("failure" in r) { m.actionFailure = r.failure; return false; }
|
|
252
|
+
m.added = r.body;
|
|
253
|
+
await readUsers();
|
|
254
|
+
return true;
|
|
255
|
+
},
|
|
256
|
+
askRemove(name) { m.confirming = name; m.actionFailure = null; },
|
|
257
|
+
cancelRemove() { m.confirming = null; },
|
|
258
|
+
async confirmRemove(name) {
|
|
259
|
+
m.actionFailure = null;
|
|
260
|
+
const r = await ask(`/api/users/${encodeURIComponent(name)}`, json("DELETE"));
|
|
261
|
+
m.confirming = null;
|
|
262
|
+
if ("failure" in r) { m.actionFailure = r.failure; return false; }
|
|
263
|
+
await readUsers();
|
|
264
|
+
return true;
|
|
265
|
+
},
|
|
266
|
+
dismissAdded() { m.added = null; },
|
|
267
|
+
view() {
|
|
268
|
+
return {
|
|
269
|
+
where: m.state ? renderWhere(m.state) : "",
|
|
270
|
+
updated: m.updatedAt ? esc(updatedText(m.updatedAt)) : "",
|
|
271
|
+
poll: m.pollFailure ? renderFailure(m.pollFailure) : "",
|
|
272
|
+
status: m.state ? renderStatus(m.state, m.requests) : m.pollFailure ? "" : `<p class="dim">Asking Mason…</p>`,
|
|
273
|
+
users: m.usersFailure ? renderFailure(m.usersFailure) : m.users ? renderUsers(m.users, m.confirming) : "",
|
|
274
|
+
added: m.added ? renderAdded(m.added) : "",
|
|
275
|
+
action: m.actionFailure ? renderFailure(m.actionFailure) : "",
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ------------------------------------------------------------------ the browser
|
|
282
|
+
|
|
283
|
+
/** How often the status is asked while the tab is seen. */
|
|
284
|
+
export const POLL_MS = 2000;
|
|
285
|
+
|
|
286
|
+
/** Wire the app to the page: each part written from `view()`, the buttons and the form, the poll while the tab is seen. */
|
|
287
|
+
export function start(doc, win) {
|
|
288
|
+
const app = createApp((path, init) => win.fetch(path, init));
|
|
289
|
+
// what each part was last written, compared as written: the browser's innerHTML serialises attributes its own way
|
|
290
|
+
// (data-x becomes data-x=""), and a part rewritten at every poll replaces the button under a click
|
|
291
|
+
const written = new Map();
|
|
292
|
+
const paint = () => {
|
|
293
|
+
for (const [id, html] of Object.entries(app.view())) {
|
|
294
|
+
const el = doc.getElementById(id);
|
|
295
|
+
if (el === null || written.get(id) === html) continue;
|
|
296
|
+
el.innerHTML = html;
|
|
297
|
+
written.set(id, html);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
let busy = false;
|
|
301
|
+
const poll = async () => {
|
|
302
|
+
if (busy || doc.visibilityState !== "visible") return;
|
|
303
|
+
busy = true;
|
|
304
|
+
try { await app.poll(); } finally { busy = false; paint(); }
|
|
305
|
+
};
|
|
306
|
+
doc.getElementById("add-user").addEventListener("submit", async (e) => {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
const input = e.target.elements.name;
|
|
309
|
+
if (await app.addUser(input.value.trim())) input.value = "";
|
|
310
|
+
paint();
|
|
311
|
+
});
|
|
312
|
+
doc.addEventListener("click", async (e) => {
|
|
313
|
+
const b = e.target.closest?.("button");
|
|
314
|
+
if (!b) return;
|
|
315
|
+
if (b.dataset.remove !== undefined) app.askRemove(b.dataset.remove);
|
|
316
|
+
else if (b.dataset.cancelRemove !== undefined) app.cancelRemove();
|
|
317
|
+
else if (b.dataset.confirmRemove !== undefined) await app.confirmRemove(b.dataset.confirmRemove);
|
|
318
|
+
else if (b.dataset.dismissAdded !== undefined) app.dismissAdded();
|
|
319
|
+
else if (b.dataset.copy !== undefined) {
|
|
320
|
+
const text = doc.getElementById(b.dataset.copy)?.textContent ?? "";
|
|
321
|
+
try { await win.navigator.clipboard.writeText(text); b.textContent = "Copied"; }
|
|
322
|
+
catch (err) {
|
|
323
|
+
// the clipboard refused (a browser setting): the text is selected, for the reader's own copy
|
|
324
|
+
win.getSelection()?.selectAllChildren(doc.getElementById(b.dataset.copy));
|
|
325
|
+
b.textContent = `Select and copy it (${err?.message ?? err})`;
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
paint();
|
|
330
|
+
});
|
|
331
|
+
doc.addEventListener("visibilitychange", () => { void poll(); });
|
|
332
|
+
win.setInterval(() => { void poll(); }, POLL_MS);
|
|
333
|
+
void poll();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (typeof document !== "undefined" && typeof window !== "undefined") start(document, window);
|
|
@@ -0,0 +1,39 @@
|
|
|
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="users-h">
|
|
25
|
+
<h2 id="users-h">Users</h2>
|
|
26
|
+
<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>
|
|
27
|
+
<div id="added"></div>
|
|
28
|
+
<form id="add-user" autocomplete="off">
|
|
29
|
+
<label for="user-name">Name</label>
|
|
30
|
+
<input id="user-name" name="name" required placeholder="metabase" spellcheck="false" autocapitalize="off">
|
|
31
|
+
<button type="submit">Add a user</button>
|
|
32
|
+
</form>
|
|
33
|
+
<div id="action"></div>
|
|
34
|
+
<div id="users"></div>
|
|
35
|
+
</section>
|
|
36
|
+
</main>
|
|
37
|
+
<noscript><p>This page needs JavaScript. <code>mason status</code> and <code>mason user list</code> say the same in a terminal.</p></noscript>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mason-data/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.6.2",
|
|
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
|
+
}
|