@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
|
@@ -25,7 +25,9 @@ export function maskedUrl(url) {
|
|
|
25
25
|
return u.toString();
|
|
26
26
|
}
|
|
27
27
|
catch {
|
|
28
|
-
|
|
28
|
+
// not a URL: everything from the user's colon to the LAST @ is the password, whatever it holds (a / or an @ that
|
|
29
|
+
// was not encoded) — a host never holds an @
|
|
30
|
+
return url.replace(/:\/\/([^:@/]*):.*@/, "://$1:****@");
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
@@ -38,7 +40,8 @@ export function parseSource(url) {
|
|
|
38
40
|
u = new URL(url);
|
|
39
41
|
}
|
|
40
42
|
catch {
|
|
41
|
-
|
|
43
|
+
// its password masked: the problem is printed (and, under mason mcp, kept in the MCP client's log)
|
|
44
|
+
return { problem: `${JSON.stringify(maskedUrl(url))} is not a URL. The form is postgres://user:password@localhost:5432/your_app — a password ` +
|
|
42
45
|
`containing @ : / # ? or % must be URL-encoded (encodeURIComponent; @ is %40).` };
|
|
43
46
|
}
|
|
44
47
|
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:")
|
|
@@ -62,7 +65,7 @@ export function parseSource(url) {
|
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
/** What a failed connection means, and what to change. */
|
|
65
|
-
export function explainConnectError(e, s) {
|
|
68
|
+
export function explainConnectError(e, s, o = {}) {
|
|
66
69
|
const where = `${s.host}:${s.port}`;
|
|
67
70
|
const msg = e?.message ?? String(e);
|
|
68
71
|
if (e instanceof PgError) {
|
|
@@ -82,6 +85,9 @@ export function explainConnectError(e, s) {
|
|
|
82
85
|
return { message: `${where}: ${msg}`, hints: [] };
|
|
83
86
|
}
|
|
84
87
|
if (/ECONNREFUSED/.test(msg)) {
|
|
88
|
+
// the demo database is served by the project's own run: nothing else could be listening there
|
|
89
|
+
if (o.demo)
|
|
90
|
+
return { message: `the demo database is not running on ${where}`, hints: ["the demo database runs inside mason start: start it first"] };
|
|
85
91
|
return {
|
|
86
92
|
message: `nothing is listening on ${where}`,
|
|
87
93
|
hints: [
|
|
@@ -106,13 +112,13 @@ export function explainConnectError(e, s) {
|
|
|
106
112
|
return { message: `${where}: ${msg}`, hints: [] };
|
|
107
113
|
}
|
|
108
114
|
/** Connect as the service will. The failure is explained; for a missing database, the ones that exist are listed. */
|
|
109
|
-
export async function connectSource(s) {
|
|
115
|
+
export async function connectSource(s, o = {}) {
|
|
110
116
|
const opts = { host: s.host, port: s.port, database: s.database, user: s.user, password: s.password, sslmode: s.sslmode, connectTimeoutMs: 10_000, applicationName: "mason-dev" };
|
|
111
117
|
try {
|
|
112
118
|
return { pg: await PgConnection.connect(opts) };
|
|
113
119
|
}
|
|
114
120
|
catch (e) {
|
|
115
|
-
const failure = explainConnectError(e, s);
|
|
121
|
+
const failure = explainConnectError(e, s, o);
|
|
116
122
|
if (e instanceof PgError && e.code === "3D000") {
|
|
117
123
|
try {
|
|
118
124
|
const pg = await PgConnection.connect({ ...opts, database: "postgres" });
|
|
@@ -156,17 +162,20 @@ export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text F
|
|
|
156
162
|
THEN (SELECT coalesce(sum(pg_total_relation_size(pt.relid)), 0) FROM pg_partition_tree(c.oid) pt WHERE pt.isleaf)
|
|
157
163
|
ELSE pg_total_relation_size(c.oid) END AS bytes,
|
|
158
164
|
obj_description(c.oid, 'pg_class') AS comment,
|
|
165
|
+
-- the primary key: only a valid index enforces it
|
|
159
166
|
(SELECT coalesce(json_agg(a.attname ORDER BY k.n), '[]'::json)
|
|
160
167
|
FROM pg_index i CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, n)
|
|
161
168
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
|
162
|
-
WHERE i.indrelid = c.oid AND i.indisprimary) AS key,
|
|
169
|
+
WHERE i.indrelid = c.oid AND i.indisprimary AND i.indisvalid) AS key,
|
|
163
170
|
(SELECT coalesce(json_agg(json_build_object('name', ic.relname, 'constraint', EXISTS (SELECT FROM pg_constraint u WHERE u.conindid = i.indexrelid AND u.contype = 'u'),
|
|
164
171
|
-- indnullsnotdistinct is Postgres 15's: read through the row, which an older server's lacks
|
|
165
172
|
'nulls_not_distinct', coalesce((to_jsonb(i) ->> 'indnullsnotdistinct')::boolean, false),
|
|
166
173
|
'columns', (SELECT json_agg(a.attname ORDER BY k.n) FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, n)
|
|
167
174
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum)) ORDER BY ic.relname), '[]'::json)
|
|
168
175
|
FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
169
|
-
|
|
176
|
+
-- an INVALID index (a CREATE UNIQUE INDEX CONCURRENTLY that failed, its duplicates still in the table) enforces
|
|
177
|
+
-- nothing: never declared to the catalog, never a key: Mason accepts
|
|
178
|
+
WHERE i.indrelid = c.oid AND i.indisunique AND i.indisvalid AND NOT i.indisprimary AND i.indpred IS NULL AND i.indexprs IS NULL
|
|
170
179
|
AND i.indnkeyatts = i.indnatts) AS unique_keys,
|
|
171
180
|
(SELECT coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod),
|
|
172
181
|
'not_null', a.attnotnull, 'comment', col_description(c.oid, a.attnum), 'type_oid', a.atttypid,
|
package/dist/src/cli/project.js
CHANGED
|
@@ -26,6 +26,8 @@ const CONFIG = "config.json";
|
|
|
26
26
|
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
27
27
|
/** The copy of the data, a DuckDB file in the project's `.mason/`: kept by mason stop, deleted by mason destroy. */
|
|
28
28
|
export const DUCK_FILE = "duck.db";
|
|
29
|
+
/** The demo database's folder in the project's `.mason/` (`mason init demo`): hosted by mason start, deleted by mason destroy. */
|
|
30
|
+
export const DEMO_DIR = "demo";
|
|
29
31
|
/** A name from a folder's: lower case, the rest of it dashes. */
|
|
30
32
|
export function nameFromFolder(dir) {
|
|
31
33
|
const n = path.basename(path.resolve(dir)).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 48);
|
|
@@ -97,8 +99,8 @@ export function writeConfig(dir, c) {
|
|
|
97
99
|
fs.chmodSync(file, 0o600);
|
|
98
100
|
}
|
|
99
101
|
/** A new project's config: the secrets made here, once. */
|
|
100
|
-
export function newConfig(name, folder, remote) {
|
|
101
|
-
return { name, folder, remote, apiKey: randomBytes(24).toString("hex"), sourceServer: null, running: null, lastRun: null };
|
|
102
|
+
export function newConfig(name, folder, remote, o = {}) {
|
|
103
|
+
return { name, folder, remote, ...(o.demo ? { demo: true } : {}), apiKey: randomBytes(24).toString("hex"), sourceServer: null, running: null, lastRun: null };
|
|
102
104
|
}
|
|
103
105
|
/**
|
|
104
106
|
* THE PROJECT A COMMAND MEANS: the nearest at or above `cwd`; or the sentence that says why there is none, with the
|
|
@@ -110,7 +112,7 @@ export function theProject(cwd) {
|
|
|
110
112
|
return brokenProblem(p);
|
|
111
113
|
if (p)
|
|
112
114
|
return { project: p };
|
|
113
|
-
return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one",
|
|
115
|
+
return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one (mason init demo: a demo database, nothing to install)",
|
|
114
116
|
next: [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"]] };
|
|
115
117
|
}
|
|
116
118
|
/** Whether `root`'s .gitignore already ignores `.mason/`. */
|
package/dist/src/cli/registry.js
CHANGED
|
@@ -8,6 +8,7 @@ import { dev } from "./dev.js";
|
|
|
8
8
|
import { devInBackground } from "./background.js";
|
|
9
9
|
import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, status, stop, tables, wait } from "./commands.js";
|
|
10
10
|
import { USER, user } from "./user.js";
|
|
11
|
+
import { MCP, mcp } from "./mcp.js";
|
|
11
12
|
import { UI, ui } from "./ui.js";
|
|
12
13
|
import { GUIDES, guideText } from "./guides.js";
|
|
13
14
|
import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
|
|
@@ -97,7 +98,7 @@ async function runDev(argv) {
|
|
|
97
98
|
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
98
99
|
return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
|
|
99
100
|
if (!v.attach)
|
|
100
|
-
return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"] });
|
|
101
|
+
return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"], changes: v.replication ? "replication" : v.poll ? "poll" : null });
|
|
101
102
|
return dev({ port, version: version(), changes: v.replication ? "replication" : v.poll ? "poll" : null, publication: v.publication ?? null, selfSignedTls: v["self-signed-tls"] });
|
|
102
103
|
}
|
|
103
104
|
export const HELP = {
|
|
@@ -151,7 +152,7 @@ export const STEPS = [
|
|
|
151
152
|
["Learn", [{ ...HELP, run: runHelp }]],
|
|
152
153
|
["Set up", [{ ...DOCTOR, run: (argv) => doctor(argv) }, { ...INIT, run: (argv) => init(argv) }, { ...DEV, run: runDev }]],
|
|
153
154
|
["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
|
|
154
|
-
["Connect", [{ ...USER, run: user }]],
|
|
155
|
+
["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }]],
|
|
155
156
|
["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
|
|
156
157
|
["Go to production", [{ ...SERVE, run: serve }]],
|
|
157
158
|
];
|
|
@@ -171,7 +172,9 @@ export async function orientation(cwd, s = styleOut()) {
|
|
|
171
172
|
? [["Project", `${project.config.name}, files in ${project.folder}`], ["Mason", run ? `running (pid ${run.pid})` : "not running"]]
|
|
172
173
|
: [["Project", "none here: mason init makes this folder one"]];
|
|
173
174
|
const next = !project
|
|
174
|
-
? [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"],
|
|
175
|
+
? [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"],
|
|
176
|
+
["mason init demo", "or try it on a demo database Mason makes itself: nothing to install"],
|
|
177
|
+
["mason doctor", "what this machine needs for Mason, and whether it has it"]]
|
|
175
178
|
: !run ? [["mason start", "start Mason for this project, in the background"], ["mason status", "the project and its tables"]]
|
|
176
179
|
: [["mason status", "what Mason is doing: its tables, and the last day's slow and refused statements"], ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
|
|
177
180
|
return `${reportText({ headline: "Mason: your Postgres tables, copied and served to BI tools and your app.", details: rows,
|
|
@@ -202,14 +205,14 @@ export function topUsage(s = styleOut()) {
|
|
|
202
205
|
`${s.bold("usage:")} mason <command> [options]`,
|
|
203
206
|
"",
|
|
204
207
|
para("Mason: your Postgres tables, copied and served to BI tools and your app. Make your app's folder a project (mason init), " +
|
|
205
|
-
`
|
|
208
|
+
`add tables (mason add, before or after the start), start Mason (mason start), connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
|
|
206
209
|
para(PROJECT_LINE),
|
|
207
210
|
"",
|
|
208
211
|
listed.map(([step, es]) => ` ${s.bold(step)}\n${block(es)}`).join("\n\n"),
|
|
209
212
|
"",
|
|
210
213
|
para("mason <command> --help (or mason help <command>) says how to use one, with examples; the guides are offline and short enough to read whole."),
|
|
211
214
|
para("A command means the project of the folder it runs in (or above it)."),
|
|
212
|
-
para("Every command but help takes --json: one JSON object for a program, always with next (what to run " +
|
|
215
|
+
para("Every command but help and mcp (whose stdout is the MCP protocol's) takes --json: one JSON object for a program, always with next (what to run " +
|
|
213
216
|
"next); a failure is { error: { code, message, why, fix }, next }. The exit code says how it went: 0 done, 1 failed " +
|
|
214
217
|
"with the reason, 2 a wrong command line, 124 mason wait timed out. Driving Mason from a coding agent: mason help agent."),
|
|
215
218
|
"",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE RUN'S COMMAND SERVER (`mason start`; a server for the `mason` command alone, the CLI's UI part dropped
|
|
3
|
-
* completely). `mason
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* once — and says where everything is.
|
|
3
|
+
* completely). `mason status`, `mason wait`, `mason sync`, `mason logs --requests`, and `mason add` and `mason remove`
|
|
4
|
+
* while it is up, ask it (src/cli/control.ts): it reads the database's catalog, writes the tables added and removed into
|
|
5
|
+
* the project's sources file (src/cli/source-edit.ts, the check-and-write the commands call themselves while Mason is
|
|
6
|
+
* down) — the sync, told of the change, starts copying a table at once — and says where everything is.
|
|
7
7
|
*
|
|
8
8
|
* Every request passes the run's guard (src/cli/security.ts): loopback, the Host header, and the run's token, which
|
|
9
9
|
* the commands send as `Authorization: Bearer` from the run's settings file.
|
|
@@ -29,7 +29,8 @@ import { errorJson, nextJson } from "./command.js";
|
|
|
29
29
|
import { readiness } from "./readiness.js";
|
|
30
30
|
import { addedNext, addUser, listUsers, removeUser, REMOVED_NEXT } from "./user.js";
|
|
31
31
|
import { postgresLink } from "./pg-url.js";
|
|
32
|
-
import {
|
|
32
|
+
import { catalogTable, shownPath } from "./sources.js";
|
|
33
|
+
import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
|
|
33
34
|
import { PollRefused } from "../duck/sync.js";
|
|
34
35
|
/** A statement answered in this many milliseconds or more is slow: what mason status lists (src/duck/requests.ts slow). */
|
|
35
36
|
export const SLOW_MS = 1000;
|
|
@@ -59,6 +60,9 @@ export function runState(ctx, copies, stream) {
|
|
|
59
60
|
error: c?.phase === "failed" ? c.error : null,
|
|
60
61
|
// copied and served, but not kept current by the poll (src/duck/sync.ts POLL_ROW_LIMIT): mason status and wait say so
|
|
61
62
|
...(c?.unsynced ? { unsynced: c.unsynced } : {}),
|
|
63
|
+
// how the run keeps the copy current NOW, as the sync derives it once (src/duck/sync.ts KeptCurrent, `keptOf`):
|
|
64
|
+
// what `unsynced` and the stream's tables say for mason status, and EXPLAIN says over the wire, as data
|
|
65
|
+
...(c?.kept ? { kept: c.kept } : {}),
|
|
62
66
|
...(c ? { duck: { rows: c.rows, bytes: c.bytes, seconds: c.seconds, copiedAt: c.copiedAt, error: c.error } } : {}),
|
|
63
67
|
};
|
|
64
68
|
});
|
|
@@ -68,7 +72,7 @@ export function runState(ctx, copies, stream) {
|
|
|
68
72
|
readiness: readiness({ tables }),
|
|
69
73
|
// the run serves the web page (mason ui): mason status prints its URL
|
|
70
74
|
ui: ctx.page,
|
|
71
|
-
source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote,
|
|
75
|
+
source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote, demo: ctx.demo === true,
|
|
72
76
|
version: facts.versionText, standby: facts.standby, changes: ctx.changes, name: sources.file?.name ?? null, file: sources.file?.file ?? null,
|
|
73
77
|
...(ctx.publication ? { replication: { slot: ctx.slot, publication: ctx.publication.name, owned: ctx.publication.owned } } : {}) },
|
|
74
78
|
// a sources file edited into a mistake while the run is up: the run copies what it read last, and says so
|
|
@@ -92,111 +96,30 @@ function fileBytes(file) {
|
|
|
92
96
|
async function state(ctx) {
|
|
93
97
|
return runState(ctx, ctx.duck.copies(), await ctx.duck.stream());
|
|
94
98
|
}
|
|
95
|
-
/** The sources file as it is on disk now
|
|
99
|
+
/** The sources file as it is on disk now (src/cli/source-edit.ts sourceToEdit) — and the database it names NOW must be
|
|
100
|
+
* the one this run copies: its catalog is what an edit is checked against. */
|
|
96
101
|
function sourceOnDisk(ctx) {
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
throw new Refusal(409,
|
|
100
|
-
const names = sourceFileNames(folder);
|
|
101
|
-
if (names.length !== 1)
|
|
102
|
-
throw new Refusal(409, `${path.relative(ctx.root, sourcesDir(folder)) || sourcesDir(folder)}/ has ${names.length ? `${names.length} source files` : "no source file"}: one source per project for now`);
|
|
103
|
-
const file = path.join(sourcesDir(folder), names[0]);
|
|
104
|
-
const text = fs.readFileSync(file, "utf8");
|
|
105
|
-
const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
|
|
106
|
-
if ("problems" in parsed) {
|
|
107
|
-
const f = sourceFailure(file, ctx.root, parsed.problems);
|
|
108
|
-
throw new Refusal(409, `${f.message}${f.why ? `\n${f.why}` : ""} — fix it first`);
|
|
109
|
-
}
|
|
110
|
-
// the database the file names NOW must be the one this run copies: its catalog is what an edit is checked against
|
|
111
|
-
const read = ctx.readSource();
|
|
112
|
-
if ("message" in read)
|
|
113
|
-
throw new Refusal(409, `${read.message}${read.why ? `\n${read.why}` : ""} — fix it first`);
|
|
114
|
-
if (read.resolved !== ctx.runUrl) {
|
|
115
|
-
throw new Refusal(409, `${shownPath(ctx.root, file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run copies ${maskedUrl(ctx.runUrl)}: ` +
|
|
102
|
+
const at = sourceToEdit(ctx.folder(), ctx.root, ctx.readSource);
|
|
103
|
+
if (at.read.resolved !== ctx.runUrl) {
|
|
104
|
+
throw new Refusal(409, `${shownPath(ctx.root, at.file)}: url: now names ${maskedUrl(at.read.resolved)} (${at.read.from}), and this run copies ${maskedUrl(ctx.runUrl)}: ` +
|
|
116
105
|
"mason stop && mason start copies from the new one, then add or remove the table");
|
|
117
106
|
}
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
/** The file's other entries against the database: an edit is never written over a file the run would not take. */
|
|
121
|
-
function othersHold(ctx, parsed, without, then) {
|
|
122
|
-
const others = { ...parsed, tables: parsed.tables.filter((e) => !without.includes(e.table)) };
|
|
123
|
-
const held = addedTables(others, ctx.facts().tables, ctx.source.database, ctx.root, { replication: ctx.changes === "replication" });
|
|
124
|
-
if (!Array.isArray(held))
|
|
125
|
-
throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* ADD A TABLE: its entry in the sources file, written — after the last one, or in place of the table's own (`withTable`):
|
|
129
|
-
* every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one); `key` the columns a table
|
|
130
|
-
* with no primary key is copied by. The file's other entries must hold against the database first: an edit is never
|
|
131
|
-
* written over a file the next start would refuse.
|
|
132
|
-
*/
|
|
133
|
-
export async function pick(ctx, table, columns = ["*"], key = null) {
|
|
134
|
-
const { file, text, parsed } = sourceOnDisk(ctx);
|
|
135
|
-
const facts = ctx.facts();
|
|
136
|
-
const t = facts.tables.find((x) => x.source === table);
|
|
137
|
-
if (!t)
|
|
138
|
-
throw new Refusal(404, `${table} is not a table of ${ctx.source.database} that this user can read`);
|
|
139
|
-
if (key !== null && t.key.length)
|
|
140
|
-
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason copies it by: leave --key out`);
|
|
141
|
-
if (key === null && !t.key.length)
|
|
142
|
-
throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
|
|
143
|
-
if (key !== null && ctx.changes === "replication")
|
|
144
|
-
throw new Refusal(409, `${table} has no primary key, and logical replication cannot apply its changes by --key: copy it by polling (mason start --poll), then add it`);
|
|
145
|
-
const chosen = chooseColumns(t, columns, key);
|
|
146
|
-
if ("problem" in chosen)
|
|
147
|
-
throw new Refusal(400, chosen.problem);
|
|
148
|
-
othersHold(ctx, parsed, [table], `add ${entryName(table)}`);
|
|
149
|
-
const was = parsed.tables.find((e) => e.table === table);
|
|
150
|
-
const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: key === null ? null : [...key] };
|
|
151
|
-
// the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
|
|
152
|
-
const own = entryProblems(t, entry, facts.tables, ctx.source.database, { replication: ctx.changes === "replication" });
|
|
153
|
-
if (own.length)
|
|
154
|
-
throw new Refusal(400, own.join("; "));
|
|
155
|
-
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key]) === JSON.stringify([entry.columns, entry.exclude, entry.key]);
|
|
156
|
-
if (!same) {
|
|
157
|
-
const r = withTable(text, entry);
|
|
158
|
-
if ("problem" in r)
|
|
159
|
-
throw new Refusal(409, `${path.relative(ctx.root, file)} cannot be edited: ${r.problem}`);
|
|
160
|
-
fs.writeFileSync(file, r.text);
|
|
161
|
-
}
|
|
162
|
-
return { table, file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same };
|
|
107
|
+
return at;
|
|
163
108
|
}
|
|
109
|
+
/** The database this run copies, as an edit is checked against it: the catalog it last read, and how it reads changes. */
|
|
110
|
+
const catalogOf = (ctx) => ({ tables: ctx.facts().tables, database: ctx.source.database, replication: ctx.changes === "replication" });
|
|
164
111
|
/**
|
|
165
|
-
*
|
|
166
|
-
* run
|
|
112
|
+
* ADD A TABLE: the check-and-write every entrance shares (src/cli/source-edit.ts addEntry), against the file on disk and
|
|
113
|
+
* this run's catalog; the caller tells the run the file changed, and the sync starts the copy.
|
|
167
114
|
*/
|
|
168
|
-
export
|
|
169
|
-
|
|
170
|
-
const entries = names.map((n) => {
|
|
171
|
-
const e = findEntry(parsed, n);
|
|
172
|
-
if ("problem" in e)
|
|
173
|
-
throw new Refusal(e.usage ? 400 : 404, e.problem);
|
|
174
|
-
return e;
|
|
175
|
-
});
|
|
176
|
-
// what stays must hold: else the run's re-read refuses the file, keeps the removed table and goes on copying it
|
|
177
|
-
othersHold(ctx, parsed, entries.map((e) => e.table), `remove ${names.join(" ")}`);
|
|
178
|
-
let out = text;
|
|
179
|
-
for (const e of entries) {
|
|
180
|
-
const r = withoutTable(out, e.table);
|
|
181
|
-
if (r === null)
|
|
182
|
-
continue;
|
|
183
|
-
if ("problem" in r)
|
|
184
|
-
throw new Refusal(409, `${path.relative(ctx.root, file)} cannot be edited: ${r.problem}`);
|
|
185
|
-
out = r.text;
|
|
186
|
-
}
|
|
187
|
-
fs.writeFileSync(file, out);
|
|
188
|
-
return { removed: [...new Set(entries.map((e) => e.table))], file };
|
|
115
|
+
export function pick(ctx, table, columns = ["*"], key = null) {
|
|
116
|
+
return addEntry(sourceOnDisk(ctx), catalogOf(ctx), table, columns, key);
|
|
189
117
|
}
|
|
190
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
status;
|
|
194
|
-
failure;
|
|
195
|
-
constructor(status, message, failure) { super(message); this.status = status; this.failure = failure ?? { message, code: codeOf(status) }; }
|
|
118
|
+
/** REMOVE TABLES: the check-and-write every entrance shares (src/cli/source-edit.ts removeEntries); the run then drops their copies. */
|
|
119
|
+
export function removeTables(ctx, names) {
|
|
120
|
+
return removeEntries(sourceOnDisk(ctx), catalogOf(ctx), names);
|
|
196
121
|
}
|
|
197
|
-
/** The
|
|
198
|
-
const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
|
|
199
|
-
/** The HTTP status of a Failure by its code: the other way round. */
|
|
122
|
+
/** The HTTP status of a Failure by its code: src/cli/source-edit.ts codeOf the other way round. */
|
|
200
123
|
const statusOf = (f) => f.code === "usage" ? 400 : f.code === "not_found" ? 404 : f.code === "failed" ? 500 : 409;
|
|
201
124
|
/**
|
|
202
125
|
* EVERY ERROR ANSWER: `error`, the sentence a command prints (src/cli/control.ts reads it), and `failure`, the same
|
|
@@ -282,14 +205,14 @@ export function createRunServer(ctx) {
|
|
|
282
205
|
if (body.key !== undefined && !(Array.isArray(body.key) && body.key.length > 0 && body.key.every((c) => typeof c === "string")))
|
|
283
206
|
throw new Refusal(400, "key is a list of column names");
|
|
284
207
|
if (body.picked) {
|
|
285
|
-
const r =
|
|
208
|
+
const r = pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null);
|
|
286
209
|
if (!r.already)
|
|
287
210
|
ctx.log(`added ${r.table} to ${r.file}`);
|
|
288
211
|
ctx.sourcesChanged();
|
|
289
212
|
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
290
213
|
}
|
|
291
214
|
else {
|
|
292
|
-
const r =
|
|
215
|
+
const r = removeTables(ctx, [body.table]);
|
|
293
216
|
for (const t of r.removed)
|
|
294
217
|
ctx.log(`removed ${t} from ${r.file}`);
|
|
295
218
|
ctx.sourcesChanged();
|
|
@@ -354,7 +277,7 @@ export function createRunServer(ctx) {
|
|
|
354
277
|
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
355
278
|
if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((m) => typeof m === "string"))
|
|
356
279
|
throw new Refusal(400, "send { tables: [name, …] }");
|
|
357
|
-
const r =
|
|
280
|
+
const r = removeTables(ctx, body.tables);
|
|
358
281
|
// the sync stops copying a removed table, and drops its copy
|
|
359
282
|
ctx.sourcesChanged();
|
|
360
283
|
for (const t of r.removed)
|
package/dist/src/cli/serve.js
CHANGED
|
@@ -53,7 +53,12 @@ export const SERVE = {
|
|
|
53
53
|
MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
|
|
54
54
|
MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
|
|
55
55
|
for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
|
|
56
|
-
MASON_DUCKDB_MEMORY
|
|
56
|
+
MASON_DUCKDB_MEMORY the copy's memory, 4GB or 512MiB (default half the container's memory limit, else half the
|
|
57
|
+
machine's RAM; mason doctor says it); a statement past it fails alone with 53200
|
|
58
|
+
MASON_POLL_MS how often the poll reads the tables' changes, in ms (default 5000)
|
|
59
|
+
MASON_STATEMENT_TIMEOUT each statement's limit, as Postgres's statement_timeout (default 5min; 0 for none)
|
|
60
|
+
MASON_REQUEST_LOG_DAYS how long the request log keeps a statement, in days (default 7)
|
|
61
|
+
MASON_STORE_JSON_PATHS on (the default) stores the JSON paths statements keep reading as columns; off stores none
|
|
57
62
|
MASON_COPY_PARALLEL a copy's chunks read at once, each a backend on the database (1 to 8, default 3)
|
|
58
63
|
MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
|
|
59
64
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason add` AND `mason remove`, CHECKED AND WRITTEN — ONE GRAMMAR AT EVERY ENTRANCE. The run's command server
|
|
3
|
+
* (`POST /api/pick`, `/api/remove`, src/cli/run-server.ts: the commands with Mason up, and the web page) and the commands
|
|
4
|
+
* themselves with Mason down (src/cli/commands.ts: the file is the list, so `mason init && mason add orders && mason
|
|
5
|
+
* start` is a natural order) call these functions, so a refusal is worded ONCE and both entrances say it alike
|
|
6
|
+
* (tests/cli-commands.test.ts holds the two equal). What differs is only what each does after: the run starts or drops the
|
|
7
|
+
* copy and reads the file again; the command says the copy begins at `mason start`.
|
|
8
|
+
*
|
|
9
|
+
* Each takes the sources file as it is on disk now (`sourceToEdit`), the database's catalog as `readSourceFacts` reads it
|
|
10
|
+
* (`EditCatalog`), and the request; it checks the entry and the file's other entries against the catalog (an edit is never
|
|
11
|
+
* written over a file the next read would refuse), splices the one entry in or out (`withTable` / `withoutTable`: every
|
|
12
|
+
* other byte as it was), writes the file, and answers what it did — or throws a `Refusal`, the sentence a command shows.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { addedTables, chooseColumns, entryName, entryProblems, findEntry, parseSourceText, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withoutTable, withTable } from "./sources.js";
|
|
17
|
+
/** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
|
|
18
|
+
* default the message alone under the status's code. */
|
|
19
|
+
export class Refusal extends Error {
|
|
20
|
+
status;
|
|
21
|
+
failure;
|
|
22
|
+
constructor(status, message, failure) { super(message); this.status = status; this.failure = failure ?? { message, code: codeOf(status) }; }
|
|
23
|
+
}
|
|
24
|
+
/** The FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
|
|
25
|
+
const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
|
|
26
|
+
/**
|
|
27
|
+
* The project's sources file as it is on disk NOW, its text and what it says — or why it cannot be edited: no folder, not
|
|
28
|
+
* exactly one file, a file with mistakes of its own, a URL that cannot be resolved (`read`, the file read as the run reads
|
|
29
|
+
* it: src/cli/sources.ts readSource).
|
|
30
|
+
*/
|
|
31
|
+
export function sourceToEdit(folder, root, read) {
|
|
32
|
+
if (!folder)
|
|
33
|
+
throw new Refusal(409, "choose the project's folder first");
|
|
34
|
+
const names = sourceFileNames(folder);
|
|
35
|
+
if (names.length !== 1)
|
|
36
|
+
throw new Refusal(409, `${path.relative(root, sourcesDir(folder)) || sourcesDir(folder)}/ has ${names.length ? `${names.length} source files` : "no source file"}: one source per project for now`);
|
|
37
|
+
const file = path.join(sourcesDir(folder), names[0]);
|
|
38
|
+
const text = fs.readFileSync(file, "utf8");
|
|
39
|
+
const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
|
|
40
|
+
if ("problems" in parsed) {
|
|
41
|
+
const f = sourceFailure(file, root, parsed.problems);
|
|
42
|
+
throw new Refusal(409, `${f.message}${f.why ? `\n${f.why}` : ""} — fix it first`);
|
|
43
|
+
}
|
|
44
|
+
const r = read();
|
|
45
|
+
if ("message" in r)
|
|
46
|
+
throw new Refusal(409, `${r.message}${r.why ? `\n${r.why}` : ""} — fix it first`);
|
|
47
|
+
return { root, file, text, parsed, read: r };
|
|
48
|
+
}
|
|
49
|
+
/** The file's other entries against the database: an edit is never written over a file the run would not take. */
|
|
50
|
+
function othersHold(at, db, without, then) {
|
|
51
|
+
const others = { ...at.parsed, tables: at.parsed.tables.filter((e) => !without.includes(e.table)) };
|
|
52
|
+
const held = addedTables(others, db.tables, db.database, at.root, { replication: db.replication });
|
|
53
|
+
if (!Array.isArray(held))
|
|
54
|
+
throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* ADD A TABLE (`table`: schema.table): its entry in the sources file, written — after the last one, or in place of the
|
|
58
|
+
* table's own (`withTable`): every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one);
|
|
59
|
+
* `key` the columns a table with no primary key is copied by. The entry and the file's other entries must hold against
|
|
60
|
+
* the database first: an edit is never written over a file the next read would refuse.
|
|
61
|
+
*/
|
|
62
|
+
export function addEntry(at, db, table, columns = ["*"], key = null) {
|
|
63
|
+
const t = db.tables.find((x) => x.source === table);
|
|
64
|
+
if (!t)
|
|
65
|
+
throw new Refusal(404, `${table} is not a table of ${db.database} that this user can read`);
|
|
66
|
+
if (key !== null && t.key.length)
|
|
67
|
+
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason copies it by: leave --key out`);
|
|
68
|
+
if (key === null && !t.key.length)
|
|
69
|
+
throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
|
|
70
|
+
if (key !== null && db.replication)
|
|
71
|
+
throw new Refusal(409, `${table} has no primary key, and logical replication cannot apply its changes by --key: copy it by polling (mason start --poll), then add it`);
|
|
72
|
+
const chosen = chooseColumns(t, columns, key);
|
|
73
|
+
if ("problem" in chosen)
|
|
74
|
+
throw new Refusal(400, chosen.problem);
|
|
75
|
+
othersHold(at, db, [table], `add ${entryName(table)}`);
|
|
76
|
+
const was = at.parsed.tables.find((e) => e.table === table);
|
|
77
|
+
const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: key === null ? null : [...key] };
|
|
78
|
+
// the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
|
|
79
|
+
const own = entryProblems(t, entry, db.tables, db.database, { replication: db.replication });
|
|
80
|
+
if (own.length)
|
|
81
|
+
throw new Refusal(400, own.join("; "));
|
|
82
|
+
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key]) === JSON.stringify([entry.columns, entry.exclude, entry.key]);
|
|
83
|
+
if (!same) {
|
|
84
|
+
const r = withTable(at.text, entry);
|
|
85
|
+
if ("problem" in r)
|
|
86
|
+
throw new Refusal(409, `${path.relative(at.root, at.file)} cannot be edited: ${r.problem}`);
|
|
87
|
+
fs.writeFileSync(at.file, r.text);
|
|
88
|
+
}
|
|
89
|
+
return { table, file: at.file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was. A
|
|
93
|
+
* name the file does not have is refused with the closest one it has; the entries that stay must hold against the
|
|
94
|
+
* database — else the run's re-read refuses the file, keeps the removed table and goes on copying it while the command
|
|
95
|
+
* said "Removed".
|
|
96
|
+
*/
|
|
97
|
+
export function removeEntries(at, db, names) {
|
|
98
|
+
const entries = names.map((n) => {
|
|
99
|
+
const e = findEntry(at.parsed, n);
|
|
100
|
+
if ("problem" in e)
|
|
101
|
+
throw new Refusal(e.usage ? 400 : 404, e.problem);
|
|
102
|
+
return e;
|
|
103
|
+
});
|
|
104
|
+
othersHold(at, db, entries.map((e) => e.table), `remove ${names.join(" ")}`);
|
|
105
|
+
let out = at.text;
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
const r = withoutTable(out, e.table);
|
|
108
|
+
if (r === null)
|
|
109
|
+
continue;
|
|
110
|
+
if ("problem" in r)
|
|
111
|
+
throw new Refusal(409, `${path.relative(at.root, at.file)} cannot be edited: ${r.problem}`);
|
|
112
|
+
out = r.text;
|
|
113
|
+
}
|
|
114
|
+
fs.writeFileSync(at.file, out);
|
|
115
|
+
return { removed: [...new Set(entries.map((e) => e.table))], file: at.file };
|
|
116
|
+
}
|
package/dist/src/cli/sources.js
CHANGED
|
@@ -201,8 +201,9 @@ export function sourceFileNames(folder) {
|
|
|
201
201
|
return [];
|
|
202
202
|
return fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
|
|
203
203
|
}
|
|
204
|
-
/** The file `mason init` writes: the database, then its tables
|
|
205
|
-
|
|
204
|
+
/** The file `mason init` writes: the database, then its tables — none yet, but for `mason init demo`'s, which names the
|
|
205
|
+
* demo database's tables (`tables`, each copied whole: `{}`), so its first start copies them and shows something. */
|
|
206
|
+
export function newSourceText(url = "${DATABASE_URL}", tables = []) {
|
|
206
207
|
return "# The database this project copies from, and which of its tables. Yours to commit; the URL stays out of it.\n" +
|
|
207
208
|
"kind: postgres\n" +
|
|
208
209
|
`url: ${url}${ENV_REF.test(url) ? " # from .env.local, .env or the shell; a literal postgres:// URL works too" : ""}\n` +
|
|
@@ -210,7 +211,7 @@ export function newSourceText(url = "${DATABASE_URL}") {
|
|
|
210
211
|
"# One entry per table, a bare name in public, else schema.table: {} copies every column, columns: [id, status]\n" +
|
|
211
212
|
"# those, exclude: [payload] every column but those; key: [a, b] names the columns that tell its rows apart, for a\n" +
|
|
212
213
|
"# table with no primary key. mason add and mason remove edit this list; so can you.\n" +
|
|
213
|
-
"tables: {}\n";
|
|
214
|
+
(tables.length ? `tables:\n${tables.map((t) => ` ${t}: {}\n`).join("")}` : "tables: {}\n");
|
|
214
215
|
}
|
|
215
216
|
/** A source's name from its database's: the file is named for it, else `app`. */
|
|
216
217
|
export function sourceNameFor(database) {
|
|
@@ -299,7 +300,8 @@ export function entryProblems(t, e, all, database, o) {
|
|
|
299
300
|
* A `key:` MUST TELL THE ROWS APART, AS THE DATABASE ENFORCES IT (the review of the sources file: a key with
|
|
300
301
|
* duplicates failed the copy's distinct-key check as "Mason bug", retried for ever, and duplicates after the copy made the
|
|
301
302
|
* poll's update and delete by key change both rows): every column NOT NULL, and a unique index of the table over some of
|
|
302
|
-
* them — then no two rows can share the key. Partial and
|
|
303
|
+
* them — then no two rows can share the key. Partial, expression and INVALID indexes are not read (src/cli/preflight.ts
|
|
304
|
+
* TABLES_SQL: a failed CREATE UNIQUE INDEX CONCURRENTLY leaves an invalid one, its duplicates still in the table).
|
|
303
305
|
*/
|
|
304
306
|
function keyProblems(t, key, at) {
|
|
305
307
|
const cols = key.filter((c) => t.columns.some((x) => x.name === c));
|