@mason-data/cli 0.0.0-stage → 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 (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,301 @@
1
+ /**
2
+ * `mason serve` (deploying quickly to a server — one's own or a managed platform — must be as easy as possible,
3
+ * wherever the Postgres database is): the DuckDB run of `mason start --attach` (src/cli/dev.ts), for a server or a
4
+ * container. Whatever a platform gives a process, and nothing else:
5
+ *
6
+ * - ITS SETTINGS FROM THE ENVIRONMENT (`serveSettings`): the database's URL, the API key the tokens are minted with,
7
+ * the project folder (the team's `mason/`, committed: its sources file names the database and the tables copied,
8
+ * its URL `${DATABASE_URL}` from this environment) and the data folder.
9
+ * Nothing is asked; every setting missing or wrong is said at once, each with what to set (one report).
10
+ * - ITS STATE ON ONE VOLUME: the data folder's `.mason/` — the config this command writes from the environment at
11
+ * every start (`serveConfig`), the DuckDB file, the logs. A restart resumes the copy; a new volume copies again.
12
+ * - IN THE FOREGROUND, ITS LOG ON STDOUT: a container's log is its stdout. A run that cannot start exits 1, so the
13
+ * platform restarts it; there is no supervisor of its own (src/cli/supervise.ts is `mason start`'s).
14
+ * - A HEALTH ENDPOINT (`healthBody`): `GET /healthz` on MASON_HEALTH_PORT, 503 while it starts and stops, 200 once it
15
+ * answers. It needs no credential, so it names nothing of the database: counts only.
16
+ * - A CLEAN STOP ON SIGTERM: the run's own stop (a copy in flight interrupted and rolled back, DuckDB closed).
17
+ *
18
+ * The commands beside it work as on a laptop, in the data folder: `docker compose exec mason mason add orders`.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as http from "node:http";
22
+ import * as path from "node:path";
23
+ import { failed, parseCommand } from "./command.js";
24
+ import { dev } from "./dev.js";
25
+ import { parseSource } from "./preflight.js";
26
+ import { NAME_RE, newConfig, readProject, STATE_DIR, writeConfig } from "./project.js";
27
+ import { newSourceText, OLD_TABLES_DIR, parseSourceText, readSource, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
28
+ import { appEnvValue } from "../lib/dotenv.js";
29
+ import { version } from "./version.js";
30
+ export const SERVE = {
31
+ name: "serve",
32
+ summary: "run Mason on a server or in a container, in the foreground, configured by the environment",
33
+ usage: `usage: mason serve [--json]
34
+
35
+ Runs Mason as mason start --attach does, for a server or a container: nothing asked, its settings from the
36
+ environment, its state in one folder (a volume), its log on stdout, GET /healthz for the platform's health check,
37
+ and a clean stop on SIGTERM. The tables it copies are the ones the project folder's sources file names
38
+ (sources/<name>.yml): make it with mason init and mason add on a laptop and commit it, or with mason add beside the
39
+ running server. A project folder with no sources file gets one, url: \${DATABASE_URL}, with no table yet.
40
+
41
+ DATABASE_URL=postgres://mason:…@db.internal:5432/app MASON_API_KEY=… mason serve
42
+ docker compose exec mason mason add public.orders beside it, in its container
43
+
44
+ Settings (the environment):
45
+ DATABASE_URL the database to copy from, as the sources file's url: \${DATABASE_URL} names it (the variable
46
+ the file names is required); read-only: SELECTs, or a replication slot
47
+ MASON_API_KEY what the users' passwords are made with (mason user add), at least 32 characters (required)
48
+ MASON_PROJECT the project folder: sources/, mason.yml (default ./mason)
49
+ MASON_DATA the folder whose .mason/ holds the copy and the state: the volume (default .)
50
+ MASON_NAME the project's name, the database name clients connect to (default mason)
51
+ MASON_CHANGES poll (SELECTs, the default) or replication (the change log; PG_PUBLICATION names one)
52
+ MASON_HEALTH_PORT /healthz's port (default 4140), on MASON_HEALTH_HOST (default 0.0.0.0)
53
+ MASON_PG_HOST, MASON_PG_PORT the Postgres API's address (default 127.0.0.1:5433)
54
+ MASON_PG_TLS_CERT, MASON_PG_TLS_KEY its certificate, required off loopback; or MASON_PG_SELF_SIGNED_TLS=1
55
+ for one made in .mason/, or MASON_PG_PLAIN=1 behind a TLS proxy of your own
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
62
+ MASON_COPY_PARALLEL a copy's chunks read at once, each a backend on the database (1 to 8, default 3)
63
+ MASON_COPY_CHUNK_SECONDS how long each chunk takes to read, at the rate measured (1 to 300, default 12)
64
+
65
+ --json a refusal to start as data: { error, next }; once it serves, its output is its log
66
+
67
+ Exit status: 0 stopped by SIGTERM or SIGINT; 1 it could not start (said); 2 a wrong command line.
68
+ `,
69
+ options: { json: { type: "boolean", default: false } },
70
+ json: ["next"],
71
+ };
72
+ const MIN_API_KEY = 32;
73
+ /** THE SETTINGS, OR EVERY ONE MISSING OR WRONG AT ONCE: a platform's deploy is slow, and one variable a deploy wastes it. */
74
+ export function serveSettings(env, cwd) {
75
+ const wrong = [];
76
+ const project = path.resolve(cwd, env.MASON_PROJECT || "mason");
77
+ const data = path.resolve(cwd, env.MASON_DATA || ".");
78
+ // the database's URL, with the rest: the variable the sources file names (DATABASE_URL for the one a serve writes)
79
+ wrong.push(...urlSettingProblems(project, data, env));
80
+ const apiKey = env.MASON_API_KEY ?? "";
81
+ if (!apiKey)
82
+ wrong.push("MASON_API_KEY is not set: what the users' passwords are made with (mason user add; openssl rand -hex 24 makes one)");
83
+ else if (apiKey.length < MIN_API_KEY)
84
+ wrong.push(`MASON_API_KEY is ${apiKey.length} characters: at least ${MIN_API_KEY}, as openssl rand -hex 24 makes`);
85
+ const name = env.MASON_NAME || "mason";
86
+ if (!NAME_RE.test(name))
87
+ wrong.push(`MASON_NAME=${JSON.stringify(name)} is not a project name: lower case letters, digits, - and _, at most 48`);
88
+ const changes = env.MASON_CHANGES || null;
89
+ if (changes !== null && changes !== "poll" && changes !== "replication")
90
+ wrong.push(`MASON_CHANGES=${JSON.stringify(changes)}: poll or replication`);
91
+ const selfSigned = env.MASON_PG_SELF_SIGNED_TLS || "0";
92
+ if (selfSigned !== "0" && selfSigned !== "1")
93
+ wrong.push(`MASON_PG_SELF_SIGNED_TLS=${JSON.stringify(selfSigned)}: 1, or 0 (the default)`);
94
+ const portText = env.MASON_HEALTH_PORT || "4140";
95
+ const port = Number(portText);
96
+ if (!/^\d+$/.test(portText) || port < 1 || port > 65_535)
97
+ wrong.push(`MASON_HEALTH_PORT=${JSON.stringify(portText)}: a port number, 1-65535`);
98
+ if (wrong.length) {
99
+ return { message: `${wrong.length === 1 ? "a setting is" : `${wrong.length} settings are`} missing or wrong: ${wrong.join("; ")}`, code: "bad_config",
100
+ why: "mason serve reads its settings from the environment, and asks nothing",
101
+ fix: "set them in the environment (a compose file's .env, the platform's variables), then start it again",
102
+ next: [["mason serve --help", "every setting, with its default"]] };
103
+ }
104
+ return {
105
+ name, apiKey, changes: changes, selfSignedTls: selfSigned === "1",
106
+ project, data,
107
+ health: { host: env.MASON_HEALTH_HOST || "0.0.0.0", port },
108
+ };
109
+ }
110
+ /**
111
+ * THE DATABASE'S URL AS A SETTING (the review of the sources file: an unset URL was found only after the other
112
+ * settings passed, two deploys for two variables): the variable the project's sources file names — DATABASE_URL when it
113
+ * has none yet (serveSource writes one naming it) — set and a postgres:// URL, read from the environment first (a .env in
114
+ * the data folder never overrides the platform's). The file's own mistakes are serveSource's, on its own report.
115
+ */
116
+ function urlSettingProblems(project, data, env) {
117
+ const names = sourceFileNames(project);
118
+ let variable = "DATABASE_URL";
119
+ if (names.length === 0) {
120
+ if (fs.existsSync(path.join(project, OLD_TABLES_DIR)))
121
+ return []; // refused by readSource, with the way out
122
+ if (!env.DATABASE_URL)
123
+ return [`DATABASE_URL is not set, and the project folder (${project}) has no sources file to name the database another way`];
124
+ }
125
+ else if (names.length === 1) {
126
+ const file = path.join(sourcesDir(project), names[0]);
127
+ let text;
128
+ // a file that cannot be read is serveSource's to say (readSource), with the file and the error, on its own report
129
+ try {
130
+ text = fs.readFileSync(file, "utf8");
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
136
+ if ("problems" in parsed || "literal" in parsed.url)
137
+ return [];
138
+ variable = parsed.url.env;
139
+ }
140
+ else
141
+ return [];
142
+ const found = appEnvValue(data, variable, env, { shellFirst: true });
143
+ if (found === null)
144
+ return [`${variable} is not set: the database to copy from, as the sources file's url: \${${variable}} names it`];
145
+ const p = parseSource(found.value);
146
+ return "problem" in p ? [`${variable}: ${p.problem}`] : [];
147
+ }
148
+ /**
149
+ * THE CONFIG A SERVE RUNS ON, written from its settings at every start, over what the runs before recorded (the server
150
+ * the copy was taken from, the publication, the certificate): the environment is the operator's, the rest the run's. A
151
+ * project `mason init` made is a developer's and is never taken over; a name that moved would be another slot and
152
+ * another database name over the same copy, so it is refused.
153
+ */
154
+ export function serveConfig(s, existing) {
155
+ const dir = path.join(s.data, STATE_DIR);
156
+ if (existing === null) {
157
+ const config = { ...newConfig(s.name, s.project, true), apiKey: s.apiKey, served: true };
158
+ return { config, said: [`a new copy in ${dir}`] };
159
+ }
160
+ if ("broken" in existing) {
161
+ return { message: `the state in ${dir} cannot be read: ${existing.file}: ${existing.broken}`, code: "bad_config",
162
+ fix: `MASON_DATA names a folder of its own for mason serve; move ${dir} aside to copy again` };
163
+ }
164
+ const was = existing.config;
165
+ if (was.served !== true) {
166
+ return { message: `${dir} is a project mason init made, and mason serve keeps its own state`, code: "refused",
167
+ why: "its config is a developer's (its database, its key), which a serve would overwrite from the environment",
168
+ fix: "point MASON_DATA at an empty folder (the volume), or run mason start here" };
169
+ }
170
+ if (was.name !== s.name) {
171
+ return { message: `the copy in ${dir} is the project ${was.name}, and MASON_NAME says ${s.name}`, code: "refused",
172
+ why: "the name is the database name clients connect to and names the replication slot: another name over the same copy is another project",
173
+ fix: `set MASON_NAME=${was.name} again, or give the new name a new MASON_DATA (an empty volume), which copies again` };
174
+ }
175
+ const said = [`the copy in ${dir}, resumed`];
176
+ if (was.apiKey !== s.apiKey)
177
+ said.push("MASON_API_KEY changed: every mason user is removed (mason user add each again)");
178
+ // the source is the sources file's; a database restored elsewhere, or another one, is the run's to see (sourceMove)
179
+ const { source: _old, ...kept } = was;
180
+ return { config: { ...kept, apiKey: s.apiKey, folder: s.project, remote: true, served: true }, said };
181
+ }
182
+ /**
183
+ * THE PROJECT FOLDER'S SOURCES FILE, for a serve: one there is checked as the run reads it (src/cli/sources.ts
184
+ * readSource: its URL resolved from this environment, every mistake on one report); a folder with none gets the file
185
+ * `mason init` writes, `url: ${DATABASE_URL}` and no table, when DATABASE_URL is set — else what to set is said.
186
+ */
187
+ export function serveSource(s, env) {
188
+ let wrote = null;
189
+ if (sourceFileNames(s.project).length === 0 && !fs.existsSync(path.join(s.project, OLD_TABLES_DIR))) {
190
+ const url = env.DATABASE_URL ?? "";
191
+ if (!url) {
192
+ return { message: "DATABASE_URL is not set, and the project folder has no sources file to name the database another way", code: "bad_config",
193
+ fix: "set DATABASE_URL=postgres://user:password@host:5432/app (the sources file mason init writes names it), or mount the project folder with its sources/ (MASON_PROJECT)",
194
+ next: [["mason serve --help", "every setting, with its default"]] };
195
+ }
196
+ const p = parseSource(url);
197
+ const file = path.join(sourcesDir(s.project), `${sourceNameFor("source" in p ? p.source.database : "")}.yml`);
198
+ try {
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ fs.writeFileSync(file, newSourceText());
201
+ }
202
+ catch (e) {
203
+ return { message: `the project folder has no sources file, and one cannot be written: ${e.message}`, code: "refused",
204
+ fix: `MASON_PROJECT names the project folder (sources/, mason.yml): mount it there with its sources file, or make ${file} in it` };
205
+ }
206
+ wrote = file;
207
+ }
208
+ const r = readSource(s.project, s.data, env, { shellFirst: true });
209
+ return "message" in r ? r : { wrote };
210
+ }
211
+ /**
212
+ * /healthz's answer: 200 once the run answers, whatever its copies are doing (a table still copying, or one that failed,
213
+ * leaves the rest served: the counts say it); 503 while it starts and stops. Counts only, never a table's name: the
214
+ * endpoint takes no credential.
215
+ */
216
+ export function healthBody(state, startedAt, now = Date.now()) {
217
+ const base = { status: state.phase, version: version(), uptimeSeconds: Math.floor((now - startedAt) / 1000) };
218
+ if (state.phase !== "ready")
219
+ return { status: 503, body: base };
220
+ const counts = { total: 0, copied: 0, copying: 0, waiting: 0, failed: 0 };
221
+ for (const t of state.tables()) {
222
+ counts.total++;
223
+ counts[t.phase]++;
224
+ }
225
+ return { status: 200, body: { ...base, tables: counts } };
226
+ }
227
+ /** The health endpoint: GET /healthz, nothing else. */
228
+ export function healthServer(state, startedAt) {
229
+ return http.createServer((req, res) => {
230
+ const url = new URL(req.url ?? "/", "http://health");
231
+ if (url.pathname !== "/healthz") {
232
+ res.writeHead(404, { "content-type": "text/plain" }).end("GET /healthz\n");
233
+ return;
234
+ }
235
+ if (req.method !== "GET" && req.method !== "HEAD") {
236
+ res.writeHead(405, { allow: "GET, HEAD" }).end();
237
+ return;
238
+ }
239
+ const h = healthBody(state(), startedAt);
240
+ res.writeHead(h.status, { "content-type": "application/json", "cache-control": "no-store" }).end(`${JSON.stringify(h.body)}\n`);
241
+ });
242
+ }
243
+ const say = (m) => { process.stdout.write(`${m}\n`); };
244
+ export async function serve(argv) {
245
+ const f = parseCommand(SERVE, argv);
246
+ if (typeof f === "number")
247
+ return f;
248
+ const json = f.values.json === true;
249
+ if (f.positionals.length)
250
+ return failed("serve", { message: "it takes no arguments: its settings are the environment's", code: "usage", usage: SERVE.usage }, { json });
251
+ const s = serveSettings(process.env, process.cwd());
252
+ if ("message" in s)
253
+ return failed("serve", s, { json });
254
+ // the project folder: its sources file is the list of what is copied, one with no table written when there is none
255
+ const made = serveSource(s, process.env);
256
+ if ("message" in made)
257
+ return failed("serve", made, { json });
258
+ const c = serveConfig(s, readProject(s.data));
259
+ if ("message" in c)
260
+ return failed("serve", c, { json });
261
+ try {
262
+ writeConfig(path.join(s.data, STATE_DIR), c.config);
263
+ }
264
+ catch (e) {
265
+ return failed("serve", { message: `the state cannot be written to ${path.join(s.data, STATE_DIR)}: ${e.message}`, code: "refused",
266
+ fix: "MASON_DATA names a folder this user can write: the volume's mount point" }, { json });
267
+ }
268
+ const startedAt = Date.now();
269
+ let state = { phase: "starting" };
270
+ const health = healthServer(() => state, startedAt);
271
+ const listening = await new Promise((resolve) => {
272
+ health.once("error", resolve);
273
+ health.listen(s.health.port, s.health.host, () => resolve(null));
274
+ });
275
+ if (listening !== null) {
276
+ return failed("serve", { message: `the health endpoint cannot listen on ${s.health.host}:${s.health.port}: ${listening.message}`, code: "refused",
277
+ fix: "MASON_HEALTH_PORT names a port free on this machine" }, { json });
278
+ }
279
+ say(`mason serve: ${s.name}, the project in ${s.project}, the state in ${path.join(s.data, STATE_DIR)}`);
280
+ if (made.wrote)
281
+ say(` wrote ${made.wrote}: the database (url: \${DATABASE_URL}), no table yet (mason add beside it adds one)`);
282
+ for (const l of c.said)
283
+ say(` ${l}`);
284
+ say(` health http://${s.health.host}:${s.health.port}/healthz`);
285
+ // the run reads the project of the folder it runs in (src/cli/project.ts): the data folder's
286
+ process.chdir(s.data);
287
+ let code;
288
+ try {
289
+ code = await dev({ port: 4039, version: version(), changes: s.changes, publication: null, selfSignedTls: s.selfSignedTls,
290
+ serve: { log: say, ready: (run) => { state = { phase: "ready", tables: run.tables }; }, stopping: () => { state = { phase: "stopping" }; } } });
291
+ }
292
+ finally {
293
+ // a platform's health check may hold a keep-alive connection open, which close() alone would wait for
294
+ health.closeAllConnections();
295
+ await new Promise((resolve) => health.close(() => resolve()));
296
+ }
297
+ if (code !== 0)
298
+ return failed("serve", { message: "Mason did not start: what stopped it is said above", code: "failed",
299
+ fix: "fix what it says, then start it again (a platform restarts it by itself)" }, { json: false });
300
+ return 0;
301
+ }
@@ -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
+ }