@mason-data/cli 0.0.0-stage → 0.6.2
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/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROJECT'S SOURCE (models meant nothing in the DuckDB version of Mason, so they gave way to a `sources` folder,
|
|
3
|
+
* each file a source to sync naming the tables and columns it syncs). One file, `<folder>/sources/<name>.yml`, the
|
|
4
|
+
* project's to commit:
|
|
5
|
+
*
|
|
6
|
+
* kind: postgres
|
|
7
|
+
* url: ${DATABASE_URL} an env var from .env.local, .env or the shell; a literal URL for local dev
|
|
8
|
+
* tables:
|
|
9
|
+
* orders: a bare name is in `public`; `billing.invoices` names its schema
|
|
10
|
+
* columns: [id, customer_id, status]
|
|
11
|
+
* customers: {} every column
|
|
12
|
+
* events:
|
|
13
|
+
* exclude: [raw_body] every column but these
|
|
14
|
+
* sessions:
|
|
15
|
+
* key: [tenant_id, id] only for a table with no primary key: the unique columns to copy it by
|
|
16
|
+
*
|
|
17
|
+
* The file's name is the source's name; one source per project for now. Types are NOT in it: the run reads them from
|
|
18
|
+
* the source at every start. Every mistake is said with the file, the key, what is wrong and the way out — all of a
|
|
19
|
+
* file's mistakes on ONE report (`sourceFailure`), so a coding agent fixes the file from the message alone.
|
|
20
|
+
*
|
|
21
|
+
* Read in two steps, because the second needs the database: `readSource` (the file alone: its shape, its URL resolved)
|
|
22
|
+
* and `addedTables` (its tables against the database's catalog: what the run copies, `AddedTable`). `withTable` and
|
|
23
|
+
* `withoutTable` are what `mason add` and `mason remove` write: one entry spliced into or out of the text at the YAML
|
|
24
|
+
* node's own position, every other byte — a person's comments and layout — as it was.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { isMap, isScalar, parseDocument } from "yaml";
|
|
29
|
+
import { appEnvValue, ENV_FILE } from "../lib/dotenv.js";
|
|
30
|
+
import { closest, didYouMean } from "../lib/did-you-mean.js";
|
|
31
|
+
import { maskedUrl } from "./preflight.js";
|
|
32
|
+
export const SOURCES_DIR = "sources";
|
|
33
|
+
const KEYS = ["kind", "url", "tables"];
|
|
34
|
+
const ENTRY_KEYS = ["columns", "exclude", "key"];
|
|
35
|
+
const ENV_REF = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
36
|
+
/** A file as messages name it: relative to the project's root. */
|
|
37
|
+
export const shownPath = (root, file) => path.relative(root, file) || file;
|
|
38
|
+
/** A table as a key of the file: its name alone in public, else schema.table. */
|
|
39
|
+
export const entryName = (source) => source.startsWith("public.") ? source.slice("public.".length) : source;
|
|
40
|
+
/** The table a key names: `orders` is `public.orders`, `billing.invoices` itself. */
|
|
41
|
+
export const sourceOf = (name) => name.includes(".") ? name : `public.${name}`;
|
|
42
|
+
/**
|
|
43
|
+
* WHAT A TABLE'S NAME MAY BE, at every entrance — the file's key, mason add, mason remove, mason tables, mason sync: its
|
|
44
|
+
* name alone (in public) or schema.table. A third part is refused by name, never read as a name holding a dot: a
|
|
45
|
+
* project will sync several databases, and `source.schema.table` will name a table of one of them. `near` are the
|
|
46
|
+
* tables (schema.table) it may have meant, its last two parts' closest said as `did you mean`. Null when it is a name.
|
|
47
|
+
*/
|
|
48
|
+
export function tableNameProblem(name, near = []) {
|
|
49
|
+
if (name === "" || name.trim() !== name || /^\.|\.$|\.\./.test(name))
|
|
50
|
+
return "a table is its name (orders) or schema.table (billing.invoices)";
|
|
51
|
+
const parts = name.split(".");
|
|
52
|
+
if (parts.length <= 2)
|
|
53
|
+
return null;
|
|
54
|
+
const tail = parts.slice(-2).join("."), all = [...near];
|
|
55
|
+
const hit = all.includes(tail) ? tail : closest(tail, all);
|
|
56
|
+
return `${name} has ${parts.length} parts, and a table is schema.table (billing.invoices) or its name alone in public (orders): ` +
|
|
57
|
+
`this project has one source, so a table's name has at most two${hit ? ` — did you mean \`${entryName(hit)}\`?` : ""}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A FILE'S MISTAKES, ALL OF THEM (CLAUDE.md "An error a developer meets says what to do about it"): each one at its key
|
|
61
|
+
* with what to change, said together as one failure — the one as the message, or a count with each under `why` — so
|
|
62
|
+
* fixing one never uncovers the next only at the next start.
|
|
63
|
+
*/
|
|
64
|
+
export function sourceFailure(file, root, problems, next = []) {
|
|
65
|
+
const f = shownPath(root, file);
|
|
66
|
+
const one = problems.length === 1;
|
|
67
|
+
return {
|
|
68
|
+
message: one ? `${f}: ${problems[0]}` : `${f} has ${problems.length} mistakes`,
|
|
69
|
+
code: "bad_source",
|
|
70
|
+
...(one ? {} : { why: problems.map((p) => `- ${p}`).join("\n") }),
|
|
71
|
+
fix: `edit ${f}, then run the command again`,
|
|
72
|
+
...(next.length ? { next } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** What YAML read a bare word as, said: `true is read by YAML as a boolean, not a name`. */
|
|
76
|
+
const yamlRead = (x) => `${x === null ? "null" : String(x)} is read by YAML as ${x === null ? "nothing (null)" : typeof x === "boolean" ? "a boolean" : typeof x === "number" ? "a number" : "a list or a map"}, not a name`;
|
|
77
|
+
const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
78
|
+
/** A list of column names at `at`, or the problem with it. */
|
|
79
|
+
function nameList(v, at, what) {
|
|
80
|
+
if (Array.isArray(v) && v.length) {
|
|
81
|
+
// a name YAML reads as something else (`true`, `null`, `1`): quoted, it is the column's name
|
|
82
|
+
const other = v.find((x) => typeof x !== "string");
|
|
83
|
+
if (other !== undefined)
|
|
84
|
+
return `${at}: ${yamlRead(other)} — quote it: "${String(other)}"`;
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === "string" && x !== ""))
|
|
87
|
+
return `${at}: is ${what}, a list of column names: [id, status]`;
|
|
88
|
+
const twice = v.find((x, i) => v.indexOf(x) !== i);
|
|
89
|
+
if (twice !== undefined)
|
|
90
|
+
return `${at}: names ${String(twice)} twice`;
|
|
91
|
+
return v;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The file's text read as a source file: its shape, never the database (that is `addedTables`'s). `name` is the
|
|
95
|
+
* source's (the file's name); the problems are each at their key, in the order they are in the file.
|
|
96
|
+
*/
|
|
97
|
+
export function parseSourceText(text, name, file) {
|
|
98
|
+
const doc = parseDocument(text, { uniqueKeys: true });
|
|
99
|
+
if (doc.errors.length)
|
|
100
|
+
return { problems: [`it is not YAML: ${doc.errors[0].message.split("\n")[0]}`] };
|
|
101
|
+
const v = doc.toJS();
|
|
102
|
+
if (!isPlain(v))
|
|
103
|
+
return { problems: [`it is ${v === null || v === undefined ? "empty" : "not a mapping"}: a source file is kind: postgres, url: \${DATABASE_URL} and tables: {}`] };
|
|
104
|
+
const problems = [];
|
|
105
|
+
for (const k of Object.keys(v)) {
|
|
106
|
+
if (!KEYS.includes(k))
|
|
107
|
+
problems.push(`unexpected key ${k}${didYouMean(k, KEYS)} — a source file takes ${KEYS.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
let kind = null;
|
|
110
|
+
if (v.kind === undefined || v.kind === null)
|
|
111
|
+
problems.push("kind: missing — write kind: postgres (the kind of database this source is)");
|
|
112
|
+
else if (v.kind === "postgres")
|
|
113
|
+
kind = "postgres";
|
|
114
|
+
else if (typeof v.kind === "string")
|
|
115
|
+
problems.push(`kind: ${v.kind} is not supported yet — postgres is the only kind for now`);
|
|
116
|
+
else
|
|
117
|
+
problems.push("kind: write kind: postgres");
|
|
118
|
+
let url = null;
|
|
119
|
+
if (v.url === undefined || v.url === null)
|
|
120
|
+
problems.push("url: missing — write url: ${DATABASE_URL} (DATABASE_URL in .env or the shell), or a postgres:// URL");
|
|
121
|
+
else if (typeof v.url !== "string" || v.url.trim() === "")
|
|
122
|
+
problems.push("url: write url: ${DATABASE_URL}, or a postgres:// URL");
|
|
123
|
+
else {
|
|
124
|
+
const m = ENV_REF.exec(v.url.trim());
|
|
125
|
+
const bare = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(v.url.trim());
|
|
126
|
+
if (m)
|
|
127
|
+
url = { env: m[1] };
|
|
128
|
+
else if (bare)
|
|
129
|
+
problems.push(`url: $${bare[1]} — write \${${bare[1]}}: a variable is named in braces`);
|
|
130
|
+
else if (v.url.includes("${"))
|
|
131
|
+
problems.push(`url: ${JSON.stringify(v.url)} — write the URL whole, or \${NAME} alone: the whole URL in one variable, like \${DATABASE_URL}`);
|
|
132
|
+
else
|
|
133
|
+
url = { literal: v.url.trim() };
|
|
134
|
+
}
|
|
135
|
+
const tables = [];
|
|
136
|
+
const tablesNode = isMap(doc.contents) ? doc.contents.items.find((p) => isScalar(p.key) && p.key.value === "tables")?.value : undefined;
|
|
137
|
+
const odd = new Set();
|
|
138
|
+
if (isMap(tablesNode)) {
|
|
139
|
+
for (const p of tablesNode.items) {
|
|
140
|
+
if (!isScalar(p.key) || typeof p.key.value === "string")
|
|
141
|
+
continue;
|
|
142
|
+
const written = p.key.range ? text.slice(p.key.range[0], p.key.range[1]) : String(p.key.value);
|
|
143
|
+
problems.push(`tables: ${yamlRead(p.key.value).replace(/^\S+/, written)} — quote it: "${written}"`);
|
|
144
|
+
odd.add(String(p.key.value));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (v.tables === undefined)
|
|
148
|
+
problems.push("tables: missing — write tables: {} (mason add adds a table to it)");
|
|
149
|
+
else if (v.tables !== null && !isPlain(v.tables))
|
|
150
|
+
problems.push("tables: is one entry per table, like tables: { orders: {}, customers: { columns: [id, name] } }");
|
|
151
|
+
else {
|
|
152
|
+
const seen = new Map();
|
|
153
|
+
for (const [key, raw] of Object.entries(v.tables ?? {})) {
|
|
154
|
+
if (odd.has(key))
|
|
155
|
+
continue;
|
|
156
|
+
const at = `tables.${key}`;
|
|
157
|
+
const badName = tableNameProblem(key);
|
|
158
|
+
if (badName !== null) {
|
|
159
|
+
problems.push(`${at}: ${badName}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const table = sourceOf(key);
|
|
163
|
+
const before = seen.get(table);
|
|
164
|
+
if (before !== undefined) {
|
|
165
|
+
problems.push(`${at}: ${table} is named twice (as ${before} too) — keep one`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
seen.set(table, key);
|
|
169
|
+
if (raw !== null && !isPlain(raw)) {
|
|
170
|
+
problems.push(`${at}: is {} (every column), { columns: [...] }, { exclude: [...] } or { key: [...] }`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const e = raw ?? {};
|
|
174
|
+
const stray = Object.keys(e).filter((k) => !ENTRY_KEYS.includes(k));
|
|
175
|
+
for (const k of stray)
|
|
176
|
+
problems.push(`${at}: unexpected key ${k}${didYouMean(k, ENTRY_KEYS)} — a table takes columns, exclude and key`);
|
|
177
|
+
const both = e.columns !== undefined && e.exclude !== undefined;
|
|
178
|
+
if (both)
|
|
179
|
+
problems.push(`${at}: columns and exclude together — columns names what is copied, exclude what is left out of every column; keep one`);
|
|
180
|
+
const columns = e.columns === undefined ? null : nameList(e.columns, `${at}.columns`, "the columns copied");
|
|
181
|
+
const exclude = e.exclude === undefined ? [] : nameList(e.exclude, `${at}.exclude`, "the columns left out");
|
|
182
|
+
const ownKey = e.key === undefined ? null : nameList(e.key, `${at}.key`, "the columns that tell its rows apart");
|
|
183
|
+
for (const x of [columns, exclude, ownKey])
|
|
184
|
+
if (typeof x === "string")
|
|
185
|
+
problems.push(x);
|
|
186
|
+
if (typeof columns === "string" || typeof exclude === "string" || typeof ownKey === "string" || stray.length || both)
|
|
187
|
+
continue;
|
|
188
|
+
tables.push({ name: key, table, columns, exclude, key: ownKey });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (problems.length || kind === null || url === null)
|
|
192
|
+
return { problems };
|
|
193
|
+
return { name, file, kind, url, tables };
|
|
194
|
+
}
|
|
195
|
+
/** Where a project's source files are: `<folder>/sources/`. */
|
|
196
|
+
export const sourcesDir = (folder) => path.join(folder, SOURCES_DIR);
|
|
197
|
+
/** The names of the source files in `<folder>/sources/` (`.yml`, `.yaml`), sorted; [] when it has none. */
|
|
198
|
+
export function sourceFileNames(folder) {
|
|
199
|
+
const dir = sourcesDir(folder);
|
|
200
|
+
if (!fs.existsSync(dir))
|
|
201
|
+
return [];
|
|
202
|
+
return fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
|
|
203
|
+
}
|
|
204
|
+
/** The file `mason init` writes: the database, then its tables (none yet). */
|
|
205
|
+
export function newSourceText(url = "${DATABASE_URL}") {
|
|
206
|
+
return "# The database this project copies from, and which of its tables. Yours to commit; the URL stays out of it.\n" +
|
|
207
|
+
"kind: postgres\n" +
|
|
208
|
+
`url: ${url}${ENV_REF.test(url) ? " # from .env.local, .env or the shell; a literal postgres:// URL works too" : ""}\n` +
|
|
209
|
+
"\n" +
|
|
210
|
+
"# One entry per table, a bare name in public, else schema.table: {} copies every column, columns: [id, status]\n" +
|
|
211
|
+
"# those, exclude: [payload] every column but those; key: [a, b] names the columns that tell its rows apart, for a\n" +
|
|
212
|
+
"# table with no primary key. mason add and mason remove edit this list; so can you.\n" +
|
|
213
|
+
"tables: {}\n";
|
|
214
|
+
}
|
|
215
|
+
/** A source's name from its database's: the file is named for it, else `app`. */
|
|
216
|
+
export function sourceNameFor(database) {
|
|
217
|
+
const n = database.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^[_-]+|[_-]+$/g, "").slice(0, 48);
|
|
218
|
+
return n || "app";
|
|
219
|
+
}
|
|
220
|
+
/** The folder an older Mason listed a project's tables in, one file per table: read by nothing, said by `readSource`. */
|
|
221
|
+
export const OLD_TABLES_DIR = "models";
|
|
222
|
+
/**
|
|
223
|
+
* THE PROJECT'S SOURCE FILE, read: the one file in `sources/` (a second is refused by name: one source per project for
|
|
224
|
+
* now), parsed, its URL resolved — `${NAME}` from the project's .env.local or .env, else the shell (src/lib/dotenv.ts
|
|
225
|
+
* `appEnvValue`, the order `mason init` reads DATABASE_URL in, so the two cannot disagree); `shellFirst` for a server
|
|
226
|
+
* (`mason serve`), configured by its environment alone. A project with no file is told what to write — one an older
|
|
227
|
+
* Mason made (its tables listed in another folder, which nothing reads) that mason init writes it.
|
|
228
|
+
*/
|
|
229
|
+
export function readSource(folder, root, env = process.env, o = {}) {
|
|
230
|
+
const names = sourceFileNames(folder);
|
|
231
|
+
const dir = sourcesDir(folder);
|
|
232
|
+
if (names.length === 0) {
|
|
233
|
+
const old = path.join(folder, OLD_TABLES_DIR);
|
|
234
|
+
if (fs.existsSync(old)) {
|
|
235
|
+
// no backwards compatibility before v1.0 (CLAUDE.md): nothing is migrated, the way out is said
|
|
236
|
+
return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file, and ${shownPath(root, old)}/ is an older Mason's, which this one does not read`, code: "bad_source",
|
|
237
|
+
fix: `mason init writes ${shownPath(root, dir)}/ (then mason add each table again); remove ${shownPath(root, old)}/ afterwards`, next: [["mason init", `write ${shownPath(root, dir)}/<database>.yml`]] };
|
|
238
|
+
}
|
|
239
|
+
const file = path.join(dir, "app.yml");
|
|
240
|
+
return { message: `this project names no database: ${shownPath(root, dir)}/ has no source file`, code: "bad_source",
|
|
241
|
+
fix: `write ${shownPath(root, file)}, with the database's URL in .env as DATABASE_URL:\n${newSourceText().trimEnd()}` };
|
|
242
|
+
}
|
|
243
|
+
if (names.length > 1) {
|
|
244
|
+
return { message: `${shownPath(root, dir)}/ has ${names.length} source files (${names.join(", ")}): one source per project for now`, code: "bad_source",
|
|
245
|
+
fix: `keep the one this project copies from, and move the others out of ${shownPath(root, dir)}/` };
|
|
246
|
+
}
|
|
247
|
+
const file = path.join(dir, names[0]);
|
|
248
|
+
const parsed = parseSourceText(fs.readFileSync(file, "utf8"), names[0].replace(/\.ya?ml$/, ""), file);
|
|
249
|
+
if ("problems" in parsed)
|
|
250
|
+
return sourceFailure(file, root, parsed.problems);
|
|
251
|
+
if ("literal" in parsed.url)
|
|
252
|
+
return { ...parsed, resolved: parsed.url.literal, from: shownPath(root, file) };
|
|
253
|
+
const found = appEnvValue(root, parsed.url.env, env, { shellFirst: o.shellFirst === true });
|
|
254
|
+
if (found === null) {
|
|
255
|
+
const v = parsed.url.env;
|
|
256
|
+
return { message: `${shownPath(root, file)}: url: \${${v}} is not set — not in ${o.shellFirst ? "the environment, .env.local or .env" : ".env.local, .env or the shell"}`, code: "bad_source",
|
|
257
|
+
fix: `put ${v}=postgres://user:password@host:5432/your_app in ${shownPath(root, path.join(root, ENV_FILE))} (or export it), or write the URL in url:` };
|
|
258
|
+
}
|
|
259
|
+
return { ...parsed, resolved: found.value, from: `${parsed.url.env} in ${found.from}` };
|
|
260
|
+
}
|
|
261
|
+
/** The columns a table's copy keeps, in the source's order: the entry's, its key always among them. */
|
|
262
|
+
export function copiedColumns(t, e) {
|
|
263
|
+
const keep = new Set(e.columns ?? t.columns.map((c) => c.name).filter((c) => !e.exclude.includes(c)));
|
|
264
|
+
for (const k of e.key ?? t.key)
|
|
265
|
+
keep.add(k);
|
|
266
|
+
return t.columns.map((c) => c.name).filter((c) => keep.has(c));
|
|
267
|
+
}
|
|
268
|
+
/** Why a table cannot be copied as its entry says, each at its key, against the database's catalog; none when it can. */
|
|
269
|
+
export function entryProblems(t, e, all, database, o) {
|
|
270
|
+
const at = `tables.${e.name}`;
|
|
271
|
+
if (t === undefined) {
|
|
272
|
+
const names = all.map((x) => entryName(x.source));
|
|
273
|
+
return [`${at}: ${database} has no table ${e.table} this user can read${didYouMean(e.name, names)}${names.length ? "" : " (it has none this user can read)"}`];
|
|
274
|
+
}
|
|
275
|
+
const have = t.columns.map((c) => c.name);
|
|
276
|
+
const missing = (list, key) => list.filter((c) => !have.includes(c)).map((c) => `${at}.${key}: ${t.source} has no column ${c}${didYouMean(c, have)} (its columns: ${have.join(", ")})`);
|
|
277
|
+
const out = [...missing(e.columns ?? [], "columns"), ...missing(e.exclude, "exclude")];
|
|
278
|
+
if (e.key !== null) {
|
|
279
|
+
if (t.key.length)
|
|
280
|
+
out.push(`${at}.key: ${t.source} has a primary key (${t.key.join(", ")}), which Mason copies it by — remove key`);
|
|
281
|
+
else {
|
|
282
|
+
out.push(...missing(e.key, "key"));
|
|
283
|
+
out.push(...keyProblems(t, e.key, at));
|
|
284
|
+
// its rows have no identity on the database, so logical replication carries no key to apply a change by
|
|
285
|
+
if (o.replication)
|
|
286
|
+
out.push(`${at}: ${t.source} has no primary key, and logical replication cannot apply its changes by key: — copy it by polling (mason start --poll), or remove it`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (!t.key.length) {
|
|
290
|
+
const unique = (t.uniqueKeys ?? []).find((u) => u.columns.every((c) => t.columns.find((x) => x.name === c)?.notNull === true));
|
|
291
|
+
out.push(`${at}: ${t.source} has no primary key, so Mason cannot tell its rows apart — name the columns that do: key: [${unique ? unique.columns.join(", ") : "…"}]` +
|
|
292
|
+
`${unique ? ` (its unique index ${unique.name})` : ""}`);
|
|
293
|
+
}
|
|
294
|
+
for (const k of (e.key ?? t.key).filter((k) => e.exclude.includes(k)))
|
|
295
|
+
out.push(`${at}.exclude: ${k} is ${t.source}'s key, which is always copied — take it out of exclude`);
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* A `key:` MUST TELL THE ROWS APART, AS THE DATABASE ENFORCES IT (the review of the sources file: a key with
|
|
300
|
+
* duplicates failed the copy's distinct-key check as "Mason bug", retried for ever, and duplicates after the copy made the
|
|
301
|
+
* 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
|
+
*/
|
|
304
|
+
function keyProblems(t, key, at) {
|
|
305
|
+
const cols = key.filter((c) => t.columns.some((x) => x.name === c));
|
|
306
|
+
if (cols.length !== key.length)
|
|
307
|
+
return [];
|
|
308
|
+
const out = [];
|
|
309
|
+
const nullable = cols.filter((c) => t.columns.find((x) => x.name === c)?.notNull !== true);
|
|
310
|
+
if (nullable.length)
|
|
311
|
+
out.push(`${at}.key: ${nullable.join(", ")} may be NULL in ${t.source}, and rows with NULL there cannot be told apart — a key's columns are NOT NULL`);
|
|
312
|
+
const usable = (t.uniqueKeys ?? []).filter((u) => u.columns.every((c) => t.columns.find((x) => x.name === c)?.notNull === true));
|
|
313
|
+
if (!(t.uniqueKeys ?? []).some((u) => u.columns.every((c) => key.includes(c)))) {
|
|
314
|
+
out.push(`${at}.key: no unique index of ${t.source} is over (${key.join(", ")}), so two of its rows may share them and Mason could not tell them apart — ` +
|
|
315
|
+
(usable.length ? `name the columns of one: ${usable.map((u) => `key: [${u.columns.join(", ")}] (its unique index ${u.name})`).join(", or ")}`
|
|
316
|
+
: `it has no unique index over NOT NULL columns, so Mason cannot copy it until its owner adds one (or a primary key)`));
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* THE TABLES THE RUN COPIES (`AddedTable`, src/duck/sync.ts): each entry against the database's catalog, narrowed to the
|
|
322
|
+
* columns it copies (`copiedColumns`) and its foreign keys out of them. A `key:` is the key the table is copied by, and
|
|
323
|
+
* `primaryKey` keeps the source's own (none): the catalog declares only what the source does. Every entry's problems on
|
|
324
|
+
* one report (`sourceFailure`).
|
|
325
|
+
*/
|
|
326
|
+
export function addedTables(src, tables, database, root, o) {
|
|
327
|
+
const problems = [];
|
|
328
|
+
const out = [];
|
|
329
|
+
for (const e of src.tables) {
|
|
330
|
+
const t = tables.find((x) => x.source === e.table);
|
|
331
|
+
const p = entryProblems(t, e, tables, database, o);
|
|
332
|
+
if (p.length || t === undefined) {
|
|
333
|
+
problems.push(...p);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const keep = new Set(copiedColumns(t, e));
|
|
337
|
+
out.push({ ...t, columns: t.columns.filter((c) => keep.has(c.name)), foreignKeys: t.foreignKeys.filter((f) => keep.has(f.column)),
|
|
338
|
+
...(e.key !== null ? { key: e.key, primaryKey: t.key } : {}) });
|
|
339
|
+
}
|
|
340
|
+
return problems.length ? sourceFailure(src.file, root, problems, [["mason tables", "the database's tables and their columns"]]) : out;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* THE SOURCES FILE READ AGAIN WHILE THE RUN IS UP (mason add, mason remove): its tables now — or, when the file cannot be
|
|
344
|
+
* taken as it is, the tables read before, kept (a typo never drops a copy), with what is wrong; `said` is the log line,
|
|
345
|
+
* once per new problem. A `url:` that resolves to another database than the one the run copies (a .env edited) is such a
|
|
346
|
+
* problem: the run's catalog is the old database's, so it is said, and mason stop && mason start reads the new one.
|
|
347
|
+
*/
|
|
348
|
+
export function rereadSources(prev, read, runUrl, catalog, database, root, o) {
|
|
349
|
+
let problem = null;
|
|
350
|
+
let next = null;
|
|
351
|
+
if ("message" in read)
|
|
352
|
+
problem = `${read.message}${read.why ? `: ${read.why.replace(/\n/g, "; ")}` : ""}`;
|
|
353
|
+
else if (read.resolved !== runUrl) {
|
|
354
|
+
problem = `${shownPath(root, read.file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run copies ${maskedUrl(runUrl)}: mason stop && mason start copies from the new one`;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const t = addedTables(read, catalog, database, root, o);
|
|
358
|
+
if (Array.isArray(t))
|
|
359
|
+
next = t;
|
|
360
|
+
else
|
|
361
|
+
problem = `${t.message}${t.why ? `: ${t.why.replace(/\n/g, "; ")}` : ""}`;
|
|
362
|
+
}
|
|
363
|
+
if (next !== null && !("message" in read))
|
|
364
|
+
return { file: read, tables: next, problem: null, said: null };
|
|
365
|
+
return { ...prev, problem, said: problem !== prev.problem ? `the sources file cannot be read as it is now, so the tables read before are kept: ${problem}` : null };
|
|
366
|
+
}
|
|
367
|
+
// ------------------------------------------------------------------ the columns mason add asks for
|
|
368
|
+
/**
|
|
369
|
+
* A COLUMN THAT LOOKS LIKE A SECRET is left out of every-column adds unless named (an agent's every-column
|
|
370
|
+
* add put a table's `password`, `deploy_key_content` and `warehouse_private_key` into a copy anyone with a user's
|
|
371
|
+
* password can query, with no warning). By name alone — the database says nothing about a column's sensitivity — so the
|
|
372
|
+
* words are credentials' own, and a suffix that names an identifier or a time (`token_id`, `secret_count`) is not one.
|
|
373
|
+
*/
|
|
374
|
+
export const SECRET_WORDS = /(^|_)(password|passwd|pwd|secret|secrets|token|tokens|api_key|apikey|access_key|secret_key|private_key|ssh_key|credential|credentials|salt)(_|$)|_key_content$/;
|
|
375
|
+
export function looksSecret(column) {
|
|
376
|
+
return SECRET_WORDS.test(column) && !/_(id|ids|at|count|type|name|kind|hash_algo)$/.test(column);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The entry `mason add` writes for the columns asked: a list (`--columns`) is `columns:`, the key added when left out
|
|
380
|
+
* (`addedKey`); `*` is every column — `{}`, or `exclude:` naming the secret-looking columns it left out (`skipped`)
|
|
381
|
+
* unless `--include` names them beside it (`["*", "password"]`). A column the table lacks is refused with its columns.
|
|
382
|
+
* `key` is `--key`'s: the columns a table with no primary key is copied by.
|
|
383
|
+
*/
|
|
384
|
+
export function chooseColumns(t, asked, key = null) {
|
|
385
|
+
if (asked.length === 0)
|
|
386
|
+
return { problem: `name ${t.source}'s columns: mason add ${entryName(t.source)} --columns id,status,… — or leave --columns out for every one` };
|
|
387
|
+
const have = t.columns.map((c) => c.name);
|
|
388
|
+
const named = asked.filter((c) => c !== "*");
|
|
389
|
+
const unknown = [...named, ...(key ?? [])].filter((c) => !have.includes(c));
|
|
390
|
+
if (unknown.length) {
|
|
391
|
+
return { problem: `${t.source} has no column ${unknown.map((c) => JSON.stringify(c)).join(", ")}${didYouMean(unknown[0], have)}; its columns: ${have.join(", ")}` };
|
|
392
|
+
}
|
|
393
|
+
const keyCols = key ?? t.key;
|
|
394
|
+
if (asked.includes("*")) {
|
|
395
|
+
const skipped = have.filter((c) => looksSecret(c) && !keyCols.includes(c) && !named.includes(c));
|
|
396
|
+
return { columns: null, exclude: skipped, addedKey: [], skipped };
|
|
397
|
+
}
|
|
398
|
+
// the key columns are written into the list, first: the file says every column the copy holds
|
|
399
|
+
const addedKey = keyCols.filter((k) => !named.includes(k));
|
|
400
|
+
return { columns: [...new Set([...addedKey, ...named])], exclude: [], addedKey, skipped: [] };
|
|
401
|
+
}
|
|
402
|
+
// ------------------------------------------------------------------ editing the file (mason add, mason remove)
|
|
403
|
+
/** A name as YAML writes it: plain when it is an identifier YAML reads as a string, else quoted. */
|
|
404
|
+
const yamlName = (n) => /^[A-Za-z_][A-Za-z0-9_$.]*$/.test(n) && !/^(true|false|null|yes|no|on|off|y|n)$/i.test(n) ? n : JSON.stringify(n);
|
|
405
|
+
const flowList = (xs) => `[${xs.map(yamlName).join(", ")}]`;
|
|
406
|
+
/** An entry's lines at `indent`: `name: {}` on one line, else its keys a line each under it. */
|
|
407
|
+
export function entryText(e, indent) {
|
|
408
|
+
const body = [];
|
|
409
|
+
if (e.columns !== null)
|
|
410
|
+
body.push(`columns: ${flowList(e.columns)}`);
|
|
411
|
+
else if (e.exclude.length)
|
|
412
|
+
body.push(`exclude: ${flowList(e.exclude)}`);
|
|
413
|
+
if (e.key !== null)
|
|
414
|
+
body.push(`key: ${flowList(e.key)}`);
|
|
415
|
+
if (!body.length)
|
|
416
|
+
return `${indent}${yamlName(e.name)}: {}\n`;
|
|
417
|
+
return `${indent}${yamlName(e.name)}:\n${body.map((b) => `${indent} ${b}\n`).join("")}`;
|
|
418
|
+
}
|
|
419
|
+
const FLOW_MAP = "its tables: is written as a { … } map with entries: write it one table per line to have mason add and mason remove edit it";
|
|
420
|
+
/** The file parsed for an edit: its `tables:` pair (null when it has none), and the line helpers over its text. */
|
|
421
|
+
function forEdit(text) {
|
|
422
|
+
const doc = parseDocument(text, { uniqueKeys: true });
|
|
423
|
+
if (doc.errors.length)
|
|
424
|
+
return { problem: `it is not YAML (${doc.errors[0].message.split("\n")[0]})` };
|
|
425
|
+
if (!isMap(doc.contents))
|
|
426
|
+
return { problem: "it is not a mapping" };
|
|
427
|
+
const pair = doc.contents.items.find((p) => isScalar(p.key) && p.key.value === "tables") ?? null;
|
|
428
|
+
return {
|
|
429
|
+
pair: pair,
|
|
430
|
+
lineStart: (at) => text.lastIndexOf("\n", at - 1) + 1,
|
|
431
|
+
lineEnd: (at) => { if (at > 0 && text[at - 1] === "\n")
|
|
432
|
+
return at; const n = text.indexOf("\n", at); return n < 0 ? text.length : n + 1; },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** The table entries of a block `tables:` map, with the table each names. */
|
|
436
|
+
function entryPairs(map) {
|
|
437
|
+
return map.items.filter((p) => isScalar(p.key)).map((p) => {
|
|
438
|
+
const name = String(p.key.value);
|
|
439
|
+
return { pair: p, table: sourceOf(name), name };
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The file with `e` as its entry for `e.table` — after the last entry, or in place of the one naming that table (under
|
|
444
|
+
* that one's own name) — and every other byte as it was: spliced into the text at the `tables:` map's own positions
|
|
445
|
+
* (the `yaml` package's node ranges), never re-serialised, so a person's comments, quoting and layout stay theirs. A
|
|
446
|
+
* reason instead when the map cannot be spliced (a `{ … }` flow map with entries, a file that does not parse).
|
|
447
|
+
*/
|
|
448
|
+
export function withTable(text, e) {
|
|
449
|
+
return inOwnLineEndings(text, (t) => withTableLf(t, e));
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* An edit of a file written with CRLF line endings (a Windows editor's) made in LF and written back in CRLF, so the lines
|
|
453
|
+
* it adds end as the file's do; a file of LF, or of both, is edited as it is.
|
|
454
|
+
*/
|
|
455
|
+
function inOwnLineEndings(text, edit) {
|
|
456
|
+
const crlf = text.includes("\r\n") && !/(^|[^\r])\n/.test(text);
|
|
457
|
+
if (!crlf)
|
|
458
|
+
return edit(text);
|
|
459
|
+
const r = edit(text.replace(/\r\n/g, "\n"));
|
|
460
|
+
return (r !== null && "text" in r ? { ...r, text: r.text.replace(/\n/g, "\r\n") } : r);
|
|
461
|
+
}
|
|
462
|
+
function withTableLf(text, e) {
|
|
463
|
+
const r = forEdit(text);
|
|
464
|
+
if ("problem" in r)
|
|
465
|
+
return r;
|
|
466
|
+
const { pair, lineStart, lineEnd } = r;
|
|
467
|
+
if (pair === null)
|
|
468
|
+
return { text: `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}tables:\n${entryText(e, " ")}` };
|
|
469
|
+
const key = pair.key;
|
|
470
|
+
const value = pair.value;
|
|
471
|
+
const emptyMap = isMap(value) && value.items.length === 0;
|
|
472
|
+
if (value === null || (isScalar(value) && (value.value === null || value.value === undefined)) || emptyMap) {
|
|
473
|
+
// `tables:`, `tables: ~` or `tables: {}`: the empty value cut from its line (a comment after it stays), the entry under it
|
|
474
|
+
const end = lineEnd(key.range[1]);
|
|
475
|
+
const v = value?.range;
|
|
476
|
+
const line = v && v[1] > v[0] && v[0] < end
|
|
477
|
+
? `${text.slice(lineStart(key.range[0]), v[0]).replace(/[ \t]+$/, "")}${text.slice(v[1], end).trim() ? text.slice(v[1], end) : "\n"}`
|
|
478
|
+
: text.slice(lineStart(key.range[0]), end);
|
|
479
|
+
return { text: `${text.slice(0, lineStart(key.range[0]))}${line.endsWith("\n") ? line : `${line}\n`}${entryText(e, " ")}${text.slice(end)}` };
|
|
480
|
+
}
|
|
481
|
+
if (!isMap(value))
|
|
482
|
+
return { problem: "its tables: is not one entry per table" };
|
|
483
|
+
if (value.flow)
|
|
484
|
+
return { problem: FLOW_MAP };
|
|
485
|
+
const entries = entryPairs(value);
|
|
486
|
+
const first = entries[0].pair.key;
|
|
487
|
+
const indent = text.slice(lineStart(first.range[0]), first.range[0]);
|
|
488
|
+
const same = entries.find((x) => x.table === e.table);
|
|
489
|
+
if (same) {
|
|
490
|
+
const k = same.pair.key, v = same.pair.value;
|
|
491
|
+
const to = lineEnd(v?.range ? v.range[1] : k.range[1]);
|
|
492
|
+
return { text: `${text.slice(0, lineStart(k.range[0]))}${entryText({ ...e, name: same.name }, indent)}${text.slice(to)}` };
|
|
493
|
+
}
|
|
494
|
+
const last = entries[entries.length - 1].pair;
|
|
495
|
+
const lv = last.value;
|
|
496
|
+
const at = lineEnd(lv?.range ? lv.range[1] : last.key.range[1]);
|
|
497
|
+
return { text: `${text.slice(0, at)}${at > 0 && text[at - 1] !== "\n" ? "\n" : ""}${entryText(e, indent)}${text.slice(at)}` };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The file without its entry for `table`: its lines cut, every other byte as it was; the last entry gone leaves
|
|
501
|
+
* `tables: {}`. Null when no entry names that table.
|
|
502
|
+
*/
|
|
503
|
+
export function withoutTable(text, table) {
|
|
504
|
+
return inOwnLineEndings(text, (t) => withoutTableLf(t, table));
|
|
505
|
+
}
|
|
506
|
+
function withoutTableLf(text, table) {
|
|
507
|
+
const r = forEdit(text);
|
|
508
|
+
if ("problem" in r)
|
|
509
|
+
return r;
|
|
510
|
+
const { pair, lineStart, lineEnd } = r;
|
|
511
|
+
if (pair === null || !isMap(pair.value))
|
|
512
|
+
return null;
|
|
513
|
+
const map = pair.value;
|
|
514
|
+
const entries = entryPairs(map);
|
|
515
|
+
const gone = entries.find((x) => x.table === table);
|
|
516
|
+
if (!gone)
|
|
517
|
+
return null;
|
|
518
|
+
if (map.flow)
|
|
519
|
+
return { problem: FLOW_MAP };
|
|
520
|
+
const k = gone.pair.key, v = gone.pair.value;
|
|
521
|
+
let out = `${text.slice(0, lineStart(k.range[0]))}${text.slice(lineEnd(v?.range ? v.range[1] : k.range[1]))}`;
|
|
522
|
+
if (entries.length === 1) {
|
|
523
|
+
// the last one: `tables: {}`, a comment after the key kept on its line
|
|
524
|
+
const colon = text.indexOf(":", pair.key.range[1]);
|
|
525
|
+
out = `${out.slice(0, colon + 1)} {}${out.slice(colon + 1)}`;
|
|
526
|
+
}
|
|
527
|
+
return { text: out };
|
|
528
|
+
}
|
|
529
|
+
/** The entry naming a table — by its key, or by schema.table — or why there is none, with the closest name; `usage` when
|
|
530
|
+
* the name is none a table can have (tableNameProblem). */
|
|
531
|
+
export function findEntry(src, name) {
|
|
532
|
+
const malformed = tableNameProblem(name, src.tables.map((e) => e.table));
|
|
533
|
+
if (malformed !== null)
|
|
534
|
+
return { problem: malformed, usage: true };
|
|
535
|
+
const hit = src.tables.find((e) => e.name === name || e.table === name || e.table === sourceOf(name));
|
|
536
|
+
if (hit)
|
|
537
|
+
return hit;
|
|
538
|
+
const names = src.tables.map((e) => e.name);
|
|
539
|
+
const near = closest(name, [...names, ...src.tables.map((e) => e.table)]);
|
|
540
|
+
return { problem: `${path.basename(src.file)} names no table ${name}${near ? ` — did you mean \`${near}\`?` : ""}${names.length ? ` (it names ${names.join(", ")})` : " (it names none yet)"}` };
|
|
541
|
+
}
|
|
542
|
+
/** Why `mason add <t>` with no `--key` cannot add `t`, or null. */
|
|
543
|
+
export function unaddableReason(t) {
|
|
544
|
+
if (t.key.length)
|
|
545
|
+
return null;
|
|
546
|
+
const unique = (t.uniqueKeys ?? []).find((u) => u.columns.every((c) => t.columns.find((x) => x.name === c)?.notNull === true));
|
|
547
|
+
return `no primary key: name the columns that tell its rows apart, mason add ${entryName(t.source)} --key ${unique ? unique.columns.join(",") : "<column>,…"}${unique ? ` (its unique index ${unique.name})` : ""}`;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* A TABLE AS `mason tables` AND THE RUN'S STATE BOTH SAY IT (the hands-on review of #451, item 15: `mason tables` needed
|
|
551
|
+
* Mason running, though all it says is the database's catalog and the project's files): ONE mapping from the catalog
|
|
552
|
+
* (`t`) and the sources file's entry for it (`entry`; undefined: not added).
|
|
553
|
+
*/
|
|
554
|
+
export function catalogTable(t, entry) {
|
|
555
|
+
return {
|
|
556
|
+
table: t.source, rows: t.rows, bytes: t.bytes, key: t.key, comment: t.comment,
|
|
557
|
+
columns: t.columns.map((c) => ({ name: c.name, type: c.type, nullable: !c.notNull, comment: c.comment,
|
|
558
|
+
references: ((f) => f ? { table: f.target, column: f.targetColumn } : null)(t.foreignKeys.find((f) => f.column === c.name)) })),
|
|
559
|
+
reason: entry === undefined ? unaddableReason(t) : null,
|
|
560
|
+
added: entry !== undefined, addedColumns: entry === undefined ? null : copiedColumns(t, entry), copyKey: entry?.key ?? null,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A PROJECT IS, SAID ONCE (the retrospective's item 3). Four bugs of two days were a command
|
|
3
|
+
* meeting a state no one had run it in — a command before the run existed printed a stack, a config half-written
|
|
4
|
+
* killed every command — because each command found out the state its own way. These are every state a command can
|
|
5
|
+
* meet, in order, and the failure each answers with when a command needs more:
|
|
6
|
+
*
|
|
7
|
+
* no_project no .mason/ here or above no_project
|
|
8
|
+
* bad_config a .mason/config.json that cannot be read bad_config
|
|
9
|
+
* stopped a project; Mason not running for it, or a run recorded
|
|
10
|
+
* that does not answer (a reboot: the pid dead, or another's) not_running
|
|
11
|
+
* up Mason running: its command server answers —
|
|
12
|
+
*
|
|
13
|
+
* A command declares what it needs (`CommandSpec.needs`: a project, or a run) and answers a state below it with that
|
|
14
|
+
* state's CODE. Most ask `reach`, the one gate, and say the state's own words; a few word their own answer for a state
|
|
15
|
+
* they handle — mason status and mason stop where Mason is not running (exit 0: that is their answer), mason logs,
|
|
16
|
+
* mason start and mason doctor — and keep the state's code where they fail. tests/cli-state-matrix.test.ts (the states
|
|
17
|
+
* without a run) and a run against a real Postgres (the states with one; no integration test yet) run every command in every state,
|
|
18
|
+
* and hold each to the code of the state it met.
|
|
19
|
+
*/
|
|
20
|
+
import { failed } from "./command.js";
|
|
21
|
+
import { notRunning, ping, runOf } from "./runs.js";
|
|
22
|
+
import { theProject } from "./project.js";
|
|
23
|
+
/** Every state, in order, and what each meets. */
|
|
24
|
+
export const PROJECT_STATES = {
|
|
25
|
+
no_project: { meets: null, codes: ["no_project"] },
|
|
26
|
+
bad_config: { meets: null, codes: ["bad_config"] },
|
|
27
|
+
stopped: { meets: "project", codes: ["not_running"] },
|
|
28
|
+
up: { meets: "run", codes: [] },
|
|
29
|
+
};
|
|
30
|
+
const RANK = { project: 1, run: 2 };
|
|
31
|
+
/** Whether a command that needs `need` can work in `state`. */
|
|
32
|
+
export function meets(state, need) {
|
|
33
|
+
const m = PROJECT_STATES[state].meets;
|
|
34
|
+
return m !== null && RANK[m] >= RANK[need];
|
|
35
|
+
}
|
|
36
|
+
/** The state of the project a command run in `cwd` means (src/cli/project.ts theProject: the nearest). */
|
|
37
|
+
export async function whereIs(cwd = process.cwd()) {
|
|
38
|
+
const t = theProject(cwd);
|
|
39
|
+
if ("problem" in t)
|
|
40
|
+
return { state: t.code === "bad_config" ? "bad_config" : "no_project", problem: t, project: null };
|
|
41
|
+
// ONE request says whether the run is up: its own pid answered (review of #434: a ping for each question was two per command)
|
|
42
|
+
const run = runOf(t.project);
|
|
43
|
+
const said = run ? await ping(run) : null;
|
|
44
|
+
if (!run || said === null || said.pid !== run.pid)
|
|
45
|
+
return { state: "stopped", problem: notRunning(t.project), project: t.project };
|
|
46
|
+
return { state: "up", project: t.project, run };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* THE ONE GATE: the state, when it meets what `spec` needs; else its failure said (JSON under --json) and null. A
|
|
50
|
+
* command's answer to "not here" is the state's, never one it words itself.
|
|
51
|
+
*/
|
|
52
|
+
export async function reach(spec, json, cwd = process.cwd()) {
|
|
53
|
+
const w = await whereIs(cwd);
|
|
54
|
+
if (meets(w.state, spec.needs))
|
|
55
|
+
return w;
|
|
56
|
+
failed(spec.name, w.problem, { json });
|
|
57
|
+
return null;
|
|
58
|
+
}
|