@mason-data/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +12 -4
package/dist/src/cli/doctor.js
CHANGED
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { failed, jsonText, nextBlock, parseCommand } from "./command.js";
|
|
8
8
|
import { out as styleOut, wrap } from "./style.js";
|
|
9
|
-
import { heldPgPort, namedPgPort, portHolder } from "./ports.js";
|
|
9
|
+
import { DEFAULT_UI_PORT, heldPgPort, namedPgPort, portHolder } from "./ports.js";
|
|
10
10
|
import { callRun, RunUnreachable } from "./control.js";
|
|
11
11
|
import { readiness } from "./commands.js";
|
|
12
12
|
import { connectSource, maskedUrl, parseSource, readSourceFacts, versionProblem } from "./preflight.js";
|
|
13
13
|
import { theRun } from "./runs.js";
|
|
14
14
|
import { projectSource } from "./project.js";
|
|
15
15
|
import { DEFAULT_PG_PORT } from "../api/postgres/listener.js";
|
|
16
|
-
import { defaultMemoryLimit,
|
|
16
|
+
import { defaultMemoryLimit, machineMemory, memoryLimitBytes, memoryProblems, memoryProblemText, memorySize, memoryWarning, OUTSIDE_RESERVE_BYTES } from "../duck/store.js";
|
|
17
|
+
import { probeExtensions } from "../duck/extensions.js";
|
|
18
|
+
import { readRoleFacts, roleChecks, roleUnknown, ROLE_CODES } from "./source-role.js";
|
|
19
|
+
import { replicatedEntries, shownPath } from "./sources.js";
|
|
20
|
+
import { namedPublication } from "./replication-mode.js";
|
|
17
21
|
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
18
22
|
/**
|
|
19
23
|
* THE NODE THE PACKAGE NEEDS, major.minor: cli/package.json's `engines`, this check, `mason --help` and the README read
|
|
@@ -25,17 +29,28 @@ const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
|
25
29
|
export const NODE_MIN = "22.13";
|
|
26
30
|
export const DOCTOR_USAGE = `usage: mason doctor [--json]
|
|
27
31
|
|
|
28
|
-
Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (
|
|
29
|
-
|
|
32
|
+
Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (what the engine may use and what it
|
|
33
|
+
leaves beside it; MASON_DUCKDB_MEMORY=4GiB sets it), Mason's postgres and inet extensions (installed with Mason for this
|
|
34
|
+
platform, so nothing is downloaded; else the first sync downloads them), the ports Mason listens on — with the way
|
|
35
|
+
out for anything missing. In a
|
|
30
36
|
project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
|
|
31
|
-
database, whether every added table is
|
|
37
|
+
database, whether every added table is synced, the statements of the last day it refused or answered slowly. Both
|
|
38
|
+
say whether the user Mason connects as could change a synced table's rows (a superuser, BYPASSRLS, an owner of a
|
|
39
|
+
synced table, a write privilege on one or CREATE on its schema; or a write that reaches its rows through a
|
|
40
|
+
partition, an inheritance child or parent, a foreign key's action, a view or a rule; SECURITY DEFINER code it
|
|
41
|
+
reaches; a foreign server; ADMIN OPTION or a server role; directly or through a role it is a member of), with the
|
|
42
|
+
statements that make a role that only reads.
|
|
32
43
|
|
|
33
44
|
mason doctor
|
|
34
45
|
|
|
35
|
-
--json as data: { ready, verdict, checks: [{ name, ok, detail }], next: [{ command, what }] }; ok
|
|
36
|
-
or null when it could not tell
|
|
46
|
+
--json as data: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
|
|
47
|
+
is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
|
|
48
|
+
(the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
|
|
49
|
+
${ROLE_CODES.slice(5).join(", ")};
|
|
50
|
+
fix (with code): the statements and the edit that make a role that only reads
|
|
37
51
|
|
|
38
|
-
Exit status: 0
|
|
52
|
+
Exit status: 0 nothing failed — a warning (!) and a check that could not tell (?) are said, and leave it 0; 1
|
|
53
|
+
something failed (said, with the way out).
|
|
39
54
|
`;
|
|
40
55
|
export const DOCTOR = {
|
|
41
56
|
name: "doctor",
|
|
@@ -45,7 +60,7 @@ export const DOCTOR = {
|
|
|
45
60
|
json: ["ready", "verdict", "checks", "next"],
|
|
46
61
|
};
|
|
47
62
|
/** The ports a mason start takes by default, and what listens on each. */
|
|
48
|
-
export const PORTS = [[
|
|
63
|
+
export const PORTS = [[DEFAULT_UI_PORT, "the mason commands"], [DEFAULT_PG_PORT, "the Postgres API"]];
|
|
49
64
|
/** Node's version against the package's floor, major then minor. */
|
|
50
65
|
export function nodeCheck(version = process.versions.node) {
|
|
51
66
|
const [major, minor] = version.split(".").map(Number);
|
|
@@ -54,21 +69,38 @@ export function nodeCheck(version = process.versions.node) {
|
|
|
54
69
|
? { name: "node", ok: true, detail: `Node ${version}` }
|
|
55
70
|
: { name: "node", ok: false, detail: `Node ${version} is too old: mason needs ${NODE_MIN} or later (nodejs.org, or nvm install ${minMajor})` };
|
|
56
71
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The machine's memory and what the copy's engine may use of it (src/duck/store.ts DEFAULT_MEMORY_LIMIT,
|
|
74
|
+
* MASON_DUCKDB_MEMORY): what a start refuses is said in the start's own words (`memoryProblems`), and what it warns of
|
|
75
|
+
* under the floor in the run's own words (`memoryWarning`), a warning, never a failure, since the start goes on; so is
|
|
76
|
+
* a share the operator set that leaves less than OUTSIDE_RESERVE_BYTES beside it.
|
|
77
|
+
*/
|
|
78
|
+
export function memoryCheck(memory = machineMemory(), env = process.env) {
|
|
79
|
+
const problems = memoryProblems(env);
|
|
80
|
+
if (problems.length)
|
|
81
|
+
return { name: "memory", ok: false, detail: problems.map(memoryProblemText).join("; ") };
|
|
82
|
+
const warned = memoryWarning(memory, env);
|
|
83
|
+
const set = env.MASON_DUCKDB_MEMORY || undefined;
|
|
84
|
+
const share = set ?? defaultMemoryLimit(memory.bytes);
|
|
85
|
+
const outside = memory.bytes - memoryLimitBytes(share);
|
|
86
|
+
if (warned !== null) {
|
|
87
|
+
return { name: "memory", ok: true, warn: true,
|
|
88
|
+
detail: `${memoryProblemText(warned)} (the engine uses up to ${share.trim()}, the default, leaving ${memorySize(Math.max(0, outside))} beside it)` };
|
|
63
89
|
}
|
|
64
|
-
const share = set ?? defaultMemoryLimit(totalBytes);
|
|
65
|
-
const low = totalBytes < 2 * 1024 ** 3;
|
|
66
90
|
return {
|
|
67
|
-
name: "memory", ok:
|
|
68
|
-
detail: `${
|
|
69
|
-
(
|
|
91
|
+
name: "memory", ok: true, ...(outside < OUTSIDE_RESERVE_BYTES ? { warn: true } : {}),
|
|
92
|
+
detail: `${memorySize(memory.bytes)}; the engine uses up to ${share.trim()}${set !== undefined ? " (MASON_DUCKDB_MEMORY)" : " (the default; MASON_DUCKDB_MEMORY sets it)"}, ` +
|
|
93
|
+
`leaving ${memorySize(Math.max(0, outside))} for Mason beside it` +
|
|
94
|
+
(outside < OUTSIDE_RESERVE_BYTES ? `, under the ${memorySize(OUTSIDE_RESERVE_BYTES)} it needs (measured, with a margin): the system may stop it in a first sync, ` +
|
|
95
|
+
"a generated table's build or a burst of writes (a smaller MASON_DUCKDB_MEMORY, or more memory)" : ""),
|
|
70
96
|
};
|
|
71
97
|
}
|
|
98
|
+
/** The copy's extensions (src/duck/extensions.ts): shipped with Mason and loaded by an engine of their own as the run's
|
|
99
|
+
* will load them, or not shipped, and why. `probe` is a test's. */
|
|
100
|
+
export async function extensionsCheck(probe = () => probeExtensions()) {
|
|
101
|
+
const p = await probe();
|
|
102
|
+
return { name: "extensions", ok: p.ok, detail: p.detail };
|
|
103
|
+
}
|
|
72
104
|
/**
|
|
73
105
|
* Mason's ports in one line, decided by the probe mason start uses (`portHolder`, src/cli/ports.ts: every address, so
|
|
74
106
|
* doctor and start agree on a port another program holds on the wildcard). A taken command port only moves Mason to
|
|
@@ -110,25 +142,72 @@ function portOf(url) {
|
|
|
110
142
|
return null;
|
|
111
143
|
} // not a URL: no port to compare with
|
|
112
144
|
}
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The database a URL names: reachable, its version, what this user can read — and, given `role` (the project's synced
|
|
147
|
+
* tables), whether that user could write to it (src/cli/source-role.ts), on the same connection. `name` says where the
|
|
148
|
+
* URL is (`DATABASE_URL in .env`): a refusal names it, never the value (src/cli/preflight.ts shownUrl).
|
|
149
|
+
*/
|
|
150
|
+
export async function databaseChecks(url, o = {}) {
|
|
151
|
+
const parsed = parseSource(url, o.name);
|
|
116
152
|
if ("problem" in parsed)
|
|
117
|
-
return { name: "database", ok: false, detail: parsed.problem };
|
|
153
|
+
return [{ name: "database", ok: false, detail: parsed.problem }];
|
|
118
154
|
const connected = await connectSource(parsed.source, o);
|
|
119
155
|
if ("failure" in connected)
|
|
120
|
-
return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` };
|
|
156
|
+
return [{ name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` }];
|
|
157
|
+
try {
|
|
158
|
+
const facts = await readSourceFacts(connected.pg);
|
|
159
|
+
const tooOld = versionProblem(facts);
|
|
160
|
+
if (tooOld)
|
|
161
|
+
return [{ name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` }];
|
|
162
|
+
const database = { name: "database", ok: true, detail: `${maskedUrl(parsed.source.url)}: Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read` };
|
|
163
|
+
return o.role ? [database, ...await askRole(connected.pg, o.role)] : [database];
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
connected.pg.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** The role's lines, from its one catalog read; a read that failed is "could not tell", never "only reads". */
|
|
170
|
+
async function askRole(pg, ask) {
|
|
121
171
|
let facts;
|
|
122
172
|
try {
|
|
123
|
-
facts = await
|
|
173
|
+
facts = await readRoleFacts(pg, ask.tables);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
return [roleUnknown(e.message)];
|
|
177
|
+
}
|
|
178
|
+
return roleChecks(facts, ask);
|
|
179
|
+
}
|
|
180
|
+
/** What the role check asks of a project's sources file: its tables, whether one streams, its publication, its URL. */
|
|
181
|
+
export function roleAsk(src, root, env = process.env) {
|
|
182
|
+
return {
|
|
183
|
+
tables: src.tables.map((e) => { const at = e.table.indexOf("."); return { schema: e.table.slice(0, at), table: e.table.slice(at + 1) }; }),
|
|
184
|
+
replicated: replicatedEntries(src).map((e) => e.table),
|
|
185
|
+
publication: namedPublication(src, env)?.name ?? null,
|
|
186
|
+
urlVariable: "env" in src.url ? src.url.env : null, urlFrom: src.from, file: shownPath(root, src.file), url: src.resolved,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The role's lines with Mason running (its own checks never connect to the database): the project's sources file read
|
|
191
|
+
* and the database asked, on a connection of its own. What cannot be read is "could not tell".
|
|
192
|
+
*/
|
|
193
|
+
export async function runRoleChecks(project) {
|
|
194
|
+
if (project.config.demo === true)
|
|
195
|
+
return []; // the demo database is Mason's own, inside the run
|
|
196
|
+
const src = projectSource(project);
|
|
197
|
+
if ("message" in src)
|
|
198
|
+
return [roleUnknown(`${src.message} (mason status)`)];
|
|
199
|
+
const parsed = parseSource(src.resolved);
|
|
200
|
+
if ("problem" in parsed)
|
|
201
|
+
return [roleUnknown(parsed.problem)];
|
|
202
|
+
const connected = await connectSource(parsed.source);
|
|
203
|
+
if ("failure" in connected)
|
|
204
|
+
return [roleUnknown(`${maskedUrl(parsed.source.url)}: ${connected.failure.message}`)];
|
|
205
|
+
try {
|
|
206
|
+
return await askRole(connected.pg, roleAsk(src, project.root));
|
|
124
207
|
}
|
|
125
208
|
finally {
|
|
126
209
|
connected.pg.close();
|
|
127
210
|
}
|
|
128
|
-
const tooOld = versionProblem(facts);
|
|
129
|
-
if (tooOld)
|
|
130
|
-
return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` };
|
|
131
|
-
return { name: "database", ok: true, detail: `${maskedUrl(parsed.source.url)}: Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read` };
|
|
132
211
|
}
|
|
133
212
|
/** The run's health, from its own state: every table copied, failing, or still at work; and what it refused or answered slowly. */
|
|
134
213
|
export async function runChecks(run) {
|
|
@@ -153,7 +232,7 @@ export async function runChecks(run) {
|
|
|
153
232
|
else if (ready.waiting.length)
|
|
154
233
|
checks.push({ name: "project", ok: null, detail: `still at work: ${ready.waiting.join("; ")} (mason wait)` });
|
|
155
234
|
else
|
|
156
|
-
checks.push({ name: "project", ok: true, detail: picked ? `${picked} table(s)
|
|
235
|
+
checks.push({ name: "project", ok: true, detail: picked ? `${picked} table(s) synced and kept current` : "no table added yet (mason tables, then mason add <table>)" });
|
|
157
236
|
const slow = await callRun(run, "GET", "/api/slow"), refused = await callRun(run, "GET", "/api/refused");
|
|
158
237
|
const slowN = slow.status === 200 ? slow.body.slow.length : null, refusedN = refused.status === 200 ? refused.body.refused.length : null;
|
|
159
238
|
checks.push(slowN === null || refusedN === null
|
|
@@ -170,12 +249,12 @@ export function nextSteps(checks, run, project) {
|
|
|
170
249
|
return [["mason doctor", "again, once .mason/config.json is repaired or moved aside (the detail above says how)"]];
|
|
171
250
|
// the demo database is hosted by the run itself (src/cli/demo.ts): down until mason start makes it
|
|
172
251
|
if (bad("database") && project?.config.demo === true)
|
|
173
|
-
return [["mason start", "hosts the demo database, then
|
|
252
|
+
return [["mason start", "hosts the demo database, then syncs its tables"]];
|
|
174
253
|
if (bad("database"))
|
|
175
254
|
return [["mason start", `once the connection works (the detail above says how; the URL is in ${project ? `${project.dir}/config.json` : ".mason/config.json"})`]];
|
|
176
255
|
if (project === null)
|
|
177
256
|
return [
|
|
178
|
-
["mason init", "in your app's folder: the database from DATABASE_URL in .env.local, .env or the shell"],
|
|
257
|
+
["mason init", "in your app's folder: the database from MASON_DATABASE_URL or DATABASE_URL in .env.local, .env or the shell"],
|
|
179
258
|
["mason init postgres://user:password@localhost:5432/your_app", "or name it"],
|
|
180
259
|
];
|
|
181
260
|
if (run === null)
|
|
@@ -185,7 +264,7 @@ export function nextSteps(checks, run, project) {
|
|
|
185
264
|
if (bad("project"))
|
|
186
265
|
return [["mason wait", "each problem, with the way out"]];
|
|
187
266
|
if (checks.some((c) => c.name === "project" && /no table added/.test(c.detail)))
|
|
188
|
-
return [["mason tables", "the database's tables and their columns"], ["mason add <table>", "
|
|
267
|
+
return [["mason tables", "the database's tables and their columns"], ["mason add <table>", "sync a table into Mason"]];
|
|
189
268
|
if (bad("queries"))
|
|
190
269
|
return [["mason status", "the refused and slow statements, each with what to do"]];
|
|
191
270
|
return [["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
|
|
@@ -205,7 +284,7 @@ export function verdictOf(checks, run, project) {
|
|
|
205
284
|
}
|
|
206
285
|
/** A check's mark: ok, !! a failure, ? could not tell. */
|
|
207
286
|
function markOf(c, s) {
|
|
208
|
-
return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : s.green("ok ");
|
|
287
|
+
return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : c.warn === true ? s.yellow("! ") : s.green("ok ");
|
|
209
288
|
}
|
|
210
289
|
export async function doctor(argv, deps = {}) {
|
|
211
290
|
const f = parseCommand(DOCTOR, argv);
|
|
@@ -222,12 +301,18 @@ export async function doctor(argv, deps = {}) {
|
|
|
222
301
|
if (run === null) {
|
|
223
302
|
checks.push(nodeCheck());
|
|
224
303
|
checks.push(memoryCheck());
|
|
304
|
+
checks.push(await extensionsCheck());
|
|
225
305
|
checks.push(await portsCheck([], portHolder, process.env, project?.config.pgPort ?? null));
|
|
226
306
|
if (project) {
|
|
227
307
|
// the database the project's sources file names (src/cli/sources.ts): a file that cannot be read is the failing line
|
|
228
308
|
const src = projectSource(project);
|
|
229
|
-
|
|
230
|
-
:
|
|
309
|
+
if ("message" in src)
|
|
310
|
+
checks.push({ name: "database", ok: false, detail: `${src.message}${src.why ? `: ${src.why.replace(/\n/g, "; ")}` : ""}. ${src.fix?.split("\n")[0] ?? ""}`.trim() });
|
|
311
|
+
// the demo database is Mason's own, inside the run: whom it connects as is not the person's to change
|
|
312
|
+
else if (project.config.demo === true)
|
|
313
|
+
checks.push(...await databaseChecks(src.resolved, { demo: true, name: src.from }));
|
|
314
|
+
else
|
|
315
|
+
checks.push(...await databaseChecks(src.resolved, { role: roleAsk(src, project.root), name: src.from }));
|
|
231
316
|
}
|
|
232
317
|
// a project here whose config cannot be read: init would refuse, so "Ready: mason init" would send a person nowhere
|
|
233
318
|
if ("code" in which && which.code === "bad_config")
|
|
@@ -235,6 +320,8 @@ export async function doctor(argv, deps = {}) {
|
|
|
235
320
|
}
|
|
236
321
|
else {
|
|
237
322
|
checks.push(...await runChecks(run));
|
|
323
|
+
if (project)
|
|
324
|
+
checks.push(...await runRoleChecks(project));
|
|
238
325
|
}
|
|
239
326
|
const next = nextSteps(checks, run, project);
|
|
240
327
|
const { ready, verdict } = verdictOf(checks, run, project);
|
|
@@ -252,8 +339,14 @@ export async function doctor(argv, deps = {}) {
|
|
|
252
339
|
const mark = markOf(c, s);
|
|
253
340
|
out(` ${mark} ${c.name.padEnd(w)} ${wrap(c.detail, s.width, 2 + 3 + 1 + w + 2)}`);
|
|
254
341
|
}
|
|
342
|
+
// a way out several lines share, once, under them: SQL as what you type, the rest as its words
|
|
343
|
+
for (const fix of new Set(checks.map((c) => c.fix).filter((x) => x !== undefined))) {
|
|
344
|
+
out("");
|
|
345
|
+
for (const line of fix.split("\n"))
|
|
346
|
+
out(` ${/;(\s+--.*)?$/.test(line) ? s.cyan(line) : line.startsWith("--") ? s.dim(line) : wrap(line, s.width, 4)}`);
|
|
347
|
+
}
|
|
255
348
|
if (run !== null)
|
|
256
|
-
out(s.dim(` its
|
|
349
|
+
out(s.dim(` its synced data and its state in ${run.home}. mason status for the details`));
|
|
257
350
|
if (run === null || !ready)
|
|
258
351
|
out(nextBlock(next));
|
|
259
352
|
return ready ? 0 : 1;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PROJECT'S GENERATED TABLES (issue 124): a folder beside the sources file, one file per table, the project's to
|
|
3
|
+
* commit and review. The file's name is the table's: `requests.yml` is `public.requests`, `reports.daily.yml` is
|
|
4
|
+
* `reports.daily`.
|
|
5
|
+
*
|
|
6
|
+
* kind: scheduled what the table promises: `scheduled` (any SELECT, up to target_lag behind its base)
|
|
7
|
+
* target_lag: 5m how far behind it may be
|
|
8
|
+
* sql: | what it holds
|
|
9
|
+
* SELECT tenant_id, count(*) AS requests FROM events GROUP BY 1
|
|
10
|
+
* order_by: tenant_id optional: what follows ORDER BY, over its columns, one SQL string
|
|
11
|
+
* comment: Requests per tenant. optional: COMMENT ON TABLE
|
|
12
|
+
*
|
|
13
|
+
* Read here for its shape alone: each mistake at its key, with the reason and the way out, all of a file's at once. What
|
|
14
|
+
* its SQL means is the catalog's to say (src/duck/generated-plan.ts). A mistake never stops the run or another table,
|
|
15
|
+
* unlike a sources file's: a table already deployed keeps serving what it was built from (src/duck/generated.ts).
|
|
16
|
+
*
|
|
17
|
+
* `GENERATED_KEYS` is the one list of keys: a key the reader takes and the guide says (`mason help generated`), so neither
|
|
18
|
+
* can name one the other does not.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { parseDocument } from "yaml";
|
|
23
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
24
|
+
import { parseLag } from "../lib/table-sync.js";
|
|
25
|
+
import { shownPath, tableNameProblem } from "./sources.js";
|
|
26
|
+
/** Where a project's generated tables are: `<folder>/generated_tables/`, beside `sources/`. */
|
|
27
|
+
export const GENERATED_DIR = "generated_tables";
|
|
28
|
+
export const generatedDir = (folder) => path.join(folder, GENERATED_DIR);
|
|
29
|
+
/** Every key a file takes, and what it says, as a person reads it. */
|
|
30
|
+
export const GENERATED_KEYS = {
|
|
31
|
+
kind: "required: how the table stays current. scheduled: any SELECT, up to target_lag behind its base. live is not available yet",
|
|
32
|
+
sql: "required: the one SELECT that gives the table, over the tables the sources file syncs",
|
|
33
|
+
target_lag: "required for scheduled: how far behind its base it may be, like 30s, 5m or 1h (at least 1s, at most 24h). Mason looks at each table every 5s, so a table under 5s can be up to 5s behind",
|
|
34
|
+
order_by: "optional: the table's rows are written in this order, as what follows ORDER BY, over its columns, one SQL string",
|
|
35
|
+
comment: "optional: the table's comment, as COMMENT ON TABLE",
|
|
36
|
+
};
|
|
37
|
+
const KEYS = Object.keys(GENERATED_KEYS);
|
|
38
|
+
const LIVE_WHY = "A table kept current in its base's own write isn't built yet, and Mason never runs a table as another kind than its file says.";
|
|
39
|
+
const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
40
|
+
const refusal = (key, message, options = [], why = null) => ({ key, message, why, options });
|
|
41
|
+
/** The file's text read as a definition, or everything wrong with it. */
|
|
42
|
+
export function parseGeneratedText(text, name, file) {
|
|
43
|
+
const at = { name: `${name.schema}.${name.table}`, schema: name.schema, table: name.table, file };
|
|
44
|
+
const doc = parseDocument(text, { uniqueKeys: true });
|
|
45
|
+
if (doc.errors.length)
|
|
46
|
+
return { ...at, kind: null, refusals: [refusal(null, `it is not YAML: ${doc.errors[0].message.split("\n")[0]}`)] };
|
|
47
|
+
const v = doc.toJS();
|
|
48
|
+
if (!isPlain(v))
|
|
49
|
+
return { ...at, kind: null, refusals: [refusal(null, `it is ${v === null || v === undefined ? "empty" : "not a mapping"}`, ["write kind: scheduled, target_lag: 5m and sql: SELECT …"])] };
|
|
50
|
+
const refusals = [];
|
|
51
|
+
for (const k of Object.keys(v))
|
|
52
|
+
if (!KEYS.includes(k))
|
|
53
|
+
refusals.push(refusal(k, `is not a key${didYouMean(k, KEYS)}`, [`a generated table takes ${KEYS.join(", ")}`]));
|
|
54
|
+
const kind = typeof v.kind === "string" ? v.kind : null;
|
|
55
|
+
const lagWritten = v.target_lag !== undefined;
|
|
56
|
+
const lag = lagWritten ? parseLag(v.target_lag) : null;
|
|
57
|
+
if (v.kind === undefined || v.kind === null)
|
|
58
|
+
refusals.push(refusal("kind", "is missing", ["write kind: scheduled with a target_lag, e.g. target_lag: 5m"], "Mason never decides how a table stays current for you."));
|
|
59
|
+
else if (kind === "live") {
|
|
60
|
+
refusals.push(refusal("kind", "live isn't available yet", ["set kind: scheduled with a target_lag, e.g. target_lag: 5m"], LIVE_WHY));
|
|
61
|
+
if (lagWritten)
|
|
62
|
+
refusals.push(refusal("target_lag", "contradicts kind: live", ["remove target_lag", "or set kind: scheduled"], "A live table is as fresh as its base, so it has no lag to choose."));
|
|
63
|
+
}
|
|
64
|
+
else if (kind !== "scheduled")
|
|
65
|
+
refusals.push(refusal("kind", `${String(v.kind)} is not a kind${kind === null ? "" : didYouMean(kind, ["live", "scheduled"])}`, ["write kind: scheduled with a target_lag, e.g. target_lag: 5m"]));
|
|
66
|
+
else if (!lagWritten)
|
|
67
|
+
refusals.push(refusal("target_lag", "is missing", ["write how stale is acceptable, e.g. target_lag: 5m"], "A scheduled table may lag its base, so its file says by how much."));
|
|
68
|
+
if (lag !== null && "problem" in lag)
|
|
69
|
+
refusals.push(refusal("target_lag", lag.problem));
|
|
70
|
+
const text1 = (key) => {
|
|
71
|
+
const x = v[key];
|
|
72
|
+
if (x === undefined)
|
|
73
|
+
return null;
|
|
74
|
+
if (typeof x === "string" && x.trim() !== "")
|
|
75
|
+
return x;
|
|
76
|
+
refusals.push(refusal(key, key === "order_by" ? "is one SQL string, as after ORDER BY" : "is empty or not text", key === "order_by" ? ["write order_by: day, tenant_id: a list would split on the commas inside an expression"] : []));
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
const sql = text1("sql");
|
|
80
|
+
if (v.sql === undefined)
|
|
81
|
+
refusals.push(refusal("sql", "is missing", ["write sql: | and the SELECT that gives the table"]));
|
|
82
|
+
const orderBy = text1("order_by"), comment = text1("comment");
|
|
83
|
+
if (refusals.length > 0 || sql === null || lag === null || "problem" in lag)
|
|
84
|
+
return { ...at, kind, refusals };
|
|
85
|
+
return { ...at, sql, orderBy: orderBy?.trim() ?? null, targetLag: lag, comment };
|
|
86
|
+
}
|
|
87
|
+
/** a file's name as the table it names: `requests.yml`, `reports.daily.yml`; or why it names none */
|
|
88
|
+
function tableOfFile(file) {
|
|
89
|
+
const name = file.replace(/\.ya?ml$/, "");
|
|
90
|
+
const bad = tableNameProblem(name);
|
|
91
|
+
if (bad !== null)
|
|
92
|
+
return bad;
|
|
93
|
+
const dot = name.indexOf(".");
|
|
94
|
+
const schema = dot < 0 ? "public" : name.slice(0, dot), table = dot < 0 ? name : name.slice(dot + 1);
|
|
95
|
+
if (schema === "_mason" || schema === "information_schema" || schema.startsWith("pg_") || table.startsWith("pg_") || table.includes("__mason"))
|
|
96
|
+
return `${name} is a name Postgres or Mason keeps for itself`;
|
|
97
|
+
return { schema, table };
|
|
98
|
+
}
|
|
99
|
+
/** The folder as it is now: every definition in shape, and every file refused. A folder that is not there holds none. */
|
|
100
|
+
export function readGenerated(folder, root) {
|
|
101
|
+
const dir = generatedDir(folder);
|
|
102
|
+
let names;
|
|
103
|
+
try {
|
|
104
|
+
names = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e.code === "ENOENT")
|
|
108
|
+
return { definitions: [], refused: [] };
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
const out = { definitions: [], refused: [] };
|
|
112
|
+
const seen = new Map();
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
const full = path.join(dir, name), shown = shownPath(root, full);
|
|
115
|
+
const stem = name.replace(/\.ya?ml$/, "");
|
|
116
|
+
const t = tableOfFile(name);
|
|
117
|
+
if (typeof t === "string") {
|
|
118
|
+
const dot = stem.indexOf(".");
|
|
119
|
+
out.refused.push({ name: dot < 0 ? `public.${stem}` : stem, schema: dot < 0 ? "public" : stem.slice(0, dot), table: dot < 0 ? stem : stem.slice(dot + 1), file: shown, kind: null, refusals: [refusal(null, t, ["rename the file: its name is the table's"])] });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const table = `${t.schema}.${t.table}`, before = seen.get(table);
|
|
123
|
+
if (before !== undefined) {
|
|
124
|
+
out.refused.push({ name: table, ...t, file: shown, kind: null, refusals: [refusal(null, `names the table ${table}, as ${before} does`, ["keep one"])] });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
seen.set(table, shown);
|
|
128
|
+
let text;
|
|
129
|
+
try {
|
|
130
|
+
text = fs.readFileSync(full, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
out.refused.push({ name: table, ...t, file: shown, kind: null, refusals: [refusal(null, `it cannot be read: ${e.message}`)] });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const read = parseGeneratedText(text, t, shown);
|
|
137
|
+
if ("refusals" in read)
|
|
138
|
+
out.refused.push(read);
|
|
139
|
+
else
|
|
140
|
+
out.definitions.push(read);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|