@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
@@ -25,7 +25,9 @@ export function maskedUrl(url) {
25
25
  return u.toString();
26
26
  }
27
27
  catch {
28
- return url.replace(/:\/\/([^:@/]*):[^@/]*@/, "://$1:****@");
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
- return { problem: `${JSON.stringify(url)} is not a URL. The form is postgres://user:password@localhost:5432/your_app — a password ` +
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
- WHERE i.indrelid = c.oid AND i.indisunique AND NOT i.indisprimary AND i.indpred IS NULL AND i.indexprs IS NULL
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,
@@ -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/`. */
@@ -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://…)"], ["mason doctor", "what this machine needs for Mason, and whether it has it"]]
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
- `start Mason (mason start), add tables, connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
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 tables`, `mason add`, `mason status`, `mason wait`, `mason sync` and
4
- * `mason logs --requests` ask it (src/cli/control.ts): it reads the database's catalog, writes the tables added and
5
- * removed into the project's sources file (src/cli/sources.ts) — the sync, told of the change, starts copying a table at
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 { addedTables, catalogTable, chooseColumns, entryName, entryProblems, findEntry, parseSourceText, shownPath, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withoutTable, withTable } from "./sources.js";
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, its text and what it says — or why the command server cannot edit it. */
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 folder = ctx.folder();
98
- if (!folder)
99
- throw new Refusal(409, "choose the project's folder first");
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 { file, text, parsed };
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
- * REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was; the
166
- * run then drops its copy. A name the file does not have is refused with the closest one it has.
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 async function removeTables(ctx, names) {
169
- const { file, text, parsed } = sourceOnDisk(ctx);
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
- /** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
191
- * default the message alone under the status's code. */
192
- export class Refusal extends Error {
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 FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
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 = await pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null);
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 = await removeTables(ctx, [body.table]);
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 = await removeTables(ctx, body.tables);
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)
@@ -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, MASON_POLL_MS, MASON_STATEMENT_TIMEOUT, MASON_STORE_JSON_PATHS as for mason start
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
+ }
@@ -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 (none yet). */
205
- export function newSourceText(url = "${DATABASE_URL}") {
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 expression indexes are not read (src/cli/preflight.ts).
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));