@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.
- package/README.md +71 -12
- package/dist/src/api/postgres/catalog.js +36 -1
- package/dist/src/api/postgres/server.js +133 -32
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cli/background.js +29 -5
- package/dist/src/cli/command.js +4 -3
- package/dist/src/cli/commands.js +259 -127
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +113 -20
- package/dist/src/cli/doctor.js +6 -3
- package/dist/src/cli/guides.js +32 -8
- package/dist/src/cli/init.js +58 -18
- package/dist/src/cli/machine.js +8 -3
- package/dist/src/cli/main.js +2 -2
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/preflight.js +16 -7
- package/dist/src/cli/project.js +5 -3
- package/dist/src/cli/registry.js +8 -5
- package/dist/src/cli/run-server.js +29 -106
- package/dist/src/cli/serve.js +6 -1
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +6 -4
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +7 -2
- package/dist/src/duck/executor.js +67 -26
- package/dist/src/duck/explain.js +128 -9
- package/dist/src/duck/functions.js +60 -5
- package/dist/src/duck/gaps.js +9 -4
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +206 -39
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +21 -8
- package/dist/src/duck/rewrite.js +1168 -68
- package/dist/src/duck/store.js +132 -56
- package/dist/src/duck/sync.js +90 -25
- package/dist/src/duck/verified.js +6 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +11 -0
- package/dist/src/web/app.js +167 -12
- package/dist/src/web/index.html +6 -1
- package/package.json +1 -1
package/dist/src/cli/commands.js
CHANGED
|
@@ -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)
|
|
4
|
-
*
|
|
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,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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",
|
|
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
|
|
541
|
-
out(`${t.table.padEnd(w)} ${size.padStart(18)} ${
|
|
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
|
|
571
|
-
* answered "Mason is not running", though it reads only Postgres): the
|
|
572
|
-
* readSourceFacts, what mason start reads)
|
|
573
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|
|
676
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
786
|
-
|
|
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
|
-
|
|
789
|
-
// the run
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
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(
|
|
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",
|
|
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 }));
|