@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,7 +1,8 @@
1
1
  /**
2
2
  * THE COMMANDS BESIDE `mason start` (an interface as good for people as for coding agents).
3
- * Each one finds the run it means (src/cli/runs.ts), asks its command server (src/cli/control.ts), and prints
4
- * it for a person — or, with `--json`, as data a program reads. Exit codes are part of each command's contract:
3
+ * Each one finds the run it means (src/cli/runs.ts) and asks its command server (src/cli/control.ts) — or, for what
4
+ * needs only the project's files and its database (tables; add and remove while Mason is down), reads them itself — and
5
+ * prints it for a person or, with `--json`, as data a program reads. Exit codes are part of each command's contract:
5
6
  *
6
7
  * mason status [--json] what the run is doing: its database, its tables and their copies, the Postgres API
7
8
  * mason tables [<table>…] [--json] the database's tables, their columns, keys and foreign keys
@@ -13,17 +14,19 @@
13
14
  */
14
15
  import * as fs from "node:fs";
15
16
  import * as path from "node:path";
16
- import { endText, errorJson, failed, jsonText, nextBlock, parseCommand, reportText, withEnd } from "./command.js";
17
+ import { asFailure, endText, errorJson, failed, jsonText, nextBlock, parseCommand, reportText, withEnd } from "./command.js";
17
18
  import { err as styleErr, out as styleOut, stepRenderer, wrap } from "./style.js";
18
- import { alive, endRun, END_GRACE_MS, theRun } from "./runs.js";
19
+ import { alive, endRun, END_GRACE_MS, notAnswering, runState, theRun } from "./runs.js";
19
20
  import { version } from "./version.js";
20
21
  import { readRestarts, signalSupervisor, stopSupervisor, supervisorGone } from "./supervise.js";
21
- import { DUCK_FILE, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
22
+ import { DEMO_DIR, DUCK_FILE, STATE_DIR, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
22
23
  import { callRun, RunUnreachable } from "./control.js";
23
24
  import { withPlaceholders, postgresLink } from "./pg-url.js";
24
25
  import { listenerText } from "./pg-tls.js";
25
26
  import { SLOW_MS } from "./run-server.js";
26
27
  import { catalogTable, entryName, looksSecret, tableNameProblem } from "./sources.js";
28
+ import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
29
+ import { changesOf } from "./replication-mode.js";
27
30
  import { connectSource, maskedUrl, parseSource, readSourceFacts } from "./preflight.js";
28
31
  import { reach } from "./state.js";
29
32
  import { copyingText, copyProgress, duration, readiness } from "./readiness.js";
@@ -200,10 +203,18 @@ const STATUS_USAGE = `usage: mason status [--json]
200
203
  --json as data: { running, pid, postgres: { url, host, port, database, sslmode (require when it is TLS), listener:
201
204
  { host (what it is bound to), tls (certificate, self-signed or null), fingerprint } }, source: { name (the
202
205
  source's: its sources file's), file (the sources file), url, database, remote, version, standby, changes,
203
- replication }, warnings, folder, project, tables: [{ table, rows, bytes, key, added, addedColumns, copy, error,
204
- … }], engine: { file, bytes, pollSeconds, restarts (each time its supervisor started the
205
- run again: { at, reason, upSeconds }) }, slowQueries, refusedQueries, refusedTotal (how many in all),
206
- readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), next }; with Mason stopped: { running: false, project, source: { name,
206
+ replication, demo (the demo database this run hosts) }, warnings, folder, project, tables: [{ table, rows,
207
+ bytes, key, added, addedColumns, copy, error, unsynced: { reason, asOf } (copied and served, not kept
208
+ current by the poll), kept, … }], engine: { file, bytes, pollSeconds, restarts (each time its supervisor
209
+ started the run again: { at, reason, upSeconds }) }, slowQueries, refusedQueries, refusedTotal (how many in
210
+ all), readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), next }.
211
+ Each table's kept is how its copy is kept current now (absent for a table the sync does not hold):
212
+ { by: poll, polledAt (its last copy or poll), unsynced (only when it applies: why the passes leave it as
213
+ copied, mason sync polls it), pending (only when it applies: why the copy is not kept current now, its
214
+ phase included: waiting for its copy, being copied, its copy failed, to be copied again, caught up first) }
215
+ or { by: recopy, copiedAt, pending (only when it applies, as the poll's) }
216
+ or { by: replication, slot, state (streamed, catching-up, waiting, left-out), why, position, lastCommit,
217
+ notRunning (why the stream is not running, null when it is) }; with Mason stopped: { running: false, project, source: { name,
207
218
  file, url, error (the sources file's problem, or null) }, engine, tables: [{ table }], lastRun, lastStop:
208
219
  { at, how } or null, next }; hung: the same with hung: { pid, problem, why },
209
220
  exit 1. In replication mode stream: { slot, publication, running, failure, position,
@@ -327,10 +338,11 @@ export async function status(argv) {
327
338
  export function sourceRows(s) {
328
339
  return [
329
340
  ...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
330
- ["Database", `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
341
+ ["Database", s.source.demo ? `the demo database Mason hosts (${s.source.url} · Postgres ${s.source.version})`
342
+ : `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
331
343
  ["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
332
344
  `(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
333
- : `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · mason start --replication reads the database's change log instead`],
345
+ : `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · ${s.source.demo ? "the demo database is read by polling only" : "mason start --replication reads the database's change log instead"}`],
334
346
  ];
335
347
  }
336
348
  /** An SQL statement on one line, cut at `max` characters (the whole of it is mason logs --requests --json's). */
@@ -537,9 +549,8 @@ export async function tables(argv) {
537
549
  if (!f.positionals.length && !f.values.all) {
538
550
  const w = Math.max(...shown.map((t) => t.table.length));
539
551
  for (const t of shown) {
540
- const size = t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`;
541
- out(`${t.table.padEnd(w)} ${size.padStart(18)} ${t.key.length ? `key ${t.key.join(", ")}` : "no key"}` +
542
- `${t.added ? " · added" : t.reason ? ` · cannot be added as it is: ${t.reason}` : ""}`);
552
+ const l = tableFacts(t);
553
+ out(`${t.table.padEnd(w)} ${l.size.padStart(18)} ${l.key}${l.state ? ` · ${l.state}` : ""}`);
543
554
  }
544
555
  out(nextBlock(next));
545
556
  return 0;
@@ -566,22 +577,35 @@ export async function tables(argv) {
566
577
  out(nextBlock(next).slice(1));
567
578
  return 0;
568
579
  }
580
+ /** A table's line in `mason tables`: its size, its key, and added or why it cannot be added as it is (the web page's
581
+ * tables say the same, src/web/app.js; tests/web-page.test.ts holds the two equal). */
582
+ export function tableFacts(t) {
583
+ return { size: t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`, key: t.key.length ? `key ${t.key.join(", ")}` : "no key",
584
+ state: t.added ? "added" : t.reason ? `cannot be added as it is: ${t.reason}` : null };
585
+ }
569
586
  /**
570
- * THE PROJECT'S DATABASE'S TABLES, READ BY THE COMMAND ITSELF (the hands-on review of #451, item 15: `mason tables`
571
- * answered "Mason is not running", though it reads only Postgres): the database's catalog (preflight.ts
572
- * readSourceFacts, what mason start reads), each table as the run's state says it against the sources file
573
- * (sources.ts catalogTable). A file that cannot be read, or a database that does not answer, is said with why and the way out.
587
+ * THE PROJECT'S DATABASE'S CATALOG, READ BY THE COMMAND ITSELF (the hands-on review of #451, item 15: `mason tables`
588
+ * answered "Mason is not running", though it reads only Postgres): the sources file as the run reads it, and the
589
+ * database's catalog (preflight.ts readSourceFacts, what mason start reads) — what `mason tables` lists, and what `mason
590
+ * add` and `mason remove` check an edit against while Mason is down. A file that cannot be read, or a database that does
591
+ * not answer, is said with why and the way out.
574
592
  */
575
- async function databaseTables(p) {
593
+ async function readCatalog(p) {
576
594
  const src = projectSource(p);
577
595
  if ("message" in src)
578
596
  return src;
597
+ const demo = p.config.demo === true;
579
598
  const fix = `change the URL where it is (${src.from}) when the database's password or host changed`;
580
599
  const parsed = parseSource(src.resolved);
581
600
  if ("problem" in parsed)
582
601
  return { message: `the database's URL (${src.from}): ${parsed.problem}`, code: "bad_source", fix };
583
- const connected = await connectSource(parsed.source);
602
+ const connected = await connectSource(parsed.source, { demo });
584
603
  if ("failure" in connected) {
604
+ // the demo database's way out is its run (src/cli/demo.ts): its hint is the fix, mason start the next command
605
+ if (demo) {
606
+ return { message: `could not connect to ${maskedUrl(parsed.source.url)}: ${connected.failure.message}`, code: "unreachable",
607
+ fix: connected.failure.hints.join("; ") || "mason start hosts the demo database", next: [["mason start", "the demo database and Mason, in the background"]] };
608
+ }
585
609
  return { message: `could not connect to ${maskedUrl(parsed.source.url)}: ${connected.failure.message}`, code: "unreachable",
586
610
  ...(connected.failure.hints.length ? { why: connected.failure.hints.join("; ") } : {}), fix,
587
611
  next: [["mason doctor", "checks the machine and the project's database"]] };
@@ -598,7 +622,59 @@ async function databaseTables(p) {
598
622
  finally {
599
623
  connected.pg.close();
600
624
  }
601
- return { database: parsed.source.database, name: src.name, file: src.file, tables: facts.tables.map((t) => catalogTable(t, src.tables.find((e) => e.table === t.source))) };
625
+ return { src, database: parsed.source.database, facts: facts.tables };
626
+ }
627
+ /** `mason tables` without the run: the database's tables, each as the run's state says it against the sources file
628
+ * (sources.ts catalogTable). */
629
+ async function databaseTables(p) {
630
+ const c = await readCatalog(p);
631
+ if ("message" in c)
632
+ return c;
633
+ return { database: c.database, name: c.src.name, file: c.src.file, tables: c.facts.map((t) => catalogTable(t, c.src.tables.find((e) => e.table === t.source))) };
634
+ }
635
+ /**
636
+ * MASON DOWN, `mason add` AND `mason remove` EDIT THE FILE THEMSELVES (the file is the list: `mason init && mason add
637
+ * orders && mason start` is a natural order, and `mason init demo` already writes entries before any run) — through
638
+ * the same check-and-write the run's command server calls (src/cli/source-edit.ts), against the catalog the command
639
+ * reads itself (`readCatalog`), so a refusal is worded alike from both. A run alive and silent (hung) still holds the
640
+ * file: refused, with mason stop as the way out, as is a run that came up since the command looked. Null once the
641
+ * failure is said.
642
+ */
643
+ async function withoutRun(cmd, p, json) {
644
+ const st = await runState(p);
645
+ // a hung run is Mason running and not answering: FAILURE_CODES' unreachable
646
+ if (st.kind === "hung") {
647
+ failed(cmd, { ...asFailure(notAnswering(p, st.run)), code: "unreachable" }, { json });
648
+ return null;
649
+ }
650
+ if (st.kind === "up") {
651
+ failed(cmd, { message: `Mason started for ${p.config.name} while mason ${cmd} ran`, code: "refused", fix: `run mason ${cmd} again: it asks the run, which copies at once` }, { json });
652
+ return null;
653
+ }
654
+ const c = await readCatalog(p);
655
+ if ("message" in c) {
656
+ failed(cmd, c, { json });
657
+ return null;
658
+ }
659
+ return { db: { tables: c.facts, database: c.database, replication: changesOf(null, p.config.changes) === "replication" },
660
+ tables: c.facts.map((t) => catalogTable(t, c.src.tables.find((e) => e.table === t.source))), file: c.src.file,
661
+ edit: () => sourceToEdit(p.folder, p.root, () => projectSource(p)) };
662
+ }
663
+ function edited(f) {
664
+ try {
665
+ return { ok: f() };
666
+ }
667
+ catch (e) {
668
+ // a refusal is the edit's answer, said as the run's is; anything else is a bug, and goes on up
669
+ if (e instanceof Refusal)
670
+ return { status: e.status, error: e.message };
671
+ throw e;
672
+ }
673
+ }
674
+ /** The run's command server's answer, as an edit's (`Edited`): what `/api/pick` or `/api/remove` answered. */
675
+ async function viaRun(run, route, body) {
676
+ const r = await callRun(run, "POST", route, body);
677
+ return r.status === 200 ? { ok: r.body } : { status: r.status, error: String(r.body.error) };
602
678
  }
603
679
  // ------------------------------------------------------------------ mason add
604
680
  const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--include <column>,…] [--key <column>,…] [--json]
@@ -616,8 +692,10 @@ const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--inclu
616
692
  it is written as exclude: [password_hash], since anyone with a user's password can read every copied column. Name
617
693
  one with --columns or --include to have it.
618
694
 
619
- The copy starts at once; mason wait returns when it is done and the table is served. Adding a table again with
620
- other columns changes its entry, and the table is copied again with them.
695
+ With Mason running the copy starts at once; mason wait returns when it is done and the table is served. Before
696
+ mason start (mason init && mason add orders && mason start) the entry is written, checked against the database as
697
+ the run checks it, and the copy begins at mason start. Adding a table again with other columns changes its entry,
698
+ and the table is copied again with them.
621
699
 
622
700
  --columns <a,b,…> only these columns (one table at a time: the list is that table's)
623
701
  --include <a,b,…> these columns as well as every other one — a column that looks like a secret, named on purpose
@@ -629,7 +707,8 @@ const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--inclu
629
707
  exit 1 when a table could not be added
630
708
  `;
631
709
  export const ADD = {
632
- needs: "run",
710
+ // the project, not the run: the sources file is the list, so a table is added before mason start as well as after
711
+ needs: "project",
633
712
  name: "add",
634
713
  summary: "copy tables into Mason, adding them to the sources file: mason add orders (--columns id,status: those only)",
635
714
  usage: ADD_USAGE,
@@ -672,94 +751,119 @@ export async function add(argv) {
672
751
  const specs = addSpecs(f.positionals, f.values.columns, f.values.include, f.values.key);
673
752
  if (typeof specs === "string")
674
753
  return failed("add", { message: specs, code: "usage", usage: ADD_USAGE }, { json });
675
- const run = await runOr(ADD, json);
676
- if (!run)
754
+ const where = await reach(ADD, json);
755
+ // (a state with no project never meets the need: reach said it)
756
+ if (where === null || where.project === null)
677
757
  return 1;
678
- return guarded("add", json, async () => {
679
- const s = await readState(run);
680
- const results = [];
681
- const added = [];
682
- let file = s.source.file ?? null;
683
- // the tables that could not be added, each with its reason and code: the command's failure, in the failure shape
684
- const refused = [];
685
- // A CHECKLIST, a line per table (it reads best as a list of completed actions): a spinner
686
- // while it is added, ✔ with what was written, its notes under it; ✘ with the reason when it could not be
687
- const st = styleOut();
688
- const steps = stepRenderer({ line: out, live: !json && process.stdout.isTTY ? { write: (t) => { process.stdout.write(t); } } : null, style: st });
689
- const note = (text) => { if (!json)
690
- steps.print(st.dim(` ${wrap(text, st.width, 4)}`)); };
691
- const shown = (p) => { const rel = path.relative(process.cwd(), p); return rel.startsWith("..") ? p : rel; };
692
- for (const spec of specs) {
693
- const t = findTable(s.tables, spec.table);
694
- // a table the database lacks is a failed line of the checklist, its reason under it, like a table Mason refused
695
- if (!("table" in t)) {
696
- refused.push({ table: spec.table, message: t.message, code: t.code });
697
- results.push({ table: spec.table, error: t.message });
698
- if (!json) {
699
- steps.begin(`Adding ${spec.table}`);
700
- steps.fail(`Adding ${spec.table}`);
701
- note(`${t.message}${t.fix ? `: ${t.fix}` : ""}${t.next?.length ? ` (${t.next[0][0]})` : ""}`);
702
- }
703
- continue;
704
- }
705
- if (!json)
706
- steps.begin(`Adding ${t.table}`);
707
- const r = await callRun(run, "POST", "/api/pick", { table: t.table, picked: true, columns: spec.columns, ...(spec.key ? { key: spec.key } : {}) });
708
- if (r.status !== 200) {
709
- refused.push({ table: t.table, message: String(r.body.error), code: r.status === 404 ? "not_found" : "refused" });
710
- results.push({ table: t.table, error: String(r.body.error) });
711
- if (!json) {
712
- steps.fail(`Adding ${t.table}`);
713
- note(String(r.body.error));
714
- }
715
- continue;
758
+ if (where.state === "up") {
759
+ // Mason up: the run adds it (the copy starts now, and the run's own re-read never races the file's write)
760
+ const run = where.run;
761
+ return guarded("add", json, async () => {
762
+ const s = await readState(run);
763
+ return addTables({ tables: s.tables, file: s.source.file ?? null,
764
+ pick: (table, columns, key) => viaRun(run, "/api/pick", { table, picked: true, columns, ...(key ? { key } : {}) }),
765
+ next: (n) => [["mason wait", `until ${n === 1 ? "its copy finishes" : "their copies finish"} (the copy has started)`], READY_STEPS[0]] }, specs, json);
766
+ });
767
+ }
768
+ // Mason down: the command writes the entry itself, checked as the run checks it; the copy begins at mason start
769
+ const down = await withoutRun("add", where.project, json);
770
+ if (down === null)
771
+ return 1;
772
+ return addTables({ tables: down.tables, file: down.file,
773
+ pick: async (table, columns, key) => edited(() => addEntry(down.edit(), down.db, table, columns, key)),
774
+ next: () => [["mason start", "Mason in the background, for this project: the copy of every added table begins"],
775
+ ["mason wait", "then, until every added table is copied and served"]] }, specs, json);
776
+ }
777
+ /** `mason add`'s checklist and answer, the same whichever way the tables are added (`AddWay`). */
778
+ async function addTables(way, specs, json) {
779
+ const results = [];
780
+ const added = [];
781
+ let file = way.file;
782
+ // the tables that could not be added, each with its reason and code: the command's failure, in the failure shape
783
+ const refused = [];
784
+ // A CHECKLIST, a line per table (it reads best as a list of completed actions): a spinner
785
+ // while it is added, ✔ with what was written, its notes under it; ✘ with the reason when it could not be
786
+ const st = styleOut();
787
+ const steps = stepRenderer({ line: out, live: !json && process.stdout.isTTY ? { write: (t) => { process.stdout.write(t); } } : null, style: st });
788
+ const note = (text) => { if (!json)
789
+ steps.print(st.dim(` ${wrap(text, st.width, 4)}`)); };
790
+ const shown = (p) => { const rel = path.relative(process.cwd(), p); return rel.startsWith("..") ? p : rel; };
791
+ for (const spec of specs) {
792
+ const t = findTable(way.tables, spec.table);
793
+ // a table the database lacks is a failed line of the checklist, its reason under it, like a table Mason refused
794
+ if (!("table" in t)) {
795
+ refused.push({ table: spec.table, message: t.message, code: t.code });
796
+ results.push({ table: spec.table, error: t.message });
797
+ if (!json) {
798
+ steps.begin(`Adding ${spec.table}`);
799
+ steps.fail(`Adding ${spec.table}`);
800
+ note(`${t.message}${t.fix ? `: ${t.fix}` : ""}${t.next?.length ? ` (${t.next[0][0]})` : ""}`);
716
801
  }
717
- const b = r.body;
718
- file = b.file;
719
- results.push({ table: b.table, entry: b.entry, columns: b.columns, exclude: b.exclude, key: b.key, addedKey: b.addedKey, skippedSecrets: b.skippedSecrets, already: b.already });
720
- added.push(b.table);
721
- if (json)
722
- continue;
723
- const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
724
- steps.done(`Added ${t.table} to ${shown(b.file)}: ${copies}${b.key ? `, copied by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`);
725
- if (b.addedKey.length)
726
- note(`key added: ${b.addedKey.join(", ")} (a copy always has its key)`);
727
- // a secret is readable by anyone with a user's password: left out, and said, with the way to include one
728
- if (b.skippedSecrets.length)
729
- note(`left out ${b.skippedSecrets.join(", ")}: ${b.skippedSecrets.length === 1 ? "it looks" : "they look"} like ${b.skippedSecrets.length === 1 ? "a secret" : "secrets"}, ` +
730
- `which anyone with a user's password could read (--include ${b.skippedSecrets[0]} adds it)`);
802
+ continue;
731
803
  }
732
- const next = added.length ? [
733
- ["mason wait", `until ${added.length === 1 ? "its copy finishes" : "their copies finish"} (the copy has started)`],
734
- READY_STEPS[0],
735
- ] : [];
736
- if (refused.length) {
737
- // A TABLE NOT ADDED IS THE COMMAND'S FAILURE, in the shape every failure has (found by the CLI session test of the private repo:
738
- // exit 1 with no error, and nothing on stderr): what is wrong, each table's reason, and next — the added ones' too
739
- const f = {
740
- message: specs.length === 1 ? `${refused[0].table} was not added` : `${refused.length} of ${specs.length} tables were not added: ${refused.map((x) => x.table).join(", ")}`,
741
- code: refused.every((x) => x.code === "not_found") ? "not_found" : refused.every((x) => x.code === "usage") ? "usage" : "refused",
742
- why: refused.map((x) => specs.length === 1 ? x.message : `${x.table}: ${x.message}`).join("; "),
743
- next: [...next, ["mason tables", "the database's tables, as this user reads them"]],
744
- };
745
- if (json) {
746
- out(jsonText(ADD, { added: results, file, error: errorJson(f) }, f.next));
747
- return 1;
804
+ if (!json)
805
+ steps.begin(`Adding ${t.table}`);
806
+ const r = await way.pick(t.table, spec.columns, spec.key);
807
+ if (!("ok" in r)) {
808
+ refused.push({ table: t.table, message: r.error, code: r.status === 404 ? "not_found" : "refused" });
809
+ results.push({ table: t.table, error: r.error });
810
+ if (!json) {
811
+ steps.fail(`Adding ${t.table}`);
812
+ note(r.error);
748
813
  }
749
- return failed("add", f, { json: false });
814
+ continue;
750
815
  }
816
+ const b = r.ok;
817
+ file = b.file;
818
+ results.push({ table: b.table, entry: b.entry, columns: b.columns, exclude: b.exclude, key: b.key, addedKey: b.addedKey, skippedSecrets: b.skippedSecrets, already: b.already });
819
+ added.push(b.table);
751
820
  if (json)
752
- out(jsonText(ADD, { added: results, file }, next));
753
- else
754
- out(endText({ next }, st));
755
- return 0;
756
- });
821
+ continue;
822
+ const l = addedLines(b, shown(b.file));
823
+ steps.done(l.line);
824
+ for (const x of l.notes)
825
+ note(x);
826
+ }
827
+ const next = added.length ? way.next(added.length) : [];
828
+ if (refused.length) {
829
+ // A TABLE NOT ADDED IS THE COMMAND'S FAILURE, in the shape every failure has (found by the CLI session test of the private repo:
830
+ // exit 1 with no error, and nothing on stderr): what is wrong, each table's reason, and next — the added ones' too
831
+ const f = {
832
+ message: specs.length === 1 ? `${refused[0].table} was not added` : `${refused.length} of ${specs.length} tables were not added: ${refused.map((x) => x.table).join(", ")}`,
833
+ code: refused.every((x) => x.code === "not_found") ? "not_found" : refused.every((x) => x.code === "usage") ? "usage" : "refused",
834
+ why: refused.map((x) => specs.length === 1 ? x.message : `${x.table}: ${x.message}`).join("; "),
835
+ next: [...next, ["mason tables", "the database's tables, as this user reads them"]],
836
+ };
837
+ if (json) {
838
+ out(jsonText(ADD, { added: results, file, error: errorJson(f) }, f.next));
839
+ return 1;
840
+ }
841
+ return failed("add", f, { json: false });
842
+ }
843
+ if (json)
844
+ out(jsonText(ADD, { added: results, file }, next));
845
+ else
846
+ out(endText({ next }, st));
847
+ return 0;
848
+ }
849
+ /** What `mason add` prints for a table added: its line (✔ …), then its notes under it (the web page says the same,
850
+ * src/web/app.js; tests/web-page.test.ts holds the two equal). `file` is the sources file as the reader is shown it. */
851
+ export function addedLines(b, file) {
852
+ const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
853
+ const s = b.skippedSecrets;
854
+ return { line: `Added ${b.table} to ${file}: ${copies}${b.key ? `, copied by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`, notes: [
855
+ ...(b.addedKey.length ? [`key added: ${b.addedKey.join(", ")} (a copy always has its key)`] : []),
856
+ // a secret is readable by anyone with a user's password: left out, and said, with the way to include one
857
+ ...(s.length ? [`left out ${s.join(", ")}: ${s.length === 1 ? "it looks" : "they look"} like ${s.length === 1 ? "a secret" : "secrets"}, ` +
858
+ `which anyone with a user's password could read (--include ${s[0]} adds it)`] : []),
859
+ ] };
757
860
  }
758
861
  // ------------------------------------------------------------------ mason remove
759
862
  const REMOVE_USAGE = `usage: mason remove <table>… [--json]
760
863
 
761
864
  Takes tables out of the project: each one's entry in the sources file (mason/sources/<name>.yml) is removed, the
762
- rest of the file as it was. Mason then stops copying it and drops its copy.
865
+ rest of the file as it was. With Mason running it stops copying the table and drops its copy at once; with Mason
866
+ stopped, a copy an earlier run made is dropped at the next mason start.
763
867
 
764
868
  mason remove customers
765
869
  mason remove customers billing.invoices several at once
@@ -767,7 +871,8 @@ const REMOVE_USAGE = `usage: mason remove <table>… [--json]
767
871
  --json as data: { removed (the tables, schema.table), file (the sources file), next }
768
872
  `;
769
873
  export const REMOVE = {
770
- needs: "run",
874
+ // the project, not the run, as mason add
875
+ needs: "project",
771
876
  name: "remove",
772
877
  summary: "take tables out of the sources file: mason remove customers (its copy is dropped)",
773
878
  usage: REMOVE_USAGE,
@@ -782,30 +887,53 @@ export async function remove(argv) {
782
887
  const names = [...new Set(f.positionals)];
783
888
  if (!names.length)
784
889
  return failed("remove", { message: "name a table: mason remove customers (mason status lists the added ones)", code: "usage", usage: REMOVE_USAGE }, { json });
785
- const run = await runOr(REMOVE, json);
786
- if (!run)
890
+ const where = await reach(REMOVE, json);
891
+ // (a state with no project never meets the need: reach said it)
892
+ if (where === null || where.project === null)
787
893
  return 1;
788
- return guarded("remove", json, async () => {
789
- // the run's own removal (src/cli/run-server.ts removeTables): one path, so nothing can disagree with it
790
- const r = await callRun(run, "POST", "/api/remove", { tables: names });
791
- if (r.status !== 200) {
792
- const message = String(r.body.error);
793
- return failed("remove", { message: `${names.join(", ")} ${names.length === 1 ? "was" : "were"} not removed`, code: r.status === 404 ? "not_found" : r.status === 400 ? "usage" : "refused", why: message,
794
- next: [["mason status", "the tables added"]] }, { json });
795
- }
796
- const b = r.body;
797
- const next = [["mason status", "the tables left"]];
798
- if (json) {
799
- out(jsonText(REMOVE, { removed: b.removed, file: b.file }, next));
800
- return 0;
801
- }
802
- const rel = path.relative(process.cwd(), b.file);
803
- out(reportText({ headline: `Removed ${b.removed.join(", ")}`, details: [["from", rel.startsWith("..") ? b.file : rel]],
804
- tips: [`Mason stops copying ${b.removed.length === 1 ? "it" : "them"} and drops ${b.removed.length === 1 ? "its copy" : "their copies"}`], next }, styleOut()));
894
+ if (where.state === "up") {
895
+ // Mason up: the run removes it and drops the copy at once (src/cli/run-server.ts removeTables)
896
+ const run = where.run;
897
+ return guarded("remove", json, async () => removedAnswer(await viaRun(run, "/api/remove", { tables: names }), names, true, json));
898
+ }
899
+ // Mason down: the command cuts the entries itself, checked as the run checks them (src/cli/source-edit.ts removeEntries)
900
+ const down = await withoutRun("remove", where.project, json);
901
+ if (down === null)
902
+ return 1;
903
+ return removedAnswer(edited(() => removeEntries(down.edit(), down.db, names)), names, false, json);
904
+ }
905
+ /** `mason remove`'s answer, the same whichever way the entries were cut; `running`: Mason is up, and dropped the copies. */
906
+ function removedAnswer(r, names, running, json) {
907
+ if (!("ok" in r)) {
908
+ return failed("remove", { message: `${names.join(", ")} ${names.length === 1 ? "was" : "were"} not removed`, code: r.status === 404 ? "not_found" : r.status === 400 ? "usage" : "refused", why: r.error,
909
+ next: [["mason status", "the tables added"]] }, { json });
910
+ }
911
+ const b = r.ok;
912
+ const next = running ? [["mason status", "the tables left"]]
913
+ : [["mason start", "Mason in the background, for this project: it copies the tables left"], ["mason status", "the tables left"]];
914
+ if (json) {
915
+ out(jsonText(REMOVE, { removed: b.removed, file: b.file }, next));
805
916
  return 0;
806
- });
917
+ }
918
+ const rel = path.relative(process.cwd(), b.file);
919
+ out(reportText({ ...removedReport(b.removed, rel.startsWith("..") ? b.file : rel, { running }), next }, styleOut()));
920
+ return 0;
921
+ }
922
+ /** What `mason remove` prints for the tables removed, `file` the sources file as the reader is shown it (the web page
923
+ * says the same, src/web/app.js; tests/web-page.test.ts holds the two equal). */
924
+ export function removedReport(removed, file, o = {}) {
925
+ const one = removed.length === 1;
926
+ return { headline: `Removed ${removed.join(", ")}`, details: [["from", file]],
927
+ // Mason down (the web page always has a run): a copy an earlier run made is dropped by the next start's sync
928
+ tips: [o.running === false ? `a copy an earlier run made of ${one ? "it" : "them"} is dropped at the next mason start`
929
+ : `Mason stops copying ${one ? "it" : "them"} and drops ${one ? "its copy" : "their copies"}`] };
807
930
  }
808
931
  // ------------------------------------------------------------------ mason wait
932
+ /** An added table as mason wait --json says it: its copy, its error, and how it is kept current, as the run's state has
933
+ * them (mason status --json's own fields, never derived again here) */
934
+ export function waitTable(t) {
935
+ return { table: t.table, copy: t.copy, error: t.error, ...(t.unsynced ? { unsynced: t.unsynced } : {}), ...(t.kept ? { kept: t.kept } : {}) };
936
+ }
809
937
  const WAIT_USAGE = `usage: mason wait [--timeout <seconds>] [--json]
810
938
 
811
939
  Waits until every added table is copied and served, showing every added table on a line of its own: 100% for the
@@ -815,9 +943,11 @@ const WAIT_USAGE = `usage: mason wait [--timeout <seconds>] [--json]
815
943
  mason wait --timeout 3600 a large table: wait up to an hour
816
944
 
817
945
  --timeout <seconds> how long to wait (default 600); the copy goes on after, and mason wait again picks it up
818
- --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error }]
819
- (copy: { phase, rowsCopied, progress: the chunks done, reading and for how long, the rate, the
820
- seconds left, while its chunks are read }),
946
+ --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error,
947
+ unsynced, kept }] (copy: { phase, rowsCopied, progress: the chunks done, reading and for how
948
+ long, the rate, the seconds left, while its chunks are read }; unsynced: { reason, asOf }
949
+ and kept as mason status --json says them — kept's pending, when it applies, says why the
950
+ copy is not kept current now, its phase included),
821
951
  stream (replication mode: where the stream is, as mason status --json says it), error (on a
822
952
  failure or a timeout: { code, message, next }), next }
823
953
 
@@ -889,7 +1019,7 @@ export async function wait(argv) {
889
1019
  next: [["mason wait", "keep waiting (--timeout <seconds> for longer)"], ["mason status", "what Mason is doing"]] }
890
1020
  : null;
891
1021
  if (json && (done || failure)) {
892
- out(jsonText(WAIT, { ...r, timedOut: !done, tables: picked.map((t) => ({ table: t.table, copy: t.copy, error: t.error, ...(t.unsynced ? { unsynced: t.unsynced } : {}) })),
1022
+ out(jsonText(WAIT, { ...r, timedOut: !done, tables: picked.map(waitTable),
893
1023
  ...(s.stream ? { stream: s.stream } : {}), ...(failure ? { error: errorJson(failure) } : {}) }, failure ? failure.next ?? [] : readyNext));
894
1024
  }
895
1025
  // the block as it ends, too: every table at 100% beside Ready (when the last change was not printed yet)
@@ -1133,11 +1263,13 @@ export async function stop(argv) {
1133
1263
  const recorded = readProject(run.root);
1134
1264
  const replicating = isProject(recorded) && recorded.config.changes === "replication";
1135
1265
  const named = isProject(recorded) ? recorded.config.publication ?? null : null;
1266
+ // a demo project's database (src/cli/demo.ts) stays too, as the copy does: said where a person reads the stop
1267
+ const demo = isProject(recorded) && recorded.config.demo === true;
1136
1268
  if (json) {
1137
1269
  out(jsonText(STOP, { stopped: [run.name] }, next));
1138
1270
  return 0;
1139
1271
  }
1140
- out(reportText({ headline: `Stopped ${run.name}`, details: [["kept", "its copy of the data and its state"],
1272
+ out(reportText({ headline: `Stopped ${run.name}`, details: [["kept", `its copy of the data and its state${demo ? ` and the demo database (${STATE_DIR}/${DEMO_DIR})` : ""}`],
1141
1273
  ...(replicating ? [["slot", `its replication slot stays on the database, holding WAL until the next mason start (mason destroy drops it)`],
1142
1274
  ["publication", named ? `${named}, its owner's: read as it is, never altered (mason destroy leaves it)` : "Mason's own (mason destroy drops it)"]] : [])] }));
1143
1275
  out(endText({ next }));