@mason-data/cli 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +268 -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 +477 -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 +1771 -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 +588 -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 +198 -0
- package/dist/src/cli/command.js +173 -0
- package/dist/src/cli/commands.js +1277 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +669 -0
- package/dist/src/cli/doctor.js +260 -0
- package/dist/src/cli/guides.js +158 -0
- package/dist/src/cli/init.js +366 -0
- package/dist/src/cli/machine.js +162 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/mcp.js +726 -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 +347 -0
- package/dist/src/cli/project.js +129 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +220 -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 +311 -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 +301 -0
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +564 -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/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +268 -0
- package/dist/src/duck/executor.js +567 -0
- package/dist/src/duck/explain.js +238 -0
- package/dist/src/duck/functions.js +819 -0
- package/dist/src/duck/gaps.js +94 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +188 -0
- package/dist/src/duck/kept-route.js +299 -0
- package/dist/src/duck/kept.js +964 -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 +378 -0
- package/dist/src/duck/plan.js +259 -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 +5101 -0
- package/dist/src/duck/store.js +907 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2929 -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 +1001 -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 +237 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +76 -0
- package/dist/src/web/app.js +491 -0
- package/dist/src/web/index.html +44 -0
- package/package.json +22 -4
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason start` (one run per project): Mason for the project of this folder
|
|
3
|
+
* (src/cli/project.ts), on the database its sources file names (src/cli/sources.ts), copying the tables it names.
|
|
4
|
+
*
|
|
5
|
+
* 0. A DEMO PROJECT'S DATABASE (`mason init demo`, src/cli/demo.ts): hosted by this process, opened first on the port
|
|
6
|
+
* the sources file's URL names, then connected to and read as any database is (by polling: it has no change log),
|
|
7
|
+
* and changed every few seconds so the copy is seen keeping current.
|
|
8
|
+
* 1. THE DATABASE (src/cli/preflight.ts checkSource): connect as the run will, say what to change when it cannot;
|
|
9
|
+
* a database on another machine only when the project was made with --remote. A database restored to another server
|
|
10
|
+
* is read again (its rows compared, the unchanged kept); another database is refused (mason destroy, then mason start).
|
|
11
|
+
* 2. THE COPY (src/duck/store.ts, src/duck/sync.ts): DuckDB in this process, file-backed in `.mason/duck.db`; each
|
|
12
|
+
* added table copied whole from the database and kept current by the xmin poll, or by logical replication
|
|
13
|
+
* (--replication, src/duck/stream.ts).
|
|
14
|
+
* 3. THE COMMAND SERVER (src/cli/run-server.ts) on 127.0.0.1, which `mason tables`, `mason add`, `mason status` …
|
|
15
|
+
* ask with the run's token from the project's config (src/cli/control.ts, src/cli/security.ts).
|
|
16
|
+
* 4. THE POSTGRES LISTENER (src/api/postgres/server.ts): a BI tool or an app connects as to a read replica, as a
|
|
17
|
+
* `mason user`, and every statement is answered from the copy.
|
|
18
|
+
* 5. `mason stop` (or Ctrl-C under --attach) stops it all. Nothing was created on the database (polling), so nothing
|
|
19
|
+
* is left there; the next `mason start` resumes from the file.
|
|
20
|
+
*
|
|
21
|
+
* IN THE BACKGROUND (as good for people as for coding agents): `mason start` starts the run
|
|
22
|
+
* as a process of its own, shows its start, and RETURNS once it answers — a coding agent cannot wait on a command that
|
|
23
|
+
* never ends. The run's terminal is its log (`mason logs`). `--attach` runs it in the foreground, as before.
|
|
24
|
+
*
|
|
25
|
+
* What a run keeps between runs (the API key, the copy, the users, the logs) is in the project's `.mason/`.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { choosePgPort, freePort } from "./ports.js";
|
|
30
|
+
import { failed } from "./command.js";
|
|
31
|
+
import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
|
|
32
|
+
import { createRunServer } from "./run-server.js";
|
|
33
|
+
import { addedTables, rereadSources } from "./sources.js";
|
|
34
|
+
import { newToken } from "./security.js";
|
|
35
|
+
import { liveRun } from "./runs.js";
|
|
36
|
+
import { DEMO_DIR, DUCK_FILE, projectSource, theProject, writeConfig } from "./project.js";
|
|
37
|
+
import { closedWithin, closingOnce, DEMO_STILL_CLOSING, demoChanges, demoChangesRefusal, demoPort, demoSettings, openDemo } from "./demo.js";
|
|
38
|
+
import { postgresApiUrl, readPgTls } from "../api/postgres/listener.js";
|
|
39
|
+
import { certificateFingerprint, elsewhereTip, ensureSelfSigned, listenerText, runListenerSettings, SELF_SIGNED_FILES } from "./pg-tls.js";
|
|
40
|
+
import { startPostgresApi, timeoutMs } from "../api/postgres/server.js";
|
|
41
|
+
import { RequestLedger, REQUESTS_TABLE, requestLogDays } from "../duck/requests.js";
|
|
42
|
+
import { withPlaceholders } from "./pg-url.js";
|
|
43
|
+
import { cliPinProblem, ensurePin } from "./pin.js";
|
|
44
|
+
import { DEFAULT_MEMORY_LIMIT, DuckStore, DUCKDB_VERSION } from "../duck/store.js";
|
|
45
|
+
import { copyChunkSecondsOf, copyParallelOf, Sync, syncModeOf } from "../duck/sync.js";
|
|
46
|
+
import { storeJsonPathsOf } from "../duck/kept.js";
|
|
47
|
+
import { readLibcOrder } from "../duck/libc-order.js";
|
|
48
|
+
import { isLibcCollation } from "../duck/rewrite.js";
|
|
49
|
+
import { listenerLogins, LiveUsers, USERS_FILE } from "../duck/users.js";
|
|
50
|
+
import { READY_LINE } from "./supervise.js";
|
|
51
|
+
import { changesOf, dropOwnReplication, dropStatements, madeReplication, namedPublication, projectReplication, replicationReadiness } from "./replication-mode.js";
|
|
52
|
+
import { textEntry } from "../lib/log.js";
|
|
53
|
+
const say = (m) => { process.stdout.write(`${m}\n`); };
|
|
54
|
+
/**
|
|
55
|
+
* THE RUN'S STEPS, as lines (an opening block of prose was too much to read, as `mason init`'s had been): a step begun,
|
|
56
|
+
* done or failed, each a line its terminal shows as it is and its relay (src/cli/background.ts) draws as a checklist —
|
|
57
|
+
* a spinner while one runs, ✔ or ✘ when it ends.
|
|
58
|
+
*/
|
|
59
|
+
export const STEP_MARK = { begin: "▸ ", done: "✓ ", fail: "✗ " };
|
|
60
|
+
const begin = (label) => { say(`${STEP_MARK.begin}${label}`); };
|
|
61
|
+
const done = (text) => { say(`${STEP_MARK.done}${text}`); };
|
|
62
|
+
const fail = (text) => { say(`${STEP_MARK.fail}${text}`); };
|
|
63
|
+
/**
|
|
64
|
+
* WHAT THE SERVER UNDER THE DATABASE SAYS about the copy this project holds (a database moved to a different host is
|
|
65
|
+
* still the same database): the same database on a new server is read again — the poll's watermarks
|
|
66
|
+
* are the old server's transaction ids, which mean nothing there — and another database is refused, because its rows
|
|
67
|
+
* are not the copy's. `recorded` is what the first start saw; null records it now.
|
|
68
|
+
*/
|
|
69
|
+
export function sourceMove(recorded, now) {
|
|
70
|
+
if (!recorded)
|
|
71
|
+
return { action: "record" };
|
|
72
|
+
if (recorded.database !== now.database) {
|
|
73
|
+
return { refuse: `this project's copy is of the database ${recorded.database}, and .mason/config.json now names ${now.database}. ` +
|
|
74
|
+
"Its copied rows are not that database's: mason destroy (removes the copy; your files stay), then mason start copies it" };
|
|
75
|
+
}
|
|
76
|
+
if (recorded.systemIdentifier !== null && now.systemIdentifier !== null && recorded.systemIdentifier !== now.systemIdentifier)
|
|
77
|
+
return { action: "reread" };
|
|
78
|
+
return { action: "same" };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The run, and whatever it hosts closed however it ends: a demo database that was opened is closed at the stop (after
|
|
82
|
+
* the sync, before the copy), and by this on every other way out — a failed step, a crash — its data flushed to its
|
|
83
|
+
* folder, a close that fails said.
|
|
84
|
+
*/
|
|
85
|
+
export async function dev(o) {
|
|
86
|
+
const hosted = { stopChanges: null, close: null };
|
|
87
|
+
try {
|
|
88
|
+
return await run(o, hosted);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
hosted.stopChanges?.();
|
|
92
|
+
await closeDemo(hosted, null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The demo database closed, if it is open: a failure said, never swallowed; `closed`, when given, said once it closed. */
|
|
96
|
+
async function closeDemo(hosted, closed) {
|
|
97
|
+
const close = hosted.close;
|
|
98
|
+
if (close === null)
|
|
99
|
+
return;
|
|
100
|
+
hosted.close = null;
|
|
101
|
+
const failure = await close();
|
|
102
|
+
if (failure !== null)
|
|
103
|
+
say(` closing the demo database: ${failure}`);
|
|
104
|
+
else if (closed !== null)
|
|
105
|
+
say(` ${closed}`);
|
|
106
|
+
}
|
|
107
|
+
async function run(o, hosted) {
|
|
108
|
+
const t = theProject(process.cwd());
|
|
109
|
+
if ("problem" in t) {
|
|
110
|
+
say(`mason start: ${t.problem}`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
const project = t.project;
|
|
114
|
+
const cfg = project.config;
|
|
115
|
+
const home = project.dir;
|
|
116
|
+
const folder = project.folder;
|
|
117
|
+
// ONE mason start per project: a second would open the same DuckDB file, which DuckDB refuses to a second writer
|
|
118
|
+
const other = await liveRun(project);
|
|
119
|
+
if (other !== null) {
|
|
120
|
+
say(`mason start: Mason is already running for ${cfg.name} (pid ${other.pid}): mason status says what it is doing`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
// A DEMO PROJECT IS POLLED (src/cli/demo.ts): its database has no change log, so replication is refused before anything starts
|
|
124
|
+
const demoRefused = demoChangesRefusal(cfg, o.changes);
|
|
125
|
+
if (demoRefused)
|
|
126
|
+
return failed("start", demoRefused, { json: false });
|
|
127
|
+
let demoChangeMs = null;
|
|
128
|
+
if (cfg.demo === true) {
|
|
129
|
+
try {
|
|
130
|
+
demoChangeMs = demoSettings(process.env).changeMs;
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
say(`mason start: ${e.message}`);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const pinned = cliPinProblem(folder, o.version);
|
|
138
|
+
if (pinned) {
|
|
139
|
+
say(`mason start: ${pinned}`);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
// THE SOURCE (src/cli/sources.ts): the database and the tables to copy, from the project's sources file; a server's
|
|
143
|
+
// URL from its environment first (mason serve is configured by it alone). Read here, its failure said below where it
|
|
144
|
+
// always was (after the listener's checks): a demo project's port is needed first
|
|
145
|
+
const readSources = () => projectSource(project, process.env, { shellFirst: o.serve !== undefined });
|
|
146
|
+
const src = readSources();
|
|
147
|
+
// a demo project's database listens on the port its URL names (the one place it is kept), which no listener of the
|
|
148
|
+
// run's may take: read BEFORE the Postgres API's port is chosen and recorded, so the choice avoids it (a recorded or
|
|
149
|
+
// named port that is the demo's is refused by choosePgPort, nothing recorded), and the command server is kept off it
|
|
150
|
+
const demoAt = cfg.demo === true && !("message" in src) ? demoPort(src.resolved, src.from) : null;
|
|
151
|
+
const demoPorts = new Set(demoAt !== null && "port" in demoAt ? [demoAt.port] : []);
|
|
152
|
+
// THE PORT IS THE PROJECT'S (src/cli/ports.ts): the first start's choice recorded, every later start on it or refused
|
|
153
|
+
const pg = await choosePgPort(process.env.MASON_PG_PORT, demoPorts, cfg.pgPort ?? null);
|
|
154
|
+
if ("problem" in pg)
|
|
155
|
+
return failed("start", pg, { json: false });
|
|
156
|
+
if (cfg.pgPort !== pg.port) {
|
|
157
|
+
cfg.pgPort = pg.port;
|
|
158
|
+
writeConfig(home, cfg);
|
|
159
|
+
}
|
|
160
|
+
// WHERE AND HOW THE POSTGRES LISTENER LISTENS (src/cli/pg-tls.ts): the one reader of MASON_PG_HOST and
|
|
161
|
+
// MASON_PG_TLS_CERT / _KEY (src/api/postgres/listener.ts), its plain-listener rule refused here, before anything is connected or started
|
|
162
|
+
const listen = runListenerSettings(process.env, { port: pg.port, selfSigned: o.selfSignedTls, home });
|
|
163
|
+
if ("problem" in listen)
|
|
164
|
+
return failed("start", listen, { json: false });
|
|
165
|
+
const pgTls = listenerTls(listen, o.selfSignedTls, home, cfg);
|
|
166
|
+
if ("problem" in pgTls)
|
|
167
|
+
return failed("start", pgTls, { json: false });
|
|
168
|
+
const chosen = new Set([pg.port, ...demoPorts]);
|
|
169
|
+
// a server's project folder is the team's, committed, and may be mounted read-only: mason serve writes nothing there
|
|
170
|
+
const wrotePin = o.serve ? null : ensurePin(folder, o.version);
|
|
171
|
+
if (wrotePin)
|
|
172
|
+
say(` wrote ${path.relative(project.root, wrotePin)}: version ${o.version}, the Mason this project is written for — commit it`);
|
|
173
|
+
if ("message" in src)
|
|
174
|
+
return failed("start", src, { json: false });
|
|
175
|
+
const logFile = path.join(home, "mason.log");
|
|
176
|
+
const log = fs.createWriteStream(logFile, { flags: "a" });
|
|
177
|
+
// one entry a line: a multi-line message's further lines indented under it (src/lib/log.ts `textEntry`);
|
|
178
|
+
// mason serve gets the same entry, for the container's stdout (src/cli/serve.ts)
|
|
179
|
+
const logLine = (l) => {
|
|
180
|
+
const entry = textEntry(`${new Date().toISOString()} `, l);
|
|
181
|
+
log.write(entry);
|
|
182
|
+
o.serve?.log(entry.trimEnd());
|
|
183
|
+
};
|
|
184
|
+
// THE DEMO DATABASE (src/cli/demo.ts), hosted here: opened before it is connected to, as any database is
|
|
185
|
+
let demo = null;
|
|
186
|
+
if (demoAt !== null) {
|
|
187
|
+
begin("Starting the demo database");
|
|
188
|
+
if ("problem" in demoAt) {
|
|
189
|
+
fail("Starting the demo database");
|
|
190
|
+
say(` ${demoAt.problem}`);
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
const opened = await openDemo({ home, port: demoAt.port, from: src.from, log: (l) => logLine(`[demo] ${l}`) });
|
|
194
|
+
if ("problem" in opened) {
|
|
195
|
+
fail("Starting the demo database");
|
|
196
|
+
say(` ${opened.problem}`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
demo = opened.demo;
|
|
200
|
+
hosted.close = closingOnce(opened.demo);
|
|
201
|
+
done(opened.done);
|
|
202
|
+
}
|
|
203
|
+
const db = maskedUrl(src.resolved);
|
|
204
|
+
begin(`Connecting to ${db}`);
|
|
205
|
+
const checkSaid = [];
|
|
206
|
+
const checked = await checkSource(src.resolved, { remote: cfg.remote, cmd: "mason start", say: (m) => checkSaid.push(m),
|
|
207
|
+
// the checklist's line says it is connecting; everything else checkSource says is the answer
|
|
208
|
+
progress: () => undefined });
|
|
209
|
+
if ("needsRemote" in checked) {
|
|
210
|
+
// connected, but the project was not made for a database elsewhere: the step is done, the confirmation is the stop
|
|
211
|
+
const { source, facts, warnings } = checked.needsRemote;
|
|
212
|
+
done(`Connected to ${db}: Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read`);
|
|
213
|
+
for (const w of warnings.filter((w) => w !== remoteWarning(source)))
|
|
214
|
+
say(` ! ${w}`);
|
|
215
|
+
const config = path.join(home, "config.json");
|
|
216
|
+
return failed("start", remoteConfirmation(source.host, { cmd: "mason start",
|
|
217
|
+
wayOut: `this project was not made with --remote: if you mean to copy from that machine, set "remote": true in ${config} and start again`,
|
|
218
|
+
next: [["mason start", `once ${config} says "remote": true`]] }), { json: false });
|
|
219
|
+
}
|
|
220
|
+
if ("exit" in checked) {
|
|
221
|
+
fail(`Connecting to ${db}`);
|
|
222
|
+
for (const m of checkSaid)
|
|
223
|
+
say(` ${m.replace(/^mason start: /, "")}`);
|
|
224
|
+
if (checked.exit !== 2)
|
|
225
|
+
say(` (the database's URL is ${src.from}: change it there if its host, port or password changed)`);
|
|
226
|
+
return checked.exit === 2 ? 1 : checked.exit;
|
|
227
|
+
}
|
|
228
|
+
const { source, warnings } = checked;
|
|
229
|
+
done(`Connected to ${db}: Postgres ${checked.facts.versionText}${checked.facts.standby ? " (a read replica)" : ""}, ${checked.facts.tables.length} table(s) this user can read`);
|
|
230
|
+
for (const w of warnings)
|
|
231
|
+
say(` ! ${w}`);
|
|
232
|
+
// HOW THE CHANGES ARE READ (src/cli/replication-mode.ts): polling with SELECTs, or the change log as production reads
|
|
233
|
+
// it (src/duck/stream.ts). A switch copies nothing again here: polling goes on from each table's watermark, and a
|
|
234
|
+
// new slot's tables are caught up by one poll (Sync prepareReplication)
|
|
235
|
+
const changes = changesOf(o.changes, cfg.changes);
|
|
236
|
+
if (o.publication !== null && changes !== "replication") {
|
|
237
|
+
say("mason start: --publication names the publication logical replication reads, and this project polls: mason start --replication --publication <name>");
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
// THE PUBLICATION: one the operator named (the flag, PG_PUBLICATION, or the one an earlier start recorded) is read as it
|
|
241
|
+
// is, never altered (src/duck/publication.ts); else Mason makes its own. What the run before made, as it recorded it,
|
|
242
|
+
// is what --poll drops (madeReplication)
|
|
243
|
+
const named = changes === "replication" ? namedPublication(o.publication, process.env, cfg.publication) : null;
|
|
244
|
+
const repl = projectReplication(cfg.name, src.name, named?.name ?? null);
|
|
245
|
+
const before = madeReplication(cfg, src.name);
|
|
246
|
+
if (changes === "replication") {
|
|
247
|
+
begin("Checking logical replication");
|
|
248
|
+
const ready = await replicationReadiness(source, repl);
|
|
249
|
+
if ("failure" in ready || ready.refused.length) {
|
|
250
|
+
fail("Checking logical replication");
|
|
251
|
+
for (const c of "failure" in ready ? [{ name: "database", detail: ready.failure }] : ready.refused)
|
|
252
|
+
say(` ${c.name}: ${c.detail}`);
|
|
253
|
+
say(" (mason start --poll reads the database with SELECTs instead, which needs none of this)");
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
done(repl.owned ? `Logical replication: Mason makes publication ${repl.publication} and slot ${repl.slot} on ${source.database} (mason destroy, or mason start --poll, drops them)`
|
|
257
|
+
: `Logical replication: publication ${repl.publication} is read as it is, never altered; Mason makes slot ${repl.slot} on ${source.database} (mason destroy, or mason start --poll, drops the slot alone)`);
|
|
258
|
+
if (named?.from === "recorded")
|
|
259
|
+
say(` publication ${repl.publication} was named at an earlier start of this copy, and is read again (mason start --replication --publication <name> names another)`);
|
|
260
|
+
for (const w of ready.warnings)
|
|
261
|
+
say(` ! ${w.name}: ${w.detail}`);
|
|
262
|
+
// A SLOT RECORDED UNDER ANOTHER NAME (the sources file renamed since) is one this run no longer reads, and it holds
|
|
263
|
+
// the database's WAL while it exists: it goes as at --poll, verified, before the record names the new one
|
|
264
|
+
const was = cfg.replication ?? null;
|
|
265
|
+
if (was !== null && was.slot !== repl.slot) {
|
|
266
|
+
try {
|
|
267
|
+
for (const d of await dropOwnReplication(source, was))
|
|
268
|
+
say(` dropped ${d} from ${source.database}: this source's slot is ${repl.slot} now`);
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
say(`mason start: ${e.message}. They hold the database's WAL while nobody reads them: drop them by hand — ${dropStatements(was)}`);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// recorded with the run, so mason status and mason stop say it, the next start reads the same one, and --poll and
|
|
276
|
+
// mason destroy drop what was made by its own name
|
|
277
|
+
const recorded = repl.owned ? null : repl.publication;
|
|
278
|
+
if ((cfg.publication ?? null) !== recorded || JSON.stringify(cfg.replication ?? null) !== JSON.stringify(repl)) {
|
|
279
|
+
cfg.publication = recorded;
|
|
280
|
+
cfg.replication = repl;
|
|
281
|
+
writeConfig(home, cfg);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const previous = cfg.changes ?? "poll";
|
|
285
|
+
if (previous !== changes) {
|
|
286
|
+
// THE SLOT GOES WHENEVER THE PROJECT LEAVES REPLICATION: it holds the database's WAL while nobody reads it
|
|
287
|
+
if (previous === "replication") {
|
|
288
|
+
try {
|
|
289
|
+
for (const d of await dropOwnReplication(source, before))
|
|
290
|
+
say(` dropped ${d} from ${source.database}`);
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
say(`mason start: ${e.message}. They hold the database's WAL while nobody reads them: drop them by hand — ${dropStatements(before)}`);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
if (!before.owned)
|
|
297
|
+
say(` publication ${before.publication} is its owner's: left as it is`);
|
|
298
|
+
cfg.publication = null;
|
|
299
|
+
cfg.replication = null;
|
|
300
|
+
}
|
|
301
|
+
cfg.changes = changes;
|
|
302
|
+
writeConfig(home, cfg);
|
|
303
|
+
}
|
|
304
|
+
let facts = checked.facts;
|
|
305
|
+
const move = sourceMove(cfg.sourceServer ?? null, { database: source.database, systemIdentifier: facts.systemIdentifier });
|
|
306
|
+
if ("refuse" in move) {
|
|
307
|
+
say(`mason start: ${move.refuse}`);
|
|
308
|
+
return 1;
|
|
309
|
+
}
|
|
310
|
+
// THE TABLES THE SOURCES FILE NAMES, against the database's catalog: every mistake said at once, before anything opens
|
|
311
|
+
const firstTables = addedTables(src, facts.tables, source.database, project.root, { replication: changes === "replication" });
|
|
312
|
+
if (!Array.isArray(firstTables))
|
|
313
|
+
return failed("start", firstTables, { json: false });
|
|
314
|
+
// what the run copies: the sources file as last read, and what is wrong with it now (an edit the run cannot take)
|
|
315
|
+
let sources = { file: src, tables: firstTables, problem: null };
|
|
316
|
+
// how many of a copy's chunks are read at once, each a backend on the database (src/duck/sync.ts COPY_PARALLEL)
|
|
317
|
+
let copyParallel;
|
|
318
|
+
try {
|
|
319
|
+
copyParallel = copyParallelOf(process.env);
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
say(`mason start: ${e.message}`);
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
// how long each of a copy's chunks should take to read, at the rate measured here (src/duck/sync.ts COPY_CHUNK_SECONDS)
|
|
326
|
+
let copyChunkSeconds;
|
|
327
|
+
try {
|
|
328
|
+
copyChunkSeconds = copyChunkSecondsOf(process.env);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
say(`mason start: ${e.message}`);
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
// the JSON paths statements keep reading, stored as columns of the copy (src/duck/kept.ts), unless turned off
|
|
335
|
+
let storeJsonPaths;
|
|
336
|
+
try {
|
|
337
|
+
storeJsonPaths = storeJsonPathsOf(process.env);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
say(`mason start: ${e.message}`);
|
|
341
|
+
return 1;
|
|
342
|
+
}
|
|
343
|
+
begin("Opening the copy");
|
|
344
|
+
const duckFile = path.join(home, DUCK_FILE);
|
|
345
|
+
let store;
|
|
346
|
+
// its memory bounded (src/duck/store.ts DEFAULT_MEMORY_LIMIT): past it a statement spills, or fails alone
|
|
347
|
+
const memoryLimit = process.env.MASON_DUCKDB_MEMORY || undefined;
|
|
348
|
+
try {
|
|
349
|
+
store = await DuckStore.open(duckFile, (l) => logLine(`[duckdb] ${l}`), memoryLimit === undefined ? {} : { memoryLimit });
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
fail("Opening the copy");
|
|
353
|
+
say(` ${e.message}`);
|
|
354
|
+
return 1;
|
|
355
|
+
}
|
|
356
|
+
// THE COPY'S THREAD DYING (src/duck/executor.ts: V8 out of heap there, an uncaught error) ends the run, said, for its
|
|
357
|
+
// supervisor to start it again: every session's statement in flight was told 57P02, and nothing can be answered after
|
|
358
|
+
let crash = null;
|
|
359
|
+
store.onCrash((reason) => {
|
|
360
|
+
logLine(`[duckdb] the copy's thread stopped: ${reason}`);
|
|
361
|
+
if (crash !== null)
|
|
362
|
+
crash(reason);
|
|
363
|
+
else
|
|
364
|
+
say(` ! the copy's thread stopped: ${reason}`);
|
|
365
|
+
});
|
|
366
|
+
done(`the copy in ${path.relative(project.root, duckFile)} (mason destroy deletes it; engine ${DUCKDB_VERSION}), at most ${(memoryLimit ?? DEFAULT_MEMORY_LIMIT).trim()} of memory (MASON_DUCKDB_MEMORY), spilling to ${path.relative(project.root, duckFile)}.tmp`);
|
|
367
|
+
const pollMs = Number(process.env.MASON_POLL_MS || 5000);
|
|
368
|
+
const syncMode = changes === "replication" ? "replication" : syncModeOf(process.env);
|
|
369
|
+
const sync = new Sync(store, source, cfg.name, (l) => { logLine(`[sync] ${l}`); if (!o.serve && /^copied |failed/.test(l))
|
|
370
|
+
say(textEntry(" ", l).trimEnd()); }, pollMs, syncMode, syncMode === "replication" ? repl : null, { copyParallel, copyChunkSeconds, json: { enabled: storeJsonPaths } });
|
|
371
|
+
await sync.open();
|
|
372
|
+
// a database restored to another server: its transaction ids are not the watermarks', so every table is read again
|
|
373
|
+
if (move.action === "reread") {
|
|
374
|
+
await sync.rereadAll();
|
|
375
|
+
say(` ${source.database} is on another server than the copy was taken from: reading its tables again`);
|
|
376
|
+
}
|
|
377
|
+
if (move.action !== "same") {
|
|
378
|
+
cfg.sourceServer = { systemIdentifier: facts.systemIdentifier, database: source.database };
|
|
379
|
+
writeConfig(home, cfg);
|
|
380
|
+
}
|
|
381
|
+
// the views first: setting the tables rebuilds the catalog, which reads them. The sources file read again: a mistake in it
|
|
382
|
+
// keeps the tables read before (a typo never drops a copy), said in the log and by mason status
|
|
383
|
+
const added = () => {
|
|
384
|
+
const r = rereadSources(sources, readSources(), src.resolved, facts.tables, source.database, project.root, { replication: changes === "replication" });
|
|
385
|
+
if (r.said !== null)
|
|
386
|
+
logLine(`[sync] ${r.said}`);
|
|
387
|
+
sources = { file: r.file, tables: r.tables, problem: r.problem };
|
|
388
|
+
sync.setViews(facts.views ?? []);
|
|
389
|
+
void sync.setTables(sources.tables);
|
|
390
|
+
};
|
|
391
|
+
if (pg.said)
|
|
392
|
+
say(` ${pg.said}`);
|
|
393
|
+
const pgAddress = { host: listen.host, port: listen.port };
|
|
394
|
+
const postgresUrl = postgresApiUrl(pgAddress, cfg.name, { tls: pgTls.tls !== null });
|
|
395
|
+
// A LIBC COLLATION'S ORDER IS READ FROM THE DATABASE (src/duck/libc-order.ts): once, a few read-only SELECTs, kept in
|
|
396
|
+
// the copy and read again when the database's collation version moves (an OS upgrade's glibc)
|
|
397
|
+
let libc;
|
|
398
|
+
const coll = facts.collation;
|
|
399
|
+
if (coll !== undefined && isLibcCollation(coll.provider, coll.collate)) {
|
|
400
|
+
const reading = `Reading how ${source.database} orders text (${coll.collate}${coll.version ? `, version ${coll.version}` : ""})`;
|
|
401
|
+
try {
|
|
402
|
+
const r = await store.libcOrder(coll.collate, coll.version ?? null, async () => {
|
|
403
|
+
begin(reading);
|
|
404
|
+
const c = await connectSource(source);
|
|
405
|
+
if ("failure" in c)
|
|
406
|
+
throw new Error(`could not connect to read it: ${c.failure.message}`);
|
|
407
|
+
try {
|
|
408
|
+
return await readLibcOrder(async (sql) => (await c.pg.query(sql)).rows, coll.collate, coll.version ?? null);
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
c.pg.close();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
libc = r.order;
|
|
415
|
+
if (r.read)
|
|
416
|
+
done(`Read how ${source.database} orders text (${coll.collate}): kept in the copy, read again only when its collation version moves`);
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
fail(reading);
|
|
420
|
+
say(` ${e.message}`);
|
|
421
|
+
await sync.stop();
|
|
422
|
+
await store.close();
|
|
423
|
+
return 1;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
begin("Starting the Postgres listener");
|
|
427
|
+
// ONE STATEMENT'S LIMIT (server.ts armTimeout): a runaway query must not hold DuckDB for ever; a session's
|
|
428
|
+
// statement_timeout may only shorten it. Postgres's units (5min, 90s, 300000); 0 for none
|
|
429
|
+
const timeoutSetting = process.env.MASON_STATEMENT_TIMEOUT || "5min";
|
|
430
|
+
const statementTimeoutMs = timeoutMs(timeoutSetting);
|
|
431
|
+
if (statementTimeoutMs === null) {
|
|
432
|
+
fail("Starting the Postgres listener");
|
|
433
|
+
say(` MASON_STATEMENT_TIMEOUT=${JSON.stringify(timeoutSetting)} is not a duration: write it as Postgres's statement_timeout is written (5min, 90s, 300000 for ms; 0 for none)`);
|
|
434
|
+
await sync.stop();
|
|
435
|
+
await store.close();
|
|
436
|
+
return 1;
|
|
437
|
+
}
|
|
438
|
+
// THE REQUEST LOG (src/duck/requests.ts): every statement the listener answers or refuses, kept in the copy's own file
|
|
439
|
+
const keptDays = requestLogDays(process.env.MASON_REQUEST_LOG_DAYS);
|
|
440
|
+
if (typeof keptDays !== "number") {
|
|
441
|
+
fail("Starting the Postgres listener");
|
|
442
|
+
say(` ${keptDays.problem}`);
|
|
443
|
+
await sync.stop();
|
|
444
|
+
await store.close();
|
|
445
|
+
return 1;
|
|
446
|
+
}
|
|
447
|
+
let requests;
|
|
448
|
+
try {
|
|
449
|
+
requests = await RequestLedger.open(store, logLine, { days: keptDays });
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
fail("Starting the Postgres listener");
|
|
453
|
+
say(` the request log (${REQUESTS_TABLE}) could not be opened: ${e.message}`);
|
|
454
|
+
await sync.stop();
|
|
455
|
+
await store.close();
|
|
456
|
+
return 1;
|
|
457
|
+
}
|
|
458
|
+
let duckListener;
|
|
459
|
+
try {
|
|
460
|
+
duckListener = await store.listener(facts.collation, undefined, libc, () => sync.state(), sync.kept.enabled ? sync.kept : undefined);
|
|
461
|
+
}
|
|
462
|
+
catch (e) {
|
|
463
|
+
fail("Starting the Postgres listener");
|
|
464
|
+
say(` ${e.message}`);
|
|
465
|
+
await requests.close();
|
|
466
|
+
await sync.stop();
|
|
467
|
+
await store.close();
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
logLine(`[duckdb] text sorts as the source's collation does: ${JSON.stringify(duckListener.collation)}`);
|
|
471
|
+
// THE USERS (src/duck/users.ts): `mason user` edits the file beside this process; the listener re-reads it at every
|
|
472
|
+
// connection and before every statement of a user's session, so a removal takes effect without a restart
|
|
473
|
+
const users = new LiveUsers(path.join(home, USERS_FILE), cfg.apiKey);
|
|
474
|
+
let api;
|
|
475
|
+
try {
|
|
476
|
+
api = await startPostgresApi({
|
|
477
|
+
port: pgAddress.port, host: pgAddress.host, tls: pgTls.tls, database: cfg.name, masonVersion: o.version, product: "mason-data", statementTimeoutMs,
|
|
478
|
+
...listenerLogins(users, cfg.apiKey),
|
|
479
|
+
schema: () => sync.schema(),
|
|
480
|
+
record: (row) => requests.record(row),
|
|
481
|
+
log: logLine,
|
|
482
|
+
trace: process.env.MASON_PG_TRACE === "1",
|
|
483
|
+
duck: duckListener,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
fail("Starting the Postgres listener");
|
|
488
|
+
say(` ${e.message}`);
|
|
489
|
+
await requests.close();
|
|
490
|
+
await sync.stop();
|
|
491
|
+
await store.close();
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
494
|
+
done(`Postgres listener on ${listenerText(pgTls.listener, api.port)}; ${statementTimeoutMs === 0 ? "statements without a time limit" : `each statement at most ${timeoutSetting.trim()}`} (MASON_STATEMENT_TIMEOUT); ` +
|
|
495
|
+
`every request recorded for ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_REQUEST_LOG_DAYS; mason logs --requests)`);
|
|
496
|
+
const setupPort = await freePort(o.port, chosen);
|
|
497
|
+
const token = newToken();
|
|
498
|
+
const server = createRunServer({
|
|
499
|
+
source, facts: () => facts, warnings,
|
|
500
|
+
slot: changes === "replication" ? repl.slot : "", changes, port: setupPort, token, folder: () => folder, root: project.root,
|
|
501
|
+
// the web page (mason ui) is mason start's alone: mason serve's run serves none (src/cli/run-server.ts RunContext.page)
|
|
502
|
+
page: o.serve === undefined,
|
|
503
|
+
demo: cfg.demo === true,
|
|
504
|
+
users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database: cfg.name, postgresUrl },
|
|
505
|
+
...(changes === "replication" ? { publication: { name: repl.publication, owned: repl.owned } } : {}),
|
|
506
|
+
// the sources file is the list of what is added: a change to it is read by the sync at once
|
|
507
|
+
sources: () => sources, sourcesChanged: () => { added(); }, readSource: readSources, runUrl: src.resolved,
|
|
508
|
+
log: (m) => say(` ${m}`),
|
|
509
|
+
duck: { copies: () => sync.state(), file: duckFile, pollSeconds: pollMs / 1000, poll: (sources) => sync.pollNow(sources), stream: () => sync.streamStatus(), requests,
|
|
510
|
+
jsonPaths: () => sync.kept.status() },
|
|
511
|
+
});
|
|
512
|
+
await new Promise((resolve) => server.listen(setupPort, "127.0.0.1", resolve));
|
|
513
|
+
const refreshFacts = setInterval(() => {
|
|
514
|
+
void (async () => {
|
|
515
|
+
const c = await connectSource(source);
|
|
516
|
+
if ("failure" in c)
|
|
517
|
+
return; // the commands keep the last catalog; the sync says loudly if the source is away
|
|
518
|
+
try {
|
|
519
|
+
facts = await readSourceFacts(c.pg);
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
c.pg.close();
|
|
523
|
+
}
|
|
524
|
+
})().catch((e) => logLine(`catalog refresh failed: ${e.message}`));
|
|
525
|
+
}, 10_000);
|
|
526
|
+
added();
|
|
527
|
+
cfg.running = { pid: process.pid, controlUrl: `http://127.0.0.1:${setupPort}`, token, postgresUrl, postgresListener: pgTls.listener };
|
|
528
|
+
cfg.lastRun = new Date().toISOString();
|
|
529
|
+
writeConfig(home, cfg);
|
|
530
|
+
done(`Mason is up: ${sync.state().length} table(s) added, kept current ${syncMode === "replication" ? `by logical replication (slot ${repl.slot}, publication ${repl.publication}${repl.owned ? "" : ", read as it is"})`
|
|
531
|
+
: `every ${pollMs / 1000} s ${syncMode === "poll" ? "by polling with SELECTs (xmin)" : "by copying each table again whole (MASON_DUCK_SYNC=recopy)"}`}`);
|
|
532
|
+
// THE DEMO'S WRITER: a round of changes every MASON_DEMO_CHANGE_MS, begun once the run is up, said in the log
|
|
533
|
+
if (demo !== null && demoChangeMs !== null)
|
|
534
|
+
hosted.stopChanges = demoChanges(demo, demoChangeMs, (l) => logLine(`[demo] ${l}`));
|
|
535
|
+
// the stop is in place BEFORE the ready line: a supervisor or a test signals on reading it, and a SIGTERM that found
|
|
536
|
+
// no handler would end the run with the default action, never recording `running: null`
|
|
537
|
+
let stopping = false;
|
|
538
|
+
const stopped = new Promise((resolve) => {
|
|
539
|
+
// a crash of the copy's thread: the run exits 1 with its reason in V8's fatal form, which the supervisor reads
|
|
540
|
+
// (src/cli/supervise.ts crashReason), and its config still says it runs, so the supervisor starts it again
|
|
541
|
+
crash = (reason) => {
|
|
542
|
+
if (stopping)
|
|
543
|
+
return;
|
|
544
|
+
stopping = true;
|
|
545
|
+
say(`FATAL ERROR: Mason: the copy's thread stopped (${reason})`);
|
|
546
|
+
clearInterval(refreshFacts);
|
|
547
|
+
hosted.stopChanges?.();
|
|
548
|
+
server.close();
|
|
549
|
+
void (async () => {
|
|
550
|
+
// the sessions were told; the listener is closed so that none waits on a copy that cannot answer
|
|
551
|
+
const closed = api.close().catch((e) => say(` closing the listener: ${e.message}`));
|
|
552
|
+
await Promise.race([closed, new Promise((r) => setTimeout(r, 2_000).unref())]);
|
|
553
|
+
// the demo database's data flushed to its folder before the log it writes to ends (dev() closes it otherwise)
|
|
554
|
+
// (a close still going after 2 s is said: the process ends without it, and the folder may not be whole)
|
|
555
|
+
await closedWithin(closeDemo(hosted, null), 2_000, () => say(` ${DEMO_STILL_CLOSING(2_000)}`));
|
|
556
|
+
log.end();
|
|
557
|
+
resolve(1);
|
|
558
|
+
})();
|
|
559
|
+
};
|
|
560
|
+
const stop = (signal) => {
|
|
561
|
+
if (stopping)
|
|
562
|
+
return;
|
|
563
|
+
stopping = true;
|
|
564
|
+
o.serve?.stopping();
|
|
565
|
+
say("\nmason start: stopping …");
|
|
566
|
+
clearInterval(refreshFacts);
|
|
567
|
+
hosted.stopChanges?.();
|
|
568
|
+
server.close();
|
|
569
|
+
void (async () => {
|
|
570
|
+
try {
|
|
571
|
+
await api.close();
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
say(` closing the listener: ${e.message}`);
|
|
575
|
+
}
|
|
576
|
+
// after the listener: its last statements' rows are written before the file closes
|
|
577
|
+
try {
|
|
578
|
+
await requests.close();
|
|
579
|
+
}
|
|
580
|
+
catch (e) {
|
|
581
|
+
say(` closing the request log: ${e.message}`);
|
|
582
|
+
}
|
|
583
|
+
let syncStop = null;
|
|
584
|
+
try {
|
|
585
|
+
await sync.stop();
|
|
586
|
+
}
|
|
587
|
+
catch (e) {
|
|
588
|
+
syncStop = e;
|
|
589
|
+
}
|
|
590
|
+
// the demo database after the sync (nothing reads it any more) and before the copy closes
|
|
591
|
+
await closeDemo(hosted, `closed the demo database (it stays in ${path.join(home, DEMO_DIR)}; mason destroy removes it)`);
|
|
592
|
+
if (syncStop !== null)
|
|
593
|
+
say(` closing the copy: ${syncStop.message}`);
|
|
594
|
+
else {
|
|
595
|
+
try {
|
|
596
|
+
await store.close();
|
|
597
|
+
say(` closed the copy (it stays in ${duckFile})`);
|
|
598
|
+
}
|
|
599
|
+
catch (e) {
|
|
600
|
+
say(` closing the copy: ${e.message}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (syncMode === "replication")
|
|
604
|
+
say(` replication slot ${repl.slot} stays on ${source.database}, holding its WAL until the next mason start reads it (mason destroy drops it)` +
|
|
605
|
+
(repl.owned ? "" : `; publication ${repl.publication} is its owner's, left as it is`));
|
|
606
|
+
cfg.running = null;
|
|
607
|
+
// how it ended, for mason status once it is gone (a kill records nothing: mason stop writes that one)
|
|
608
|
+
cfg.lastStop = { at: new Date().toISOString(), how: signal === "SIGINT" ? "stopped from its terminal (SIGINT)" : `stopped by mason stop, or a shutdown (${signal})` };
|
|
609
|
+
writeConfig(home, cfg);
|
|
610
|
+
log.end();
|
|
611
|
+
resolve(0);
|
|
612
|
+
})();
|
|
613
|
+
};
|
|
614
|
+
process.on("SIGINT", stop);
|
|
615
|
+
process.on("SIGTERM", stop);
|
|
616
|
+
});
|
|
617
|
+
say("");
|
|
618
|
+
say(` Postgres ${withPlaceholders(postgresUrl)} (mason user add <name> makes a user)`);
|
|
619
|
+
// the web page by its command, never its URL: the URL carries the run's token, and this goes to dev.log
|
|
620
|
+
if (o.serve === undefined)
|
|
621
|
+
say(" Web UI mason ui (the status page and the users, in your browser)");
|
|
622
|
+
if (demoChangeMs !== null)
|
|
623
|
+
say(` Demo a new order lands every ${demoChangeMs / 1000} s: mason status shows the copy keeping up`);
|
|
624
|
+
const elsewhere = elsewhereTip(pgTls.listener);
|
|
625
|
+
if (elsewhere)
|
|
626
|
+
say(` ${elsewhere}`);
|
|
627
|
+
say(READY);
|
|
628
|
+
say("");
|
|
629
|
+
o.serve?.ready({ tables: () => sync.state() });
|
|
630
|
+
return await stopped;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The listener's certificate, read: under --self-signed-tls the project's own (made or kept, src/cli/pg-tls.ts, and
|
|
634
|
+
* recorded in its config), else the one MASON_PG_TLS_CERT names; with what the run records of it. A certificate that
|
|
635
|
+
* cannot be read or made is said as what is wrong and the way out.
|
|
636
|
+
*/
|
|
637
|
+
function listenerTls(listen, selfSigned, home, cfg) {
|
|
638
|
+
if (listen.tls === null)
|
|
639
|
+
return { tls: null, listener: { host: listen.host, tls: null } };
|
|
640
|
+
if (selfSigned) {
|
|
641
|
+
let made;
|
|
642
|
+
try {
|
|
643
|
+
made = ensureSelfSigned(home, cfg.name);
|
|
644
|
+
}
|
|
645
|
+
catch (e) {
|
|
646
|
+
return { problem: `the self-signed certificate could not be written to ${home}: ${e.message}`, code: "failed" };
|
|
647
|
+
}
|
|
648
|
+
if (made.said)
|
|
649
|
+
say(` ${made.said} (SHA-256 ${made.fingerprint}); a later mason start --self-signed-tls serves it again`);
|
|
650
|
+
const recorded = { cert: SELF_SIGNED_FILES.cert, key: SELF_SIGNED_FILES.key, fingerprint: made.fingerprint };
|
|
651
|
+
if (JSON.stringify(cfg.selfSignedTls ?? null) !== JSON.stringify(recorded)) {
|
|
652
|
+
cfg.selfSignedTls = recorded;
|
|
653
|
+
writeConfig(home, cfg);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const read = readPgTls(listen.tls);
|
|
657
|
+
if ("problem" in read)
|
|
658
|
+
return { problem: read.problem, code: "refused", fix: "MASON_PG_TLS_CERT and MASON_PG_TLS_KEY name PEM files this user can read" };
|
|
659
|
+
const fingerprint = certificateFingerprint(read.cert);
|
|
660
|
+
if (typeof fingerprint !== "string")
|
|
661
|
+
return { ...fingerprint, code: "refused", fix: "MASON_PG_TLS_CERT names the certificate's PEM file (-----BEGIN CERTIFICATE-----), MASON_PG_TLS_KEY its key's" };
|
|
662
|
+
return { tls: read, listener: { host: listen.host, tls: selfSigned ? "self-signed" : "certificate", fingerprint } };
|
|
663
|
+
}
|
|
664
|
+
/** The line a run writes once it answers commands: what `mason start` in the background waits for (src/cli/background.ts),
|
|
665
|
+
* and what tells its supervisor a death after it is a crash to restart (src/cli/supervise.ts, which defines it). */
|
|
666
|
+
export const READY = READY_LINE;
|
|
667
|
+
/** The one sentence of how the pieces fit, printed first by `mason start` and at the top of its help. */
|
|
668
|
+
export const PROJECT_LINE = "A Mason project is your app's folder: mason/sources/ names the database and the tables to copy, yours to commit; " +
|
|
669
|
+
"this machine's copy of your data and Mason's state are in .mason/, rebuilt from that file.";
|