@mason-data/cli 0.6.2 → 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 (45) hide show
  1. package/README.md +71 -12
  2. package/dist/src/api/postgres/catalog.js +36 -1
  3. package/dist/src/api/postgres/server.js +133 -32
  4. package/dist/src/cdc/pg-wire.js +144 -38
  5. package/dist/src/cli/background.js +29 -5
  6. package/dist/src/cli/command.js +4 -3
  7. package/dist/src/cli/commands.js +259 -127
  8. package/dist/src/cli/demo.js +149 -0
  9. package/dist/src/cli/dev.js +113 -20
  10. package/dist/src/cli/doctor.js +6 -3
  11. package/dist/src/cli/guides.js +32 -8
  12. package/dist/src/cli/init.js +58 -18
  13. package/dist/src/cli/machine.js +8 -3
  14. package/dist/src/cli/main.js +2 -2
  15. package/dist/src/cli/mcp.js +726 -0
  16. package/dist/src/cli/preflight.js +16 -7
  17. package/dist/src/cli/project.js +5 -3
  18. package/dist/src/cli/registry.js +8 -5
  19. package/dist/src/cli/run-server.js +29 -106
  20. package/dist/src/cli/serve.js +6 -1
  21. package/dist/src/cli/source-edit.js +116 -0
  22. package/dist/src/cli/sources.js +6 -4
  23. package/dist/src/demo/seed.js +237 -0
  24. package/dist/src/demo/source.js +655 -0
  25. package/dist/src/duck/distinct-key.js +228 -0
  26. package/dist/src/duck/executor-worker.js +7 -2
  27. package/dist/src/duck/executor.js +67 -26
  28. package/dist/src/duck/explain.js +128 -9
  29. package/dist/src/duck/functions.js +60 -5
  30. package/dist/src/duck/gaps.js +9 -4
  31. package/dist/src/duck/jsonb-order.js +175 -0
  32. package/dist/src/duck/jsonb.js +10 -8
  33. package/dist/src/duck/kept-route.js +82 -2
  34. package/dist/src/duck/kept.js +206 -39
  35. package/dist/src/duck/pgtree.js +40 -0
  36. package/dist/src/duck/plan.js +21 -8
  37. package/dist/src/duck/rewrite.js +1168 -68
  38. package/dist/src/duck/store.js +132 -56
  39. package/dist/src/duck/sync.js +90 -25
  40. package/dist/src/duck/verified.js +6 -0
  41. package/dist/src/lib/timezone.js +3 -2
  42. package/dist/src/web/app.css +11 -0
  43. package/dist/src/web/app.js +167 -12
  44. package/dist/src/web/index.html +6 -1
  45. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
1
  /**
2
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
3
+ * one page, three sections, rendered from the run's command server's JSON (src/cli/run-server.ts) in the words the
4
4
  * commands print —
5
5
  *
6
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
7
8
  * USERS GET /api/users, POST /api/users, DELETE /api/users/<name>: what `mason user list|add|remove` does
8
9
  *
9
10
  * Plain JavaScript, an ES module, no framework and no build step: the server serves this file as it is, under a
@@ -82,14 +83,47 @@ export function tableLine(t) {
82
83
  return { state: "done", fraction: 1, what: `${n(t.copy.rowsCopied ?? 0)} rows · copied${t.copy.retired ? ` · ${t.copy.retired}` : ""}` };
83
84
  }
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
+
85
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. */
86
119
  export function sourceRows(s) {
87
120
  return [
88
121
  ...(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" : ""}`],
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" : ""}`],
90
124
  ["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
91
125
  `(${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`],
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"}`],
93
127
  ];
94
128
  }
95
129
 
@@ -150,6 +184,71 @@ export function renderStatus(s, requests = {}) {
150
184
  return parts.join("");
151
185
  }
152
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
+
153
252
  /** The heading's line: the database the run copies, and its sources file's name when that is another. */
154
253
  export function renderWhere(s) {
155
254
  return `${esc(s.source.database)}${s.source.name && s.source.name !== s.source.database ? ` · source ${esc(s.source.name)}` : ""}`;
@@ -214,11 +313,20 @@ export const updatedText = (at) => `Updated ${at.toLocaleTimeString("en-GB")}`;
214
313
 
215
314
  /**
216
315
  * 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.
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.
219
319
  */
220
320
  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 };
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; };
222
330
  /** One request: its JSON, or a Failure (`failure`) when it was refused or nothing answered. */
223
331
  const ask = async (path, init) => {
224
332
  let res;
@@ -236,15 +344,44 @@ export function createApp(fetchFn, now = () => new Date()) {
236
344
  if (m.confirming !== null && !m.users.users.some((x) => x.name === m.confirming)) m.confirming = null;
237
345
  };
238
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
+ };
239
359
  return {
240
360
  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();
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;
247
383
  },
384
+ dismissOutcome() { m.outcome = null; },
248
385
  async addUser(name) {
249
386
  m.actionFailure = null;
250
387
  const r = await ask("/api/users", json("POST", { name }));
@@ -270,6 +407,7 @@ export function createApp(fetchFn, now = () => new Date()) {
270
407
  updated: m.updatedAt ? esc(updatedText(m.updatedAt)) : "",
271
408
  poll: m.pollFailure ? renderFailure(m.pollFailure) : "",
272
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 }) : "",
273
411
  users: m.usersFailure ? renderFailure(m.usersFailure) : m.users ? renderUsers(m.users, m.confirming) : "",
274
412
  added: m.added ? renderAdded(m.added) : "",
275
413
  action: m.actionFailure ? renderFailure(m.actionFailure) : "",
@@ -309,6 +447,16 @@ export function start(doc, win) {
309
447
  if (await app.addUser(input.value.trim())) input.value = "";
310
448
  paint();
311
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
+ });
312
460
  doc.addEventListener("click", async (e) => {
313
461
  const b = e.target.closest?.("button");
314
462
  if (!b) return;
@@ -316,6 +464,13 @@ export function start(doc, win) {
316
464
  else if (b.dataset.cancelRemove !== undefined) app.cancelRemove();
317
465
  else if (b.dataset.confirmRemove !== undefined) await app.confirmRemove(b.dataset.confirmRemove);
318
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();
319
474
  else if (b.dataset.copy !== undefined) {
320
475
  const text = doc.getElementById(b.dataset.copy)?.textContent ?? "";
321
476
  try { await win.navigator.clipboard.writeText(text); b.textContent = "Copied"; }
@@ -21,6 +21,11 @@
21
21
  <h2 id="status-h">Status</h2>
22
22
  <div id="status"><p class="dim">Asking Mason…</p></div>
23
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>
24
29
  <section aria-labelledby="users-h">
25
30
  <h2 id="users-h">Users</h2>
26
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>
@@ -34,6 +39,6 @@
34
39
  <div id="users"></div>
35
40
  </section>
36
41
  </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>
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>
38
43
  </body>
39
44
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mason-data/cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
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
5
  "keywords": ["mason", "analytics", "postgres", "duckdb", "cli"],
6
6
  "homepage": "https://masondata.ai",