@mason-data/cli 0.7.0 → 0.8.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 +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- 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 +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- 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 +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -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 +10 -3
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** The content type of the text exposition format, version 0.0.4. */
|
|
2
|
+
export const METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
3
|
+
/**
|
|
4
|
+
* HOW MANY SAMPLES OLD A NUMBER READ FROM THE DATABASE (the stream's position, the slot) or THE SAMPLER (p50 / p95) may
|
|
5
|
+
* be and still be said: the sampler refreshes each once a minute (src/duck/usage.ts SAMPLE_SECONDS), and a scrape never
|
|
6
|
+
* reads them itself (src/cli/run-server.ts `metricsFacts`), so one older than two samples means the sampler could not
|
|
7
|
+
* take them — said as unknown (absent), never as a stale number that looks current.
|
|
8
|
+
*/
|
|
9
|
+
export const FRESH_SAMPLES = 2;
|
|
10
|
+
/** a label value escaped as the format says: backslash, double quote and line feed */
|
|
11
|
+
const labelValue = (v) => v.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
|
|
12
|
+
/** a sample's value: an integer as written, a fraction as JavaScript prints it (Go's ParseFloat reads both) */
|
|
13
|
+
const valueText = (n) => (Number.isInteger(n) ? n.toFixed(0) : String(n));
|
|
14
|
+
/** a sample that must be a count: a whole number, at least 0 */
|
|
15
|
+
const isCount = (s, kind) => kind === "counter" || s.suffix === "_count";
|
|
16
|
+
/** One metric's lines, its HELP and TYPE first — or none, the WHOLE family absent, when a sample it needs is unknown
|
|
17
|
+
* or any value is not finite (or a count not a whole number). */
|
|
18
|
+
function metric(name, kind, help, samples) {
|
|
19
|
+
const whole = samples.every((s) => s.value === null ? s.optional === true
|
|
20
|
+
: Number.isFinite(s.value) && (!isCount(s, kind) || (Number.isInteger(s.value) && s.value >= 0)));
|
|
21
|
+
const kept = samples.filter((s) => s.value !== null);
|
|
22
|
+
if (!whole || kept.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
const lines = [`# HELP ${name} ${help.replaceAll("\\", "\\\\").replaceAll("\n", "\\n")}`, `# TYPE ${name} ${kind}`];
|
|
25
|
+
for (const s of kept) {
|
|
26
|
+
const labels = s.labels ? Object.entries(s.labels).map(([k, v]) => `${k}="${labelValue(v ?? "")}"`).join(",") : "";
|
|
27
|
+
lines.push(`${name}${s.suffix ?? ""}${labels ? `{${labels}}` : ""} ${valueText(s.value)}`);
|
|
28
|
+
}
|
|
29
|
+
return lines;
|
|
30
|
+
}
|
|
31
|
+
/** The PHASES `mason_tables` says, in order: the sync's (src/duck/sync.ts `CopyState.phase`), as `/healthz` counts them. */
|
|
32
|
+
export const TABLE_PHASES = ["copied", "copying", "waiting", "failed"];
|
|
33
|
+
/** The OUTCOMES `mason_statements_total` says: the request log's (`outcomeOf`), `ok` said as `completed`. */
|
|
34
|
+
export const STATEMENT_OUTCOMES = ["completed", "failed", "refused", "cancelled"];
|
|
35
|
+
/**
|
|
36
|
+
* THE BODY OF `GET /metrics`. Starting or stopping: `mason_ready 0` and `mason_info` alone (a scrape must not fail
|
|
37
|
+
* while Mason starts; whether it is up is `/healthz`'s to say). Serving: `mason_ready 1` and the run's numbers.
|
|
38
|
+
*/
|
|
39
|
+
export function metricsText(f) {
|
|
40
|
+
const lines = [
|
|
41
|
+
...metric("mason_ready", "gauge", "1 once Mason serves the synced data; 0 while it starts or stops (/healthz answers 503 then).", [{ value: f.run === null ? 0 : 1 }]),
|
|
42
|
+
...metric("mason_info", "gauge", "Mason's version, as a label; the value is always 1.", [{ labels: { version: f.version }, value: 1 }]),
|
|
43
|
+
];
|
|
44
|
+
const r = f.run;
|
|
45
|
+
if (r !== null) {
|
|
46
|
+
const u = r.usage, st = r.statements;
|
|
47
|
+
// a number read from the database (the stream's position, the slot) as of `readAt`: said only while fresh
|
|
48
|
+
const fresh = (value, readAt) => readAt !== null && isFresh(r, readAt) ? value : null;
|
|
49
|
+
lines.push(...metric("mason_uptime_seconds", "gauge", "Seconds since mason serve started.", [{ value: r.uptimeSeconds }]), ...metric("mason_tables", "gauge", "The synced tables the run holds, by phase (a removed table's data is held until dropped).", TABLE_PHASES.map((phase) => ({ labels: { phase }, value: u.tables[phase] }))), ...metric("mason_copy_bytes", "gauge", "The size of the synced data's file on disk, bytes.", [{ value: u.copyBytes }]), ...metric("mason_copy_rows", "gauge", "The rows of the synced tables served.", [{ value: u.totals.rows }]),
|
|
50
|
+
// absent, not NaN, before any added table was synced: nothing has an age yet (see the module's comment)
|
|
51
|
+
...metric("mason_sync_oldest_age_seconds", "gauge", "Seconds since the stalest added table was last brought current; absent before any sync.", [{ value: u.oldestSyncSeconds }]),
|
|
52
|
+
// absent where no table is replicated (the run polls): a 0 would claim a stream that is not there. The database's
|
|
53
|
+
// position under the bytes, and the slot, are as the sampler last read them (a scrape never asks the database):
|
|
54
|
+
// absent when that is more than FRESH_SAMPLES samples old. The seconds are the run's own clock against the last
|
|
55
|
+
// batch's commit time, read from nothing: current at every scrape
|
|
56
|
+
...metric("mason_stream_lag_bytes", "gauge", "Replication: WAL bytes between the database's position (as last sampled, at most two samples old) and what Mason has applied; absent when nothing is replicated or not known.", [{ value: fresh(u.streamLag?.bytes ?? null, u.streamLagReadAt) }]), ...metric("mason_stream_lag_seconds", "gauge", "Replication: seconds since the newest change applied was committed on the database; absent when nothing is replicated.", [{ value: u.streamLag?.seconds ?? null }]), ...metric("mason_slot_wal_held_bytes", "gauge", "Replication: WAL bytes the replication slot holds on the database (as last sampled, at most two samples old); absent when nothing is replicated or not known.", [{ value: fresh(u.slotWalHeldBytes, u.slotWalReadAt) }]), ...metric("mason_disk_free_bytes", "gauge", "Bytes free on the disk the synced data is on.", [{ value: u.diskFreeBytes }]), ...metric("mason_sessions_open", "gauge", "Sessions logged in to Mason's Postgres API now.", [{ value: u.sessionsOpen }]), ...metric("mason_statements_total", "counter", "Statements the listener answered since the process started, whether or not the request log kept each, by outcome (refused: the statement's fault; failed: Mason's or the machine's).", STATEMENT_OUTCOMES.map((outcome) => ({ labels: { outcome }, value: outcome === "completed" ? st.completed : st[outcome] }))), ...durationSummary(r));
|
|
57
|
+
}
|
|
58
|
+
return `${lines.join("\n")}\n`;
|
|
59
|
+
}
|
|
60
|
+
/** a time `readAt` (ISO) at most `freshSeconds` before the facts were read */
|
|
61
|
+
const isFresh = (r, readAt) => (r.at - Date.parse(readAt)) / 1000 <= r.freshSeconds;
|
|
62
|
+
/**
|
|
63
|
+
* `mason_statement_duration_seconds`, a summary: the quantiles are the last usage sample's (its minute's completed
|
|
64
|
+
* statements, as `mason usage` says them: src/duck/usage.ts `lastDurations`; past DURATIONS_KEPT in a minute, of a
|
|
65
|
+
* uniform sample of them), absent before the first sample, for a minute none completed, and when the last sample is
|
|
66
|
+
* more than FRESH_SAMPLES samples old (a sample that could not be taken keeps the one before); `_sum` and `_count` are
|
|
67
|
+
* the completed statements since the process started (the counter), as a Prometheus client's summary is: quantiles
|
|
68
|
+
* over a recent window, sum and count cumulative.
|
|
69
|
+
*/
|
|
70
|
+
function durationSummary(r) {
|
|
71
|
+
const d = r.durations !== null && isFresh(r, r.durations.at) ? r.durations : null;
|
|
72
|
+
const seconds = (ms) => (ms === null || ms === undefined ? null : ms / 1000);
|
|
73
|
+
return metric("mason_statement_duration_seconds", "summary", "Completed statements' duration, seconds: p50 and p95 of the last minute's usage sample (sampled: a busy minute's of a uniform sample); sum and count since the process started.", [
|
|
74
|
+
{ labels: { quantile: "0.5" }, value: seconds(d?.p50Ms), optional: true },
|
|
75
|
+
{ labels: { quantile: "0.95" }, value: seconds(d?.p95Ms), optional: true },
|
|
76
|
+
{ suffix: "_sum", value: r.statements.completedMs / 1000 },
|
|
77
|
+
{ suffix: "_count", value: r.statements.completed },
|
|
78
|
+
]);
|
|
79
|
+
}
|
package/dist/src/cli/pg-tls.js
CHANGED
|
@@ -42,22 +42,30 @@ export function runListenerSettings(shell, o) {
|
|
|
42
42
|
...(s.fix ? { fix: s.kind === "plain" ? `${s.fix}. Or mason start --self-signed-tls: a certificate made for this project in .mason/` : s.fix } : {}),
|
|
43
43
|
...(s.kind === "plain" ? { next: [["mason start --self-signed-tls", "TLS from a certificate made in .mason/ (clients connect with sslmode=require)"]] } : {}) };
|
|
44
44
|
}
|
|
45
|
+
/** The name in every self-signed certificate: a certificate is handed to whoever connects, before any login, so it
|
|
46
|
+
* names no project. */
|
|
47
|
+
const SELF_SIGNED_NAME = "mason";
|
|
45
48
|
/**
|
|
46
49
|
* The project's self-signed certificate: the one in `.mason/` when it is still good (it parses, its key is its own, it
|
|
47
|
-
* has not expired), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
|
|
50
|
+
* has not expired, it names no project), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
|
|
48
51
|
*/
|
|
49
|
-
export function ensureSelfSigned(home,
|
|
52
|
+
export function ensureSelfSigned(home, now = new Date()) {
|
|
50
53
|
const certFile = path.join(home, SELF_SIGNED_FILES.cert), keyFile = path.join(home, SELF_SIGNED_FILES.key);
|
|
51
54
|
const kept = keptCertificate(certFile, keyFile, now);
|
|
52
55
|
if ("fingerprint" in kept)
|
|
53
56
|
return { fingerprint: kept.fingerprint, said: null };
|
|
54
|
-
const made = selfSignedCertificate(
|
|
57
|
+
const made = selfSignedCertificate(SELF_SIGNED_NAME, { now });
|
|
55
58
|
for (const [file, text] of [[keyFile, made.key], [certFile, made.cert]]) {
|
|
56
59
|
fs.writeFileSync(file, text, { mode: 0o600 });
|
|
57
60
|
fs.chmodSync(file, 0o600); // a file that was there keeps its mode through writeFileSync
|
|
58
61
|
}
|
|
59
62
|
return { fingerprint: new crypto.X509Certificate(made.cert).fingerprint256, said: kept.why };
|
|
60
63
|
}
|
|
64
|
+
/** The project's self-signed certificate as a listener reads it: the two files of `ensureSelfSigned`, and what it said. */
|
|
65
|
+
export function selfSignedPair(home) {
|
|
66
|
+
const { fingerprint, said } = ensureSelfSigned(home);
|
|
67
|
+
return { cert: fs.readFileSync(path.join(home, SELF_SIGNED_FILES.cert)), key: fs.readFileSync(path.join(home, SELF_SIGNED_FILES.key)), fingerprint, said };
|
|
68
|
+
}
|
|
61
69
|
function keptCertificate(certFile, keyFile, now) {
|
|
62
70
|
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile))
|
|
63
71
|
return { why: `made a self-signed certificate: .mason/${SELF_SIGNED_FILES.cert}` };
|
|
@@ -65,6 +73,8 @@ function keptCertificate(certFile, keyFile, now) {
|
|
|
65
73
|
const x = new crypto.X509Certificate(fs.readFileSync(certFile));
|
|
66
74
|
if (!x.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(keyFile))))
|
|
67
75
|
return { why: `made a new self-signed certificate: .mason/${SELF_SIGNED_FILES.key} was not the key of .mason/${SELF_SIGNED_FILES.cert}` };
|
|
76
|
+
if (x.subject !== `CN=${SELF_SIGNED_NAME}` || x.issuer !== `CN=${SELF_SIGNED_NAME}`)
|
|
77
|
+
return { why: "made a new self-signed certificate: the one in .mason/ named this project to whoever connected" };
|
|
68
78
|
if (new Date(x.validTo).getTime() <= now.getTime())
|
|
69
79
|
return { why: `made a new self-signed certificate: the one in .mason/ expired ${x.validTo}` };
|
|
70
80
|
return { fingerprint: x.fingerprint256 };
|
package/dist/src/cli/pg-url.js
CHANGED
|
@@ -40,6 +40,10 @@ export const withPlaceholders = (recorded) => recorded.replace(/^postgresql:\/\/
|
|
|
40
40
|
export const withoutPlaceholders = (shown) => shown.replace(/^postgres:\/\/<user>:<password>@/, "postgresql://");
|
|
41
41
|
/** The psql line for a URL, quoted for a shell. */
|
|
42
42
|
export const psqlLine = (url) => `psql ${shellWord(url)}`;
|
|
43
|
+
/** WHERE A TOOL CONNECTS, as `mason status --json` and `/api/state` say it (`postgres`): the recorded URL's parts, the
|
|
44
|
+
* URL with the credentials' place shown (`shown`, what `mason start` and `mason status` print) and its psql line — one
|
|
45
|
+
* builder, so the web page's Connect panel and the commands print the same line. */
|
|
46
|
+
export const postgresFacts = (recorded) => ({ ...postgresLink(recorded), shown: withPlaceholders(recorded), psql: psqlLine(withPlaceholders(recorded)) });
|
|
43
47
|
/** What a tool connecting to the listener should know: it listens on every address; its certificate is self-signed. */
|
|
44
48
|
export function listenerTips(l) {
|
|
45
49
|
if (l === null)
|
package/dist/src/cli/pin.js
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE PROJECT'S VERSION PIN, as the mason command holds it (src/lib/project-version.ts has the rule and the image's
|
|
3
3
|
* side): `mason init` writes `mason.yml` with this command's version, `mason start` writes it into a project made
|
|
4
|
-
* before there was one, and `mason init` and `mason
|
|
5
|
-
* developer runs is then what production
|
|
4
|
+
* before there was one, and `mason init`, `mason start` and a `mason serve` of the package refuse a command of another
|
|
5
|
+
* release line — what the developer runs is then what production is held to.
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import { parseVersion, pinFileText,
|
|
9
|
+
import { parseVersion, pinFileText, projectPinProblem, PIN_FILE } from "../lib/project-version.js";
|
|
10
10
|
/** Why this mason command may not run the project in `folder`, or null (a checkout's `dev` checks nothing). */
|
|
11
|
+
export function commandPinProblem(folder, cliVersion) {
|
|
12
|
+
return projectPinProblem(folder, cliVersion, { what: "this mason command",
|
|
13
|
+
run: (p) => `run the mason command it names: npm i -g @mason-data/cli@${p} (or npx @mason-data/cli@${p} …)` });
|
|
14
|
+
}
|
|
15
|
+
const sentence = (clause) => `${clause[0].toUpperCase()}${clause.slice(1)}.`;
|
|
16
|
+
/** The refusal as `mason init` and `mason start` say it: one line of sentences. */
|
|
11
17
|
export function cliPinProblem(folder, cliVersion) {
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
14
|
-
return null;
|
|
15
|
-
const read = readPin(folder);
|
|
16
|
-
if ("problem" in read)
|
|
17
|
-
return read.problem;
|
|
18
|
-
if (read.pin === null)
|
|
18
|
+
const r = commandPinProblem(folder, cliVersion);
|
|
19
|
+
if (r === null)
|
|
19
20
|
return null;
|
|
20
|
-
const
|
|
21
|
-
return
|
|
21
|
+
const [run, ...others] = r.ways;
|
|
22
|
+
return run === undefined ? r.problem : [`${r.problem}.`, sentence(run), ...others.map((w) => sentence(`or ${w}`))].join(" ");
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
25
|
* Write `folder`'s mason.yml with this command's version when it has none; the file written, or null when there was one
|
|
@@ -149,7 +149,7 @@ export const VIEWS_SQL = `SET LOCAL search_path = pg_catalog; SELECT coalesce(js
|
|
|
149
149
|
) v`;
|
|
150
150
|
/** One catalog read: every ordinary or partitioned table outside the system schemas, its key, size, comments and FKs. */
|
|
151
151
|
export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text FROM (
|
|
152
|
-
SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid,
|
|
152
|
+
SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid, c.relreplident AS replica_identity,
|
|
153
153
|
-- a PARTITIONED table's rows and bytes are its leaves' (its own are -1 / 0 and ~0 bytes); "never analysed" is
|
|
154
154
|
-- relpages = 0 AND reltuples <= 0 on any leaf, which Postgres 13 (reltuples 0) and 14+ (-1) both read
|
|
155
155
|
CASE WHEN c.relkind = 'p'
|
|
@@ -221,6 +221,7 @@ export async function readSourceFacts(pg, counted = COUNTED) {
|
|
|
221
221
|
foreignKeys: t.foreign_keys.map((f) => ({ column: f.column, target: f.target, targetColumn: f.target_column })),
|
|
222
222
|
uniqueKeys: t.unique_keys.map((u) => ({ name: u.name, columns: u.columns, constraint: u.constraint, nullsNotDistinct: u.nulls_not_distinct })),
|
|
223
223
|
...(t.row_type_oid > 0 ? { rowTypeOid: t.row_type_oid } : {}),
|
|
224
|
+
...(t.replica_identity === "d" || t.replica_identity === "n" || t.replica_identity === "f" || t.replica_identity === "i" ? { replicaIdentity: t.replica_identity } : {}),
|
|
224
225
|
});
|
|
225
226
|
}
|
|
226
227
|
// the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
|
|
@@ -261,7 +262,7 @@ export function serverlessHost(host) {
|
|
|
261
262
|
* on a database elsewhere (the tables worth copying are the big ones). */
|
|
262
263
|
export function unpickableReason(t) {
|
|
263
264
|
if (t.key.length === 0)
|
|
264
|
-
return "no primary key: Mason keeps one row per key, so it can only
|
|
265
|
+
return "no primary key: Mason keeps one row per key, so it can only sync a table that has one";
|
|
265
266
|
return null;
|
|
266
267
|
}
|
|
267
268
|
/** The warnings `mason start` prints, and `mason status` shows, before anything is copied. */
|
|
@@ -280,8 +281,8 @@ export function sourceWarnings(s, f) {
|
|
|
280
281
|
/** The warning a database on another machine gets at every start: what the copy is and how it is kept current (true of
|
|
281
282
|
* both modes — the mode is the start's, not known here: a poll scans each table, replication reads the change log). */
|
|
282
283
|
export function remoteWarning(s) {
|
|
283
|
-
return `${s.host} is not this machine. mason start will
|
|
284
|
-
"with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log
|
|
284
|
+
return `${s.host} is not this machine. mason start will sync the tables you pick to this machine (into its synced data's file, ` +
|
|
285
|
+
"with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log for a table whose sync: is replication";
|
|
285
286
|
}
|
|
286
287
|
/**
|
|
287
288
|
* THE CONFIRMATION A DATABASE ON ANOTHER MACHINE NEEDS (the refusal read as a failed connection and then a fix: only a
|
|
@@ -291,9 +292,9 @@ export function remoteWarning(s) {
|
|
|
291
292
|
*/
|
|
292
293
|
export function remoteConfirmation(host, o) {
|
|
293
294
|
return { code: "failed", exit: 1,
|
|
294
|
-
message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and
|
|
295
|
-
why: "mason start would
|
|
296
|
-
"and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log
|
|
295
|
+
message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and syncing from it needs your say-so`,
|
|
296
|
+
why: "mason start would sync the tables you pick to this machine: a file under .mason/, with no access control beyond this machine's\n" +
|
|
297
|
+
"and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log for a table whose sync: is replication",
|
|
297
298
|
fix: o.wayOut, next: o.next };
|
|
298
299
|
}
|
|
299
300
|
/** `pg_current_snapshot()` and xid8, which polling reads, arrived in Postgres 13. */
|
package/dist/src/cli/project.js
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
20
|
import { randomBytes } from "node:crypto";
|
|
21
|
-
import {
|
|
21
|
+
import { connectDatabase, databaseSettingRefusal } from "../lib/database-name.js";
|
|
22
|
+
import { changesRetired, pollMsRetired } from "../lib/table-sync.js";
|
|
23
|
+
import { readSource, sourcesTop, writtenDatabase } from "./sources.js";
|
|
22
24
|
export const STATE_DIR = ".mason";
|
|
23
25
|
const CONFIG = "config.json";
|
|
24
|
-
/** A project's name:
|
|
25
|
-
*
|
|
26
|
+
/** A project's name: what its commands call it and (with the source's) what its replication slot is named for. Never
|
|
27
|
+
* the database name a tool connects with: src/lib/database-name.ts. */
|
|
26
28
|
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
27
29
|
/** The copy of the data, a DuckDB file in the project's `.mason/`: kept by mason stop, deleted by mason destroy. */
|
|
28
30
|
export const DUCK_FILE = "duck.db";
|
|
@@ -113,7 +115,7 @@ export function theProject(cwd) {
|
|
|
113
115
|
if (p)
|
|
114
116
|
return { project: p };
|
|
115
117
|
return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one (mason init demo: a demo database, nothing to install)",
|
|
116
|
-
next: [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"]] };
|
|
118
|
+
next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"]] };
|
|
117
119
|
}
|
|
118
120
|
/** Whether `root`'s .gitignore already ignores `.mason/`. */
|
|
119
121
|
export function ignoresStateDir(gitignore) {
|
|
@@ -127,3 +129,40 @@ export function ignoresStateDir(gitignore) {
|
|
|
127
129
|
export function projectSource(p, env = process.env, o = {}) {
|
|
128
130
|
return readSource(p.folder, p.root, env, o);
|
|
129
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* THE DATABASE NAME THE PROJECT'S NEXT RUN WILL ANSWER AS, with Mason stopped (a run's is in its URL: `postgresLink`): by
|
|
134
|
+
* the one rule, from the sources file's one key, so it needs neither the database's URL nor the rest of the file right.
|
|
135
|
+
*/
|
|
136
|
+
export function projectDatabase(p, env = process.env) {
|
|
137
|
+
const written = writtenDatabase(p.folder, p.root);
|
|
138
|
+
return "message" in written ? written : connectDatabase(written, p.config, env);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* THE NAME THE SOURCES FILE HAS MOVED TO SINCE A RUN STARTED answering as `answersAs` (the name is read at the start), or
|
|
142
|
+
* null. Only what the file says: a server's run may be named by its setting, in an environment the shell asking need
|
|
143
|
+
* not have, so a served project whose file writes no name has nothing to compare; a file that cannot be read is said
|
|
144
|
+
* as such by the commands that read it.
|
|
145
|
+
*/
|
|
146
|
+
export function databaseMoved(p, answersAs) {
|
|
147
|
+
const written = writtenDatabase(p.folder, p.root);
|
|
148
|
+
if ("message" in written || (written.database === null && p.config.served === true))
|
|
149
|
+
return null;
|
|
150
|
+
const says = connectDatabase(written, {}, {});
|
|
151
|
+
return says === answersAs ? null : says;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A SETTING A START REFUSES, for the start that is typed and the run it spawns alike: read by the parent where the person
|
|
155
|
+
* (or --json) reads the answer, and by the run, which `mason start --attach` and `mason serve` reach without the parent.
|
|
156
|
+
* One list, because a check in the run alone reaches a background start as "the run stopped", its way out in a log.
|
|
157
|
+
*/
|
|
158
|
+
export function startSettingRefusal(p, env, o) {
|
|
159
|
+
const top = () => sourcesTop(p.root, p.folder);
|
|
160
|
+
const pollMs = pollMsRetired(env, top);
|
|
161
|
+
if (pollMs !== null)
|
|
162
|
+
return { code: "refused", ...pollMs };
|
|
163
|
+
const changes = changesRetired(env, top);
|
|
164
|
+
if (changes !== null)
|
|
165
|
+
return { code: "refused", ...changes };
|
|
166
|
+
const database = o.serve ? null : databaseSettingRefusal(env, top().shown);
|
|
167
|
+
return database === null ? null : { code: "refused", ...database };
|
|
168
|
+
}
|
|
@@ -8,11 +8,13 @@ export function duration(seconds) {
|
|
|
8
8
|
const m = Math.round(s / 60);
|
|
9
9
|
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
10
10
|
}
|
|
11
|
+
/** A duration in milliseconds as a person reads it (src/lib/table-sync.ts, where the sync reads it too). */
|
|
12
|
+
export { msText } from "../lib/table-sync.js";
|
|
11
13
|
/**
|
|
12
14
|
* WHAT A COPYING TABLE'S LINE SAYS, AND HOW FULL ITS BAR IS (a 20 M-row copy over the internet showed no progress: its
|
|
13
15
|
* first line waited on 0 for minutes, then jumped to 100%). A copy's rows
|
|
14
16
|
* land a chunk at a time, so the count is the committed chunks' rows PLUS what the chunks being read have read by now
|
|
15
|
-
* at the rate the copy measured (`progress.rowsReading`, held under each chunk's expected rows, so a commit moves it
|
|
17
|
+
* at the rate the copy measured (`progress.rowsReading`, held under each chunk's expected rows, so a commit that holds them moves it
|
|
16
18
|
* forward), and the text says what is happening: the chunks reading and for how long, the rate, and the time left at
|
|
17
19
|
* it — never from the row estimate alone. Before anything is measured it says the first chunk is being read, never
|
|
18
20
|
* `0 of`. The share is of the heap's PAGES read (`progress.share`: the ranges are exact), and only without the chunks'
|
|
@@ -35,7 +37,7 @@ export function copyingText(copied, rowsEstimate, p) {
|
|
|
35
37
|
if (p.rowsPerSecond === null) {
|
|
36
38
|
// nothing measured this attempt: its first chunks are being read (a copy resumed has rows from before)
|
|
37
39
|
const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
|
|
38
|
-
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to
|
|
40
|
+
return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to sync`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
39
41
|
}
|
|
40
42
|
const parts = [rowsText(shown)];
|
|
41
43
|
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
@@ -51,7 +53,39 @@ export function copyingText(copied, rowsEstimate, p) {
|
|
|
51
53
|
/** A copy's progress on one line, as `mason wait` lists what it waits for: `public.orders: copying 12% · …` (`copyingText`). */
|
|
52
54
|
export function copyProgress(source, copied, rowsEstimate, p) {
|
|
53
55
|
const c = copyingText(copied, rowsEstimate, p);
|
|
54
|
-
return `${source}: ${c.fraction !== null ? `
|
|
56
|
+
return `${source}: ${c.fraction !== null ? `syncing ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "syncing " : ""}${c.text}`;
|
|
57
|
+
}
|
|
58
|
+
export function syncStateOf(c) {
|
|
59
|
+
if (c === undefined || c.phase === "waiting")
|
|
60
|
+
return "waiting";
|
|
61
|
+
if (c.phase === "failed" || c.error !== null)
|
|
62
|
+
return "failed";
|
|
63
|
+
if (c.phase === "copying" || c.rebuilding !== undefined || c.behind !== undefined)
|
|
64
|
+
return "syncing";
|
|
65
|
+
return "synced";
|
|
66
|
+
}
|
|
67
|
+
export function tableSync(c, now) {
|
|
68
|
+
const s = c?.synced;
|
|
69
|
+
return { state: syncStateOf(c), syncedAt: s?.at ?? null, syncSeconds: s?.seconds ?? null, syncedBy: s?.by ?? null,
|
|
70
|
+
ageSeconds: s === undefined ? null : Math.max(0, Math.round((now - Date.parse(s.at)) / 100) / 10) };
|
|
71
|
+
}
|
|
72
|
+
export function syncTotals(added, copies) {
|
|
73
|
+
const copied = copies.filter((c) => c.phase === "copied");
|
|
74
|
+
const latest = copies.flatMap((c) => c.synced === undefined ? [] : [c.synced.at]).sort().pop() ?? null;
|
|
75
|
+
return { added, copied: copied.length, failed: copies.filter((c) => syncStateOf(c) === "failed").length, rows: copied.reduce((n, c) => n + (c.rows ?? 0), 0), syncedAt: latest };
|
|
76
|
+
}
|
|
77
|
+
export function phaseCounts(copies) {
|
|
78
|
+
const counts = { total: 0, copied: 0, copying: 0, waiting: 0, failed: 0 };
|
|
79
|
+
for (const c of copies) {
|
|
80
|
+
counts.total++;
|
|
81
|
+
counts[c.phase]++;
|
|
82
|
+
}
|
|
83
|
+
return counts;
|
|
84
|
+
}
|
|
85
|
+
/** WHERE A RUN IS IN ONE WORD, as `mason status` heads its last line and the web page's Status says it: every added
|
|
86
|
+
* table copied and served, a failure, or still under way. */
|
|
87
|
+
export function readinessWord(r) {
|
|
88
|
+
return r.ready ? "Ready" : r.failed.length ? "Failed" : "Under way";
|
|
55
89
|
}
|
|
56
90
|
/**
|
|
57
91
|
* WHERE A RUN IS. Ready when every added table's copy has ended well: `mason add` followed by `mason wait` returns
|
|
@@ -70,7 +104,7 @@ export function readiness(s) {
|
|
|
70
104
|
continue;
|
|
71
105
|
}
|
|
72
106
|
if (!t.copy)
|
|
73
|
-
waiting.push(`${t.table}: waiting for its
|
|
107
|
+
waiting.push(`${t.table}: waiting for its first sync to start`);
|
|
74
108
|
}
|
|
75
109
|
return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
|
|
76
110
|
}
|
package/dist/src/cli/registry.js
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
* command's summary, in this order — so a command cannot exist without being listed, and tests/cli-help.test.ts holds
|
|
4
4
|
* each one to src/cli/command.ts's rules (its own --help, an example, every option documented).
|
|
5
5
|
*/
|
|
6
|
-
import { failed, parseCommand, reportText } from "./command.js";
|
|
6
|
+
import { failed, parseCommand, reportText, wantsJson } from "./command.js";
|
|
7
|
+
import { removedStartFlags } from "../lib/table-sync.js";
|
|
8
|
+
import { sourcesTop } from "./sources.js";
|
|
7
9
|
import { dev } from "./dev.js";
|
|
8
10
|
import { devInBackground } from "./background.js";
|
|
9
11
|
import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, status, stop, tables, wait } from "./commands.js";
|
|
10
12
|
import { USER, user } from "./user.js";
|
|
11
13
|
import { MCP, mcp } from "./mcp.js";
|
|
14
|
+
import { SHARE, share } from "./share.js";
|
|
12
15
|
import { UI, ui } from "./ui.js";
|
|
13
16
|
import { GUIDES, guideText } from "./guides.js";
|
|
14
17
|
import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
|
|
15
18
|
import { DESTROY, destroy } from "./machine.js";
|
|
16
19
|
import { INIT, init } from "./init.js";
|
|
17
20
|
import { SYNC, sync } from "./sync.js";
|
|
21
|
+
import { USAGE, usage } from "./usage.js";
|
|
22
|
+
import { CANCEL, cancel } from "./cancel.js";
|
|
18
23
|
import { SERVE, serve } from "./serve.js";
|
|
19
24
|
import { PROJECT_LINE } from "./dev.js";
|
|
20
25
|
import { version } from "./version.js";
|
|
@@ -27,21 +32,31 @@ export const DEV = {
|
|
|
27
32
|
needs: "project",
|
|
28
33
|
name: "start",
|
|
29
34
|
summary: "start Mason for this folder's project, in the background (mason init makes the project)",
|
|
30
|
-
usage: `usage: mason start [--attach] [--
|
|
35
|
+
usage: `usage: mason start [--attach] [--self-signed-tls] [--port <n>] [--json]
|
|
31
36
|
|
|
32
|
-
Starts Mason for the project of this folder (mason init made it): one process, which
|
|
33
|
-
(mason add) from the project's database into its own file, keeps them current, and answers
|
|
34
|
-
for BI tools and your app. It only reads the database (SELECT
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
Starts Mason for the project of this folder (mason init made it): one process, which syncs the tables you add
|
|
38
|
+
(mason add) from the project's database into its own file, keeps them current as the sources file says, and answers
|
|
39
|
+
the Postgres protocol for BI tools and your app. It only reads the database (SELECT, and a table's change log when
|
|
40
|
+
its sync: says replication). It returns once Mason is up; run it again any time (it says so when Mason is
|
|
41
|
+
already running). The synced data and Mason's state stay between runs: the next start resumes.
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
After its first sync, each table is kept current as the sources file's sync: says — at the top for every table, or
|
|
44
|
+
on a table's entry: off (the default, which mason init writes) reads it again only when you run mason sync <table>;
|
|
45
|
+
poll <interval> (poll 5s, poll 5m, poll 1h) polls it with SELECTs at that interval; replication reads its changes
|
|
46
|
+
from the database's change log, as they are made. Each poll scans its table whole
|
|
47
|
+
on your database, and the next waits at least four times as long as that poll took, so the polls never take more
|
|
48
|
+
than a fifth of your database's time. The start says the file's default once; mason status says how each table is
|
|
49
|
+
kept current and when it was last synced. A table whose sync: changed keeps its synced data and is caught up by one poll.
|
|
50
|
+
|
|
51
|
+
A table under sync: replication needs wal_level = logical, a user that may replicate, and a primary key (a key:
|
|
52
|
+
table is polled): Mason makes one replication slot for the project and its own publication, carrying the replicated
|
|
53
|
+
tables alone, and drops them, verified gone, when no table's sync is replication any more (and at mason destroy).
|
|
54
|
+
publication: <name> at the top of the sources file (\${PG_PUBLICATION} reads it from .env) names a publication that
|
|
55
|
+
is the database owner's: it is read as it is, verified to carry every replicated table and column, and never
|
|
56
|
+
created, altered or dropped — Mason makes only its slot. A read replica needs one (it takes no CREATE PUBLICATION).
|
|
57
|
+
|
|
58
|
+
--replication, --poll and --publication are gone: each answers with the line to write at the top of the sources
|
|
59
|
+
file (sync: replication, sync: poll 5m, publication: <name>) and exit 2, and starts nothing.
|
|
45
60
|
|
|
46
61
|
The Postgres API's port is the project's: the first start takes 5433, or the next port free on every address when a
|
|
47
62
|
second Postgres holds it, and records it; every later start listens on the same port, so a BI tool's saved connection
|
|
@@ -58,15 +73,9 @@ export const DEV = {
|
|
|
58
73
|
|
|
59
74
|
mason start
|
|
60
75
|
mason start --attach in the foreground: Ctrl-C stops it
|
|
61
|
-
mason start --replication read the change log, as production does
|
|
62
|
-
mason start --replication --publication app_pub through the database owner's publication
|
|
63
76
|
MASON_PG_HOST=0.0.0.0 mason start --self-signed-tls the Postgres API for other machines, over TLS
|
|
64
77
|
|
|
65
78
|
--attach stay in the foreground (Ctrl-C stops Mason)
|
|
66
|
-
--replication read the database's changes from its change log (logical replication), as production does
|
|
67
|
-
--publication <name> with --replication: the publication to read, the owner's, never altered (PG_PUBLICATION;
|
|
68
|
-
default: Mason's own, mason_dev_<project>_<source>)
|
|
69
|
-
--poll read them with SELECTs (the default; takes back --replication)
|
|
70
79
|
--self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
|
|
71
80
|
served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
|
|
72
81
|
--port <n> the port the mason commands reach it on (default 4039, or the next free one)
|
|
@@ -74,22 +83,22 @@ export const DEV = {
|
|
|
74
83
|
`,
|
|
75
84
|
options: {
|
|
76
85
|
port: { type: "string" }, attach: { type: "boolean", default: false }, json: { type: "boolean", default: false },
|
|
77
|
-
replication: { type: "boolean", default: false }, poll: { type: "boolean", default: false }, publication: { type: "string" },
|
|
78
86
|
"self-signed-tls": { type: "boolean", default: false },
|
|
79
87
|
},
|
|
80
88
|
json: ["running", "pid", "postgres", "next"],
|
|
81
89
|
};
|
|
82
90
|
async function runDev(argv) {
|
|
91
|
+
// --replication, --poll and --publication are gone (issue 33): each answered with the edit to make in the sources
|
|
92
|
+
// file, exit 2, before the options are parsed (it would be an unknown option there) and before anything starts. The
|
|
93
|
+
// file is read only when one is given. A decision: `mason start --replication --help` answers the refusal, not the
|
|
94
|
+
// usage — the flag is wrong whatever else the line asks
|
|
95
|
+
const removed = removedStartFlags(argv, () => startSourcesTop(process.cwd()));
|
|
96
|
+
if (removed !== null)
|
|
97
|
+
return failed("start", { code: "usage", ...removed, next: [["mason start", "each table kept current as its sync: says"], ["mason help production", "sync: and publication:, and what each needs"]] }, { json: wantsJson(argv, DEV) });
|
|
83
98
|
const f = parseCommand(DEV, argv);
|
|
84
99
|
if (typeof f === "number")
|
|
85
100
|
return f;
|
|
86
101
|
const v = f.values;
|
|
87
|
-
if (v.replication && v.poll)
|
|
88
|
-
return failed("start", { message: "--replication or --poll, not both", code: "usage", usage: DEV.usage }, { json: v.json });
|
|
89
|
-
if (v.publication !== undefined && v.poll)
|
|
90
|
-
return failed("start", { message: "--publication goes with --replication, not --poll", code: "usage", usage: DEV.usage }, { json: v.json });
|
|
91
|
-
if (v.publication !== undefined && !/^\S+$/.test(v.publication))
|
|
92
|
-
return failed("start", { message: `--publication must name a publication, not ${JSON.stringify(v.publication)}`, code: "usage", usage: DEV.usage }, { json: v.json });
|
|
93
102
|
if (f.positionals.length) {
|
|
94
103
|
// the database is the project's now: a URL here is the old habit, answered with the new way
|
|
95
104
|
return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", usage: DEV.usage }, { json: v.json });
|
|
@@ -98,8 +107,14 @@ async function runDev(argv) {
|
|
|
98
107
|
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
99
108
|
return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
|
|
100
109
|
if (!v.attach)
|
|
101
|
-
return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"]
|
|
102
|
-
return dev({ port, version: version(),
|
|
110
|
+
return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"] });
|
|
111
|
+
return dev({ port, version: version(), selfSignedTls: v["self-signed-tls"] });
|
|
112
|
+
}
|
|
113
|
+
/** The project's sources file as a person names it (`mason/sources/app.yml`), for a removed flag's answer: the one file
|
|
114
|
+
* of the project of this folder, else the name every project's has. */
|
|
115
|
+
function startSourcesTop(cwd) {
|
|
116
|
+
const t = theProject(cwd);
|
|
117
|
+
return "problem" in t ? { shown: "mason/sources/<name>.yml", sync: null, publication: null } : sourcesTop(t.project.root, t.project.folder);
|
|
103
118
|
}
|
|
104
119
|
export const HELP = {
|
|
105
120
|
name: "help",
|
|
@@ -152,8 +167,8 @@ export const STEPS = [
|
|
|
152
167
|
["Learn", [{ ...HELP, run: runHelp }]],
|
|
153
168
|
["Set up", [{ ...DOCTOR, run: (argv) => doctor(argv) }, { ...INIT, run: (argv) => init(argv) }, { ...DEV, run: runDev }]],
|
|
154
169
|
["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
|
|
155
|
-
["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }]],
|
|
156
|
-
["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
|
|
170
|
+
["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }, { ...SHARE, run: share }]],
|
|
171
|
+
["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...CANCEL, run: cancel }, { ...USAGE, run: (argv) => usage(argv) }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
|
|
157
172
|
["Go to production", [{ ...SERVE, run: serve }]],
|
|
158
173
|
];
|
|
159
174
|
/** Every command, in the order `mason --help` lists them. */
|
|
@@ -164,6 +179,9 @@ export const COMMANDS = STEPS.flatMap(([, commands]) => commands);
|
|
|
164
179
|
* it, then the next command for that moment — and mason help, every command. `mason help` and `mason --help` are the
|
|
165
180
|
* whole list (topUsage).
|
|
166
181
|
*/
|
|
182
|
+
/** WHAT MASON IS, in one line: the first `mason` alone and `mason --help` print, in the words its reader was told it
|
|
183
|
+
* in ("a drop-in read replica for analytics"). */
|
|
184
|
+
export const TAGLINE = "Mason: a drop-in read replica of your Postgres, for analytics — the tables you add, synced, kept current and served to psql, BI tools and your app.";
|
|
167
185
|
export async function orientation(cwd, s = styleOut()) {
|
|
168
186
|
const t = theProject(cwd);
|
|
169
187
|
const project = "project" in t ? t.project : null;
|
|
@@ -172,12 +190,12 @@ export async function orientation(cwd, s = styleOut()) {
|
|
|
172
190
|
? [["Project", `${project.config.name}, files in ${project.folder}`], ["Mason", run ? `running (pid ${run.pid})` : "not running"]]
|
|
173
191
|
: [["Project", "none here: mason init makes this folder one"]];
|
|
174
192
|
const next = !project
|
|
175
|
-
? [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"],
|
|
193
|
+
? [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"],
|
|
176
194
|
["mason init demo", "or try it on a demo database Mason makes itself: nothing to install"],
|
|
177
195
|
["mason doctor", "what this machine needs for Mason, and whether it has it"]]
|
|
178
196
|
: !run ? [["mason start", "start Mason for this project, in the background"], ["mason status", "the project and its tables"]]
|
|
179
197
|
: [["mason status", "what Mason is doing: its tables, and the last day's slow and refused statements"], ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
|
|
180
|
-
return `${reportText({ headline:
|
|
198
|
+
return `${reportText({ headline: TAGLINE, details: rows,
|
|
181
199
|
next: [...next, ["mason help", "every command, and the guides"]] }, s)}\n`;
|
|
182
200
|
}
|
|
183
201
|
/** `mason --help`: what Mason is, every command with its summary, and where each one's help is. On a terminal the steps are
|
|
@@ -204,8 +222,9 @@ export function topUsage(s = styleOut()) {
|
|
|
204
222
|
return [
|
|
205
223
|
`${s.bold("usage:")} mason <command> [options]`,
|
|
206
224
|
"",
|
|
207
|
-
para(
|
|
225
|
+
para(`${TAGLINE} Make your app's folder a project (mason init), ` +
|
|
208
226
|
`add tables (mason add, before or after the start), start Mason (mason start), connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
|
|
227
|
+
para("Try it first with nothing to install: mason init demo, then mason start and mason ui (a demo shop database, hosted by Mason itself)."),
|
|
209
228
|
para(PROJECT_LINE),
|
|
210
229
|
"",
|
|
211
230
|
listed.map(([step, es]) => ` ${s.bold(step)}\n${block(es)}`).join("\n\n"),
|
|
@@ -64,6 +64,8 @@ export function suggestedWalCapGb(bytesPerSecond) {
|
|
|
64
64
|
return null;
|
|
65
65
|
return Math.max(4, Math.ceil((bytesPerSecond * 86_400) / 1024 ** 3));
|
|
66
66
|
}
|
|
67
|
+
/** What Mason does about a lost slot (src/duck/sync.ts `prepareReplication`), one wording for every check that says it. */
|
|
68
|
+
const SLOT_REMADE = "Mason makes a new slot and catches each table up with one poll (a scan of each table on your database), never a full sync";
|
|
67
69
|
/** What wal_level = logical takes on this server: its provider's way, or postgresql.conf's. */
|
|
68
70
|
function walLevelFix(f) {
|
|
69
71
|
if (f.rds)
|
|
@@ -77,12 +79,12 @@ export function replicationChecks(f, slot, slotState) {
|
|
|
77
79
|
const out = [];
|
|
78
80
|
out.push(f.walLevel === "logical"
|
|
79
81
|
? { name: "wal_level", ok: true, detail: "logical" }
|
|
80
|
-
: { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or
|
|
82
|
+
: { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or poll the table instead: give its entry sync: poll <interval> in the sources file (SELECTs alone, no change log)` });
|
|
81
83
|
if (f.standby) {
|
|
82
84
|
out.push(f.version >= 160000
|
|
83
85
|
? { name: "standby", ok: true, warn: !f.hotStandbyFeedback,
|
|
84
|
-
detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" :
|
|
85
|
-
: { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll
|
|
86
|
+
detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : `. hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (${SLOT_REMADE}) — turn it on`}` }
|
|
87
|
+
: { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll the table: give its entry sync: poll <interval> in the sources file` });
|
|
86
88
|
}
|
|
87
89
|
const canReplicate = f.superuser || f.replication || f.rds?.member === true;
|
|
88
90
|
out.push(canReplicate
|
|
@@ -92,7 +94,7 @@ export function replicationChecks(f, slot, slotState) {
|
|
|
92
94
|
const free = f.maxSlots - f.slotsUsed;
|
|
93
95
|
if (slotState) {
|
|
94
96
|
out.push(slotState.walStatus === "lost"
|
|
95
|
-
? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost):
|
|
97
|
+
? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): ${SLOT_REMADE}` }
|
|
96
98
|
: slotState.active
|
|
97
99
|
? { name: "slot", ok: false, detail: `${slot} is in use by another reader${slotState.activePid !== null ? ` (pid ${slotState.activePid} on the database)` : ""}: one slot has one reader. ` +
|
|
98
100
|
`Stop the other Mason for this project (mason stop). A run that crashed holds it until its connection ends${slotState.activePid !== null ? `: SELECT pg_terminate_backend(${slotState.activePid}) ends it now` : ""}` }
|
|
@@ -110,7 +112,7 @@ export function replicationChecks(f, slot, slotState) {
|
|
|
110
112
|
const size = gb === null ? "a day of this database's WAL" : `${gb}GB`;
|
|
111
113
|
const basis = gb === null ? "" : ` (a day of the WAL this server writes: ${bytesText(f.walBytesPerSecond)}/s since its statistics were reset)`;
|
|
112
114
|
out.push(f.slotWalKeepMb >= 0
|
|
113
|
-
? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres
|
|
115
|
+
? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres invalidates the slot rather than fill its disk, and ${SLOT_REMADE}${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means that catch-up` : ""}` }
|
|
114
116
|
: { name: "WAL cap", ok: false,
|
|
115
117
|
detail: `max_slot_wal_keep_size is unlimited: while Mason is stopped its slot holds every byte of WAL, until the disk fills and Postgres stops taking writes. Cap it at the outage you want Mason to survive, e.g. ${size}${basis}` +
|
|
116
118
|
(f.rds || f.cloudSql ? ` — in the ${f.rds ? "DB parameter group" : "database flags"} (max_slot_wal_keep_size, in MB)` : ""),
|