@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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A MASON PROJECT IS A FOLDER (the CLI works like docker's or supabase's: `mason init`, `mason ps`, `mason start` and
|
|
3
|
+
* `mason stop`). `mason init` makes one where it is run:
|
|
4
|
+
*
|
|
5
|
+
* <root>/.mason/config.json this machine's: the project's name and the API key (mode 0600, gitignored by init);
|
|
6
|
+
* while Mason runs, how the commands reach it
|
|
7
|
+
* <root>/.mason/ the run's state too: the copy of the data (duck.db), the users, the logs (mason destroy
|
|
8
|
+
* empties it, keeping the config and the users)
|
|
9
|
+
* <root>/<folder>/sources/ the project's files, yours to commit (`mason/` unless init's --folder says otherwise): one
|
|
10
|
+
* source file, the database and the tables it copies (src/cli/sources.ts); its URL is
|
|
11
|
+
* `${DATABASE_URL}`, from the root's .env (gitignored by init) or the shell
|
|
12
|
+
*
|
|
13
|
+
* Every command but init, doctor and help means the project of the folder it is run in: the nearest `.mason/` at or
|
|
14
|
+
* above it, as git finds `.git`. Nothing is kept in the home folder; the project folder is the only place a project
|
|
15
|
+
* lives (`--project <name>` and `mason list` found a project through its container's labels, and there is no
|
|
16
|
+
* container).
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { readSource } from "./sources.js";
|
|
22
|
+
export const STATE_DIR = ".mason";
|
|
23
|
+
const CONFIG = "config.json";
|
|
24
|
+
/** A project's name: the database name a tool connects to on the Postgres API, and (with the source's) what its
|
|
25
|
+
* replication slot is named for. */
|
|
26
|
+
export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
27
|
+
/** The copy of the data, a DuckDB file in the project's `.mason/`: kept by mason stop, deleted by mason destroy. */
|
|
28
|
+
export const DUCK_FILE = "duck.db";
|
|
29
|
+
/** A name from a folder's: lower case, the rest of it dashes. */
|
|
30
|
+
export function nameFromFolder(dir) {
|
|
31
|
+
const n = path.basename(path.resolve(dir)).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 48);
|
|
32
|
+
return NAME_RE.test(n) ? n : "mason";
|
|
33
|
+
}
|
|
34
|
+
/** What a recorded run must carry besides its pid (src/cli/dev.ts writes all of it): how the commands reach it. */
|
|
35
|
+
const RUNNING = ["controlUrl", "token", "postgresUrl"];
|
|
36
|
+
/** The fields a config must have, each a string: what every command reads. */
|
|
37
|
+
const REQUIRED = ["name", "folder", "apiKey"];
|
|
38
|
+
/** The project whose `.mason/` is `root`'s; a broken one when its config cannot be read; null when there is none. */
|
|
39
|
+
export function readProject(root) {
|
|
40
|
+
const dir = path.join(root, STATE_DIR);
|
|
41
|
+
const file = path.join(dir, CONFIG);
|
|
42
|
+
if (!fs.existsSync(file))
|
|
43
|
+
return null;
|
|
44
|
+
let config;
|
|
45
|
+
try {
|
|
46
|
+
config = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
return { root, dir, file, broken: `it is not valid JSON (${e.message})` };
|
|
50
|
+
}
|
|
51
|
+
if (typeof config !== "object" || config === null || Array.isArray(config))
|
|
52
|
+
return { root, dir, file, broken: "it is not a JSON object" };
|
|
53
|
+
const missing = REQUIRED.filter((k) => typeof config[k] !== "string");
|
|
54
|
+
if (missing.length)
|
|
55
|
+
return { root, dir, file, broken: `it has no ${missing.join(", ")} (each a string)` };
|
|
56
|
+
if (!NAME_RE.test(config.name))
|
|
57
|
+
return { root, dir, file, broken: `its name ${JSON.stringify(config.name)} is not a project name (lower case letters, digits, - and _)` };
|
|
58
|
+
// the project's port is what every tool's saved connection names: a value that is no port would make every start refuse
|
|
59
|
+
const pgPort = config.pgPort;
|
|
60
|
+
if (pgPort !== undefined && pgPort !== null && !(Number.isInteger(pgPort) && pgPort >= 1 && pgPort <= 65_535)) {
|
|
61
|
+
return { root, dir, file, broken: `its "pgPort" ${JSON.stringify(pgPort)} is not a port number (1-65535, the Postgres API's port mason start recorded; remove it and the next start chooses one)` };
|
|
62
|
+
}
|
|
63
|
+
// a run recorded without how to reach it would read as "not running" and let mason start launch a second service
|
|
64
|
+
// beside a live one (review of #434): what mason start writes, whole, or nothing
|
|
65
|
+
const r = config.running;
|
|
66
|
+
if (r !== undefined && r !== null) {
|
|
67
|
+
const run = r;
|
|
68
|
+
const bad = typeof r !== "object" || !Number.isInteger(run.pid) ? ["pid"]
|
|
69
|
+
: [...RUNNING.filter((k) => typeof run[k] !== "string"), ...(typeof run.postgresListener?.host !== "string" ? ["postgresListener"] : [])];
|
|
70
|
+
if (bad.length)
|
|
71
|
+
return { root, dir, file, broken: `its "running" has no ${bad.join(", ")} (mason start writes the whole of it, or null when stopped)` };
|
|
72
|
+
}
|
|
73
|
+
return { root, dir, folder: path.resolve(root, config.folder), config };
|
|
74
|
+
}
|
|
75
|
+
/** Whether a lookup found a project it could read. */
|
|
76
|
+
export const isProject = (p) => p !== null && !("broken" in p);
|
|
77
|
+
/** What a broken project's commands say: the file, what is wrong with it, and the two ways out. */
|
|
78
|
+
export function brokenProblem(b) {
|
|
79
|
+
return { problem: `this folder's Mason project cannot be read: ${b.file}: ${b.broken}`, code: "bad_config",
|
|
80
|
+
fix: `repair it (JSON: ${REQUIRED.join(", ")}), or move ${b.dir} aside and run mason init again — your project's files stay where they are`,
|
|
81
|
+
next: [["mason init", `once ${b.dir} is moved aside: a new config for this folder`]] };
|
|
82
|
+
}
|
|
83
|
+
/** The nearest project at or above `cwd`, as git finds a repository (a broken one stops the search: it is the nearest). */
|
|
84
|
+
export function findProject(cwd) {
|
|
85
|
+
for (let d = path.resolve(cwd);; d = path.dirname(d)) {
|
|
86
|
+
const p = readProject(d);
|
|
87
|
+
if (p)
|
|
88
|
+
return p;
|
|
89
|
+
if (path.dirname(d) === d)
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function writeConfig(dir, c) {
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
95
|
+
const file = path.join(dir, CONFIG);
|
|
96
|
+
fs.writeFileSync(file, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
|
|
97
|
+
fs.chmodSync(file, 0o600);
|
|
98
|
+
}
|
|
99
|
+
/** A new project's config: the secrets made here, once. */
|
|
100
|
+
export function newConfig(name, folder, remote) {
|
|
101
|
+
return { name, folder, remote, apiKey: randomBytes(24).toString("hex"), sourceServer: null, running: null, lastRun: null };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* THE PROJECT A COMMAND MEANS: the nearest at or above `cwd`; or the sentence that says why there is none, with the
|
|
105
|
+
* way out.
|
|
106
|
+
*/
|
|
107
|
+
export function theProject(cwd) {
|
|
108
|
+
const p = findProject(cwd);
|
|
109
|
+
if (p && "broken" in p)
|
|
110
|
+
return brokenProblem(p);
|
|
111
|
+
if (p)
|
|
112
|
+
return { project: p };
|
|
113
|
+
return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one",
|
|
114
|
+
next: [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"]] };
|
|
115
|
+
}
|
|
116
|
+
/** Whether `root`'s .gitignore already ignores `.mason/`. */
|
|
117
|
+
export function ignoresStateDir(gitignore) {
|
|
118
|
+
return gitignore.split("\n").map((l) => l.trim()).some((l) => /^\/?\.mason\/?(\*\*?)?$/.test(l));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* THE PROJECT'S SOURCE — its sources file read and its URL resolved (src/cli/sources.ts readSource) — or what is wrong,
|
|
122
|
+
* with the way out: what every command that reads the database asks (mason start, tables, doctor, destroy). `shellFirst`
|
|
123
|
+
* for a server's run (mason serve: its environment before any .env).
|
|
124
|
+
*/
|
|
125
|
+
export function projectSource(p, env = process.env, o = {}) {
|
|
126
|
+
return readSource(p.folder, p.root, env, o);
|
|
127
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
2
|
+
const firstLine = (s) => s.split("\n")[0].trim();
|
|
3
|
+
/** A span of seconds as a person reads it: `45 s`, `12 min`, `2 h 5 min`. */
|
|
4
|
+
export function duration(seconds) {
|
|
5
|
+
const s = Math.max(1, Math.round(seconds));
|
|
6
|
+
if (s < 90)
|
|
7
|
+
return `${s} s`;
|
|
8
|
+
const m = Math.round(s / 60);
|
|
9
|
+
return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 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
|
+
* first line waited on 0 for minutes, then jumped to 100%). A copy's rows
|
|
14
|
+
* 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
|
|
16
|
+
* forward), and the text says what is happening: the chunks reading and for how long, the rate, and the time left at
|
|
17
|
+
* it — never from the row estimate alone. Before anything is measured it says the first chunk is being read, never
|
|
18
|
+
* `0 of`. The share is of the heap's PAGES read (`progress.share`: the ranges are exact), and only without the chunks'
|
|
19
|
+
* progress of the table's row estimate (Postgres's own); below 100 until the copy says it is done, and NEVER BACKWARDS:
|
|
20
|
+
* a copy that outruns the estimate (23.7 M rows under an estimate of 20.1 M went back to `0% copying
|
|
21
|
+
* 21,385,158 rows`) stays at its share and says it is past the estimate. Null when nothing gives one.
|
|
22
|
+
*/
|
|
23
|
+
export function copyingText(copied, rowsEstimate, p) {
|
|
24
|
+
const shown = copied + (p?.rowsReading ?? 0);
|
|
25
|
+
const estimate = rowsEstimate !== null && rowsEstimate > 0 ? rowsEstimate : null;
|
|
26
|
+
const past = estimate !== null && shown > estimate;
|
|
27
|
+
const fraction = p !== undefined ? p.share : estimate === null ? null : Math.min(0.99, shown / estimate);
|
|
28
|
+
// past the estimate: the count, and that it is past it (the share holds where it was: pages, or capped)
|
|
29
|
+
const of = estimate === null ? "" : past ? ` · past the ~${n(estimate)} estimated` : ` of ~${n(estimate)}`;
|
|
30
|
+
const rowsText = (k) => past ? `${n(k)} rows${of}` : `${n(k)}${of} rows`;
|
|
31
|
+
if (p === undefined)
|
|
32
|
+
return { fraction, text: rowsText(copied) };
|
|
33
|
+
const since = p.readingSeconds === null ? "" : ` (${duration(p.readingSeconds)})`;
|
|
34
|
+
const chunks = (k) => `${k} chunk${k === 1 ? "" : "s"}`;
|
|
35
|
+
if (p.rowsPerSecond === null) {
|
|
36
|
+
// nothing measured this attempt: its first chunks are being read (a copy resumed has rows from before)
|
|
37
|
+
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 copy`}` : `${rowsText(copied)} · resumed, ${first}` };
|
|
39
|
+
}
|
|
40
|
+
const parts = [rowsText(shown)];
|
|
41
|
+
const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
|
|
42
|
+
if (allIn)
|
|
43
|
+
parts.push(`every chunk read (${p.chunksDone}): checking it and swapping it in`);
|
|
44
|
+
else
|
|
45
|
+
parts.push(`${p.reading === 0 ? "starting the next chunk" : `${chunks(p.reading)} reading${since}`}, ${p.chunksDone} of ~${p.chunksTotal} in`);
|
|
46
|
+
parts.push(`${n(Math.round(p.rowsPerSecond))} rows/s`);
|
|
47
|
+
if (!allIn && p.secondsLeft !== null)
|
|
48
|
+
parts.push(`~${duration(p.secondsLeft)} left`);
|
|
49
|
+
return { fraction, text: parts.join(" · ") };
|
|
50
|
+
}
|
|
51
|
+
/** A copy's progress on one line, as `mason wait` lists what it waits for: `public.orders: copying 12% · …` (`copyingText`). */
|
|
52
|
+
export function copyProgress(source, copied, rowsEstimate, p) {
|
|
53
|
+
const c = copyingText(copied, rowsEstimate, p);
|
|
54
|
+
return `${source}: ${c.fraction !== null ? `copying ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "copying " : ""}${c.text}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* WHERE A RUN IS. Ready when every added table's copy has ended well: `mason add` followed by `mason wait` returns
|
|
58
|
+
* once the table is served. A copy that failed is a failure (the sync tries again by itself, and says so in the log),
|
|
59
|
+
* so an agent is never left waiting for a copy that cannot finish.
|
|
60
|
+
*/
|
|
61
|
+
export function readiness(s) {
|
|
62
|
+
const failed = [], waiting = [];
|
|
63
|
+
for (const t of s.tables.filter((t) => t.added)) {
|
|
64
|
+
if (t.error) {
|
|
65
|
+
failed.push(`${t.table}: ${firstLine(t.error)}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (t.copy?.phase === "copying") {
|
|
69
|
+
waiting.push(copyProgress(t.table, t.copy.rowsCopied, t.rows, t.copy.progress));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!t.copy)
|
|
73
|
+
waiting.push(`${t.table}: waiting for its copy to start`);
|
|
74
|
+
}
|
|
75
|
+
return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
|
|
76
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY `mason` COMMAND, AND THE HELP MADE FROM THEM. `mason --help` is generated from this list — each
|
|
3
|
+
* command's summary, in this order — so a command cannot exist without being listed, and tests/cli-help.test.ts holds
|
|
4
|
+
* each one to src/cli/command.ts's rules (its own --help, an example, every option documented).
|
|
5
|
+
*/
|
|
6
|
+
import { failed, parseCommand, reportText } from "./command.js";
|
|
7
|
+
import { dev } from "./dev.js";
|
|
8
|
+
import { devInBackground } from "./background.js";
|
|
9
|
+
import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, status, stop, tables, wait } from "./commands.js";
|
|
10
|
+
import { USER, user } from "./user.js";
|
|
11
|
+
import { UI, ui } from "./ui.js";
|
|
12
|
+
import { GUIDES, guideText } from "./guides.js";
|
|
13
|
+
import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
|
|
14
|
+
import { DESTROY, destroy } from "./machine.js";
|
|
15
|
+
import { INIT, init } from "./init.js";
|
|
16
|
+
import { SYNC, sync } from "./sync.js";
|
|
17
|
+
import { SERVE, serve } from "./serve.js";
|
|
18
|
+
import { PROJECT_LINE } from "./dev.js";
|
|
19
|
+
import { version } from "./version.js";
|
|
20
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
21
|
+
import { out as styleOut, wrap } from "./style.js";
|
|
22
|
+
import { theProject } from "./project.js";
|
|
23
|
+
import { liveRun } from "./runs.js";
|
|
24
|
+
export { version } from "./version.js";
|
|
25
|
+
export const DEV = {
|
|
26
|
+
needs: "project",
|
|
27
|
+
name: "start",
|
|
28
|
+
summary: "start Mason for this folder's project, in the background (mason init makes the project)",
|
|
29
|
+
usage: `usage: mason start [--attach] [--replication [--publication <name>] | --poll] [--self-signed-tls] [--port <n>] [--json]
|
|
30
|
+
|
|
31
|
+
Starts Mason for the project of this folder (mason init made it): one process, which copies the tables you add
|
|
32
|
+
(mason add) from the project's database into its own file, keeps them current, and answers the Postgres protocol
|
|
33
|
+
for BI tools and your app. It only reads the database (SELECT): no wal_level, replication slot or publication is
|
|
34
|
+
needed or made. It returns once Mason is up; run it again any time (it says so when Mason is already running). The
|
|
35
|
+
copy and Mason's state stay between runs: the next start resumes.
|
|
36
|
+
|
|
37
|
+
With --replication it reads the database's change log instead: it needs wal_level = logical and a user that may
|
|
38
|
+
replicate, and makes its own publication and replication slot on the database (mason destroy, or mason start --poll,
|
|
39
|
+
drops them). The project keeps the mode until --poll; a switch keeps the copies and catches each table up by one
|
|
40
|
+
poll. --publication <name> (else PG_PUBLICATION) names a publication that is the database owner's: it is read as it
|
|
41
|
+
is, verified to carry every added table and column, and never created, altered or dropped — Mason makes only its
|
|
42
|
+
slot. A read replica needs one (it takes no CREATE PUBLICATION). The name is kept with the project, so the next
|
|
43
|
+
mason start --replication reads it again.
|
|
44
|
+
|
|
45
|
+
The Postgres API's port is the project's: the first start takes 5433, or the next port free on every address when a
|
|
46
|
+
second Postgres holds it, and records it; every later start listens on the same port, so a BI tool's saved connection
|
|
47
|
+
keeps working, and refuses, naming what holds it, rather than move. MASON_PG_PORT=<n> in your shell names the port,
|
|
48
|
+
or moves the project to it (said): that port is used, or start refuses, naming what holds it.
|
|
49
|
+
|
|
50
|
+
The Postgres API listens on 127.0.0.1 unless MASON_PG_HOST names another address (0.0.0.0: every address of this
|
|
51
|
+
machine). Off this machine a password travels on the connection, so it then needs TLS: MASON_PG_TLS_CERT and
|
|
52
|
+
MASON_PG_TLS_KEY (PEM files), or --self-signed-tls, which makes a certificate for the project in .mason/ and serves
|
|
53
|
+
it again at every later start with the flag. Every client then connects with sslmode=require. A team whose own proxy
|
|
54
|
+
or load balancer terminates TLS keeps it on 127.0.0.1, or sets MASON_PG_PLAIN=1 for a private network.
|
|
55
|
+
|
|
56
|
+
${PROJECT_LINE}
|
|
57
|
+
|
|
58
|
+
mason start
|
|
59
|
+
mason start --attach in the foreground: Ctrl-C stops it
|
|
60
|
+
mason start --replication read the change log, as production does
|
|
61
|
+
mason start --replication --publication app_pub through the database owner's publication
|
|
62
|
+
MASON_PG_HOST=0.0.0.0 mason start --self-signed-tls the Postgres API for other machines, over TLS
|
|
63
|
+
|
|
64
|
+
--attach stay in the foreground (Ctrl-C stops Mason)
|
|
65
|
+
--replication read the database's changes from its change log (logical replication), as production does
|
|
66
|
+
--publication <name> with --replication: the publication to read, the owner's, never altered (PG_PUBLICATION;
|
|
67
|
+
default: Mason's own, mason_dev_<project>_<source>)
|
|
68
|
+
--poll read them with SELECTs (the default; takes back --replication)
|
|
69
|
+
--self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
|
|
70
|
+
served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
|
|
71
|
+
--port <n> the port the mason commands reach it on (default 4039, or the next free one)
|
|
72
|
+
--json as data, once it is up: { running, pid, postgres: { url, host, port, database, sslmode }, next }
|
|
73
|
+
`,
|
|
74
|
+
options: {
|
|
75
|
+
port: { type: "string" }, attach: { type: "boolean", default: false }, json: { type: "boolean", default: false },
|
|
76
|
+
replication: { type: "boolean", default: false }, poll: { type: "boolean", default: false }, publication: { type: "string" },
|
|
77
|
+
"self-signed-tls": { type: "boolean", default: false },
|
|
78
|
+
},
|
|
79
|
+
json: ["running", "pid", "postgres", "next"],
|
|
80
|
+
};
|
|
81
|
+
async function runDev(argv) {
|
|
82
|
+
const f = parseCommand(DEV, argv);
|
|
83
|
+
if (typeof f === "number")
|
|
84
|
+
return f;
|
|
85
|
+
const v = f.values;
|
|
86
|
+
if (v.replication && v.poll)
|
|
87
|
+
return failed("start", { message: "--replication or --poll, not both", code: "usage", usage: DEV.usage }, { json: v.json });
|
|
88
|
+
if (v.publication !== undefined && v.poll)
|
|
89
|
+
return failed("start", { message: "--publication goes with --replication, not --poll", code: "usage", usage: DEV.usage }, { json: v.json });
|
|
90
|
+
if (v.publication !== undefined && !/^\S+$/.test(v.publication))
|
|
91
|
+
return failed("start", { message: `--publication must name a publication, not ${JSON.stringify(v.publication)}`, code: "usage", usage: DEV.usage }, { json: v.json });
|
|
92
|
+
if (f.positionals.length) {
|
|
93
|
+
// the database is the project's now: a URL here is the old habit, answered with the new way
|
|
94
|
+
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 });
|
|
95
|
+
}
|
|
96
|
+
const port = v.port === undefined ? 4039 : Number(v.port);
|
|
97
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
98
|
+
return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
|
|
99
|
+
if (!v.attach)
|
|
100
|
+
return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"] });
|
|
101
|
+
return dev({ port, version: version(), changes: v.replication ? "replication" : v.poll ? "poll" : null, publication: v.publication ?? null, selfSignedTls: v["self-signed-tls"] });
|
|
102
|
+
}
|
|
103
|
+
export const HELP = {
|
|
104
|
+
name: "help",
|
|
105
|
+
summary: "the commands by step, or one command's usage: mason help add",
|
|
106
|
+
usage: `usage: mason help [<command> | <guide>]
|
|
107
|
+
|
|
108
|
+
The commands by step (mason help), how to use one (mason help add — the same as mason add --help), or a guide,
|
|
109
|
+
offline, short enough to read whole and listing exactly what Mason accepts:
|
|
110
|
+
|
|
111
|
+
mason help
|
|
112
|
+
mason help add
|
|
113
|
+
${Object.entries(GUIDES).map(([k, v]) => ` mason help ${k.padEnd(14)} ${v}`).join("\n")}
|
|
114
|
+
`,
|
|
115
|
+
options: {},
|
|
116
|
+
};
|
|
117
|
+
async function runHelp(argv) {
|
|
118
|
+
const f = parseCommand(HELP, argv);
|
|
119
|
+
if (typeof f === "number")
|
|
120
|
+
return f;
|
|
121
|
+
const [asked, ...rest] = f.positionals;
|
|
122
|
+
if (rest.length)
|
|
123
|
+
return failed("help", { message: "one command or guide at a time", code: "usage", usage: HELP.usage }, { json: false });
|
|
124
|
+
if (asked === undefined) {
|
|
125
|
+
process.stdout.write(topUsage());
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
if (asked === "version") {
|
|
129
|
+
process.stdout.write("usage: mason version (or --version, -v): the CLI's version\n");
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
if (Object.hasOwn(GUIDES, asked)) {
|
|
133
|
+
process.stdout.write(await guideText(asked));
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
const cmd = COMMANDS.find((c) => c.name === asked);
|
|
137
|
+
if (cmd) {
|
|
138
|
+
process.stdout.write(cmd.usage);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
return failed("help", { message: `there is no command or guide ${JSON.stringify(asked)}${didYouMean(asked, [...COMMANDS.map((c) => c.name), ...Object.keys(GUIDES)])}`, code: "usage",
|
|
142
|
+
why: `the guides: ${Object.keys(GUIDES).join(", ")}`, next: [["mason help", "every command"]] }, { json: false });
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* EVERY COMMAND, IN THE ORDER A FIRST SESSION NEEDS THEM (listed in the order a developer will likely need them, the
|
|
146
|
+
* reference, `mason help`, first because it is read first), under the step each belongs to: `mason --help` prints the
|
|
147
|
+
* steps as headings, so the list reads as the runbook it is. A new command goes into its step here; COMMANDS is
|
|
148
|
+
* derived, so the order is written once.
|
|
149
|
+
*/
|
|
150
|
+
export const STEPS = [
|
|
151
|
+
["Learn", [{ ...HELP, run: runHelp }]],
|
|
152
|
+
["Set up", [{ ...DOCTOR, run: (argv) => doctor(argv) }, { ...INIT, run: (argv) => init(argv) }, { ...DEV, run: runDev }]],
|
|
153
|
+
["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
|
|
154
|
+
["Connect", [{ ...USER, run: user }]],
|
|
155
|
+
["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
|
|
156
|
+
["Go to production", [{ ...SERVE, run: serve }]],
|
|
157
|
+
];
|
|
158
|
+
/** Every command, in the order `mason --help` lists them. */
|
|
159
|
+
export const COMMANDS = STEPS.flatMap(([, commands]) => commands);
|
|
160
|
+
/**
|
|
161
|
+
* `mason` ALONE: where you are and what to run next (`mason help` once showed the same as `mason` alone, and read as
|
|
162
|
+
* broken). One line on what Mason is, whether this folder is a project and whether Mason runs for
|
|
163
|
+
* it, then the next command for that moment — and mason help, every command. `mason help` and `mason --help` are the
|
|
164
|
+
* whole list (topUsage).
|
|
165
|
+
*/
|
|
166
|
+
export async function orientation(cwd, s = styleOut()) {
|
|
167
|
+
const t = theProject(cwd);
|
|
168
|
+
const project = "project" in t ? t.project : null;
|
|
169
|
+
const run = project ? await liveRun(project) : null;
|
|
170
|
+
const rows = project
|
|
171
|
+
? [["Project", `${project.config.name}, files in ${project.folder}`], ["Mason", run ? `running (pid ${run.pid})` : "not running"]]
|
|
172
|
+
: [["Project", "none here: mason init makes this folder one"]];
|
|
173
|
+
const next = !project
|
|
174
|
+
? [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"], ["mason doctor", "what this machine needs for Mason, and whether it has it"]]
|
|
175
|
+
: !run ? [["mason start", "start Mason for this project, in the background"], ["mason status", "the project and its tables"]]
|
|
176
|
+
: [["mason status", "what Mason is doing: its tables, and the last day's slow and refused statements"], ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
|
|
177
|
+
return `${reportText({ headline: "Mason: your Postgres tables, copied and served to BI tools and your app.", details: rows,
|
|
178
|
+
next: [...next, ["mason help", "every command, and the guides"]] }, s)}\n`;
|
|
179
|
+
}
|
|
180
|
+
/** `mason --help`: what Mason is, every command with its summary, and where each one's help is. On a terminal the steps are
|
|
181
|
+
* bold, the commands cyan and every line wrapped at its width under its own column (src/cli/style.ts); anywhere else,
|
|
182
|
+
* plain and unwrapped. */
|
|
183
|
+
export function topUsage(s = styleOut()) {
|
|
184
|
+
// what each step lists: its commands, and under Learn the guides too (`mason help` shows the guides listed under
|
|
185
|
+
// Learn), so the pages a session reads first are a line each, not a paragraph after the list
|
|
186
|
+
const entries = (step, commands) => [
|
|
187
|
+
...commands.map((c) => [c.name, c.summary]),
|
|
188
|
+
...(step === "Learn" ? Object.entries(GUIDES).map(([g, what]) => [`help ${g}`, what]) : []),
|
|
189
|
+
];
|
|
190
|
+
const listed = STEPS.map(([step, commands]) => [step, entries(step, commands)]);
|
|
191
|
+
// the commands' column is the longest COMMAND's, everywhere; a guide's longer name widens Learn alone, so it never
|
|
192
|
+
// pushes every other step's summaries right
|
|
193
|
+
const commandWidth = Math.max(...COMMANDS.map((c) => c.name.length));
|
|
194
|
+
const para = (text) => ` ${wrap(text, s.width, 2)}`;
|
|
195
|
+
const block = (es) => {
|
|
196
|
+
const w = Math.max(commandWidth, ...es.map(([name]) => name.length));
|
|
197
|
+
// the column a summary starts in: " mason " + the longest name + 3
|
|
198
|
+
const col = 4 + "mason ".length + w + 3;
|
|
199
|
+
return es.map(([name, summary]) => ` ${s.cyan(`mason ${name.padEnd(w)}`)} ${s.dim(wrap(summary, s.width, col))}`).join("\n");
|
|
200
|
+
};
|
|
201
|
+
return [
|
|
202
|
+
`${s.bold("usage:")} mason <command> [options]`,
|
|
203
|
+
"",
|
|
204
|
+
para("Mason: your Postgres tables, copied and served to BI tools and your app. Make your app's folder a project (mason init), " +
|
|
205
|
+
`start Mason (mason start), add tables, connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
|
|
206
|
+
para(PROJECT_LINE),
|
|
207
|
+
"",
|
|
208
|
+
listed.map(([step, es]) => ` ${s.bold(step)}\n${block(es)}`).join("\n\n"),
|
|
209
|
+
"",
|
|
210
|
+
para("mason <command> --help (or mason help <command>) says how to use one, with examples; the guides are offline and short enough to read whole."),
|
|
211
|
+
para("A command means the project of the folder it runs in (or above it)."),
|
|
212
|
+
para("Every command but help takes --json: one JSON object for a program, always with next (what to run " +
|
|
213
|
+
"next); a failure is { error: { code, message, why, fix }, next }. The exit code says how it went: 0 done, 1 failed " +
|
|
214
|
+
"with the reason, 2 a wrong command line, 124 mason wait timed out. Driving Mason from a coding agent: mason help agent."),
|
|
215
|
+
"",
|
|
216
|
+
].join("\n");
|
|
217
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Everything the checks read of the server, in one statement: settings and roles only. */
|
|
2
|
+
export const SERVER_FACTS_SQL = `SELECT json_build_object(
|
|
3
|
+
'version', current_setting('server_version_num')::int, 'version_text', current_setting('server_version'),
|
|
4
|
+
'standby', pg_is_in_recovery(), 'user', current_user,
|
|
5
|
+
'wal_level', current_setting('wal_level'),
|
|
6
|
+
'slot_wal_keep_mb', (SELECT CASE WHEN setting = '-1' THEN -1 ELSE setting::bigint END FROM pg_settings WHERE name = 'max_slot_wal_keep_size'),
|
|
7
|
+
'max_slots', current_setting('max_replication_slots')::int, 'slots_used', (SELECT count(*) FROM pg_replication_slots),
|
|
8
|
+
'max_senders', current_setting('max_wal_senders')::int, 'senders_used', (SELECT count(*) FROM pg_stat_replication),
|
|
9
|
+
'superuser', r.rolsuper, 'replication', r.rolreplication,
|
|
10
|
+
'rds', CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rds_replication')
|
|
11
|
+
THEN json_build_object('member', pg_has_role(current_user, 'rds_replication', 'member')) END,
|
|
12
|
+
'cloud_sql', current_setting('cloudsql.logical_decoding', true) IS NOT NULL,
|
|
13
|
+
'hot_standby_feedback', current_setting('hot_standby_feedback') = 'on',
|
|
14
|
+
'idle_in_transaction_ms', (SELECT setting::bigint FROM pg_settings WHERE name = 'idle_in_transaction_session_timeout'),
|
|
15
|
+
'statement_timeout_ms', (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout')
|
|
16
|
+
)::text FROM pg_roles r WHERE r.rolname = current_user`;
|
|
17
|
+
/** pg_stat_wal (Postgres 14+): the WAL written since the statistics were reset, and over how long. */
|
|
18
|
+
export const WAL_RATE_SQL = "SELECT wal_bytes::text, extract(epoch FROM now() - stats_reset)::text FROM pg_stat_wal";
|
|
19
|
+
export async function readServerFacts(pg) {
|
|
20
|
+
const j = JSON.parse((await pg.query(SERVER_FACTS_SQL)).rows[0]?.[0] ?? "null");
|
|
21
|
+
if (j === null)
|
|
22
|
+
throw new Error("the server's settings did not read (no pg_roles row for this user)");
|
|
23
|
+
let walBytesPerSecond = null;
|
|
24
|
+
if (Number(j.version) >= 140000 && j.standby !== true) {
|
|
25
|
+
const r = (await pg.query(WAL_RATE_SQL)).rows[0];
|
|
26
|
+
const bytes = Number(r?.[0]), seconds = Number(r?.[1]);
|
|
27
|
+
// a reset moments ago says nothing about the rate: an hour of statistics at least
|
|
28
|
+
if (Number.isFinite(bytes) && Number.isFinite(seconds) && seconds >= 3600)
|
|
29
|
+
walBytesPerSecond = bytes / seconds;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
version: Number(j.version), versionText: String(j.version_text), standby: j.standby === true, user: String(j.user),
|
|
33
|
+
walLevel: String(j.wal_level), slotWalKeepMb: Number(j.slot_wal_keep_mb),
|
|
34
|
+
maxSlots: Number(j.max_slots), slotsUsed: Number(j.slots_used), maxSenders: Number(j.max_senders), sendersUsed: Number(j.senders_used),
|
|
35
|
+
superuser: j.superuser === true, replication: j.replication === true,
|
|
36
|
+
rds: j.rds === null || j.rds === undefined ? null : { member: j.rds.member === true },
|
|
37
|
+
cloudSql: j.cloud_sql === true, hotStandbyFeedback: j.hot_standby_feedback === true,
|
|
38
|
+
idleInTransactionMs: Number(j.idle_in_transaction_ms), statementTimeoutMs: Number(j.statement_timeout_ms), walBytesPerSecond,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The slot Mason would read, if it exists: whether a reader holds it and whether Postgres invalidated it. */
|
|
42
|
+
export async function readSlotFacts(pg, slot) {
|
|
43
|
+
const r = (await pg.query(`SELECT active::text, active_pid::text, wal_status, plugin FROM pg_replication_slots WHERE slot_name = '${slot.replace(/'/g, "''")}'`)).rows[0];
|
|
44
|
+
return r ? { active: r[0] === "true", activePid: r[1] ? Number(r[1]) : null, walStatus: r[2] ?? null, plugin: r[3] ?? null } : null;
|
|
45
|
+
}
|
|
46
|
+
/** The way out of a slot another reader holds, in the words of the command that met it. */
|
|
47
|
+
/** Bytes as a person reads them: 1.4 GiB, 312 MiB, 18 KiB. */
|
|
48
|
+
export function bytesText(n) {
|
|
49
|
+
const units = ["bytes", "KiB", "MiB", "GiB", "TiB"];
|
|
50
|
+
let i = 0, v = n;
|
|
51
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
52
|
+
v /= 1024;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
return i === 0 ? `${n} bytes` : `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
|
56
|
+
}
|
|
57
|
+
const q = (s) => /^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`;
|
|
58
|
+
/**
|
|
59
|
+
* A `max_slot_wal_keep_size` to suggest: a day of this server's WAL at its measured rate, rounded up to a whole GB, at
|
|
60
|
+
* least 4 GB; null when the rate is unknown (a standby, Postgres 13, statistics reset within the hour).
|
|
61
|
+
*/
|
|
62
|
+
export function suggestedWalCapGb(bytesPerSecond) {
|
|
63
|
+
if (bytesPerSecond === null)
|
|
64
|
+
return null;
|
|
65
|
+
return Math.max(4, Math.ceil((bytesPerSecond * 86_400) / 1024 ** 3));
|
|
66
|
+
}
|
|
67
|
+
/** What wal_level = logical takes on this server: its provider's way, or postgresql.conf's. */
|
|
68
|
+
function walLevelFix(f) {
|
|
69
|
+
if (f.rds)
|
|
70
|
+
return "set rds.logical_replication = 1 in the DB parameter group (Aurora: the DB cluster parameter group), then reboot the instance";
|
|
71
|
+
if (f.cloudSql)
|
|
72
|
+
return "set the database flag cloudsql.logical_decoding = on (the instance restarts)";
|
|
73
|
+
return "set wal_level = logical (ALTER SYSTEM SET wal_level = logical, or postgresql.conf; a managed Postgres: its parameter or flag of that name), then restart Postgres";
|
|
74
|
+
}
|
|
75
|
+
/** The checks of the server for the stream (REPLICATION). */
|
|
76
|
+
export function replicationChecks(f, slot, slotState) {
|
|
77
|
+
const out = [];
|
|
78
|
+
out.push(f.walLevel === "logical"
|
|
79
|
+
? { 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 read changes by polling instead (--poll: SELECT alone, for a small database)` });
|
|
81
|
+
if (f.standby) {
|
|
82
|
+
out.push(f.version >= 160000
|
|
83
|
+
? { name: "standby", ok: true, warn: !f.hotStandbyFeedback,
|
|
84
|
+
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 (Mason then rebuilds from scratch) — turn it on"}` }
|
|
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 it (--poll)` });
|
|
86
|
+
}
|
|
87
|
+
const canReplicate = f.superuser || f.replication || f.rds?.member === true;
|
|
88
|
+
out.push(canReplicate
|
|
89
|
+
? { name: "replication role", ok: true, detail: `${f.user} may replicate${f.superuser ? " (a superuser)" : f.rds?.member ? " (rds_replication)" : " (REPLICATION)"}` }
|
|
90
|
+
: { name: "replication role", ok: false, detail: `${f.user} may not open a replication connection`,
|
|
91
|
+
sql: f.rds ? `GRANT rds_replication TO ${q(f.user)};` : `ALTER ROLE ${q(f.user)} WITH REPLICATION;` });
|
|
92
|
+
const free = f.maxSlots - f.slotsUsed;
|
|
93
|
+
if (slotState) {
|
|
94
|
+
out.push(slotState.walStatus === "lost"
|
|
95
|
+
? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): Mason drops it and rebuilds from scratch at its start` }
|
|
96
|
+
: slotState.active
|
|
97
|
+
? { 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
|
+
`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` : ""}` }
|
|
99
|
+
: { name: "slot", ok: true, detail: `${slot} exists (${slotState.plugin ?? "?"}, ${slotState.walStatus ?? "?"}): Mason resumes from it` });
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
out.push(free >= 1
|
|
103
|
+
? { name: "slot", ok: true, detail: `${slot} is made at Mason's first start (${f.slotsUsed} of max_replication_slots ${f.maxSlots} in use)` }
|
|
104
|
+
: { name: "slot", ok: false, detail: `every replication slot is taken (${f.slotsUsed} of max_replication_slots ${f.maxSlots}): raise max_replication_slots (a restart), or drop a slot nobody reads` });
|
|
105
|
+
}
|
|
106
|
+
out.push(f.maxSenders - f.sendersUsed >= 1
|
|
107
|
+
? { name: "wal senders", ok: true, detail: `${f.sendersUsed} of max_wal_senders ${f.maxSenders} in use` }
|
|
108
|
+
: { name: "wal senders", ok: false, detail: `every WAL sender is in use (${f.sendersUsed} of max_wal_senders ${f.maxSenders}): raise max_wal_senders (a restart)` });
|
|
109
|
+
const gb = suggestedWalCapGb(f.walBytesPerSecond);
|
|
110
|
+
const size = gb === null ? "a day of this database's WAL" : `${gb}GB`;
|
|
111
|
+
const basis = gb === null ? "" : ` (a day of the WAL this server writes: ${bytesText(f.walBytesPerSecond)}/s since its statistics were reset)`;
|
|
112
|
+
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 drops the slot rather than fill its disk, and Mason rebuilds from scratch${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 a rebuild` : ""}` }
|
|
114
|
+
: { name: "WAL cap", ok: false,
|
|
115
|
+
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
|
+
(f.rds || f.cloudSql ? ` — in the ${f.rds ? "DB parameter group" : "database flags"} (max_slot_wal_keep_size, in MB)` : ""),
|
|
117
|
+
...(f.rds || f.cloudSql ? {} : { sql: `ALTER SYSTEM SET max_slot_wal_keep_size = '${gb ?? 4}GB';\nSELECT pg_reload_conf();` }) });
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** What polling costs this database: a scan of every replicated table at each poll, and the delete check's keys. */
|