@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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason start --replication` (the journey to production, item 6): a project's run reading its database's
|
|
3
|
+
* change log as production does — a publication, a replication slot, `wal_level = logical` — instead of polling it with
|
|
4
|
+
* SELECTs (src/cdc/poll.ts). A developer then meets the setup production needs on their own database first. The mode is
|
|
5
|
+
* the project's (`.mason/config.json` `changes`), kept between starts until `--poll` takes it back.
|
|
6
|
+
*
|
|
7
|
+
* What it costs the developer's database, said where it happens: Mason creates its slot there (`mason_<deployment>`), and
|
|
8
|
+
* a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or going back to polling, drops
|
|
9
|
+
* it (`dropOwnReplication`), verified gone, by the name the start that made it RECORDED (`madeReplication`), never one
|
|
10
|
+
* computed again. The PUBLICATION is Mason's own (`mason_<deployment>`, made and dropped with
|
|
11
|
+
* the slot) unless the operator names one (`--publication <name>`, else `PG_PUBLICATION`, else the one
|
|
12
|
+
* an earlier start recorded): that one is the database owner's, read as it is and never created, altered or dropped
|
|
13
|
+
* (src/duck/publication.ts) — on a read replica it is the only way, since CREATE PUBLICATION is refused on a standby.
|
|
14
|
+
*
|
|
15
|
+
* In the ClickHouse service this was cut from, the two modes kept row versions that could not continue each other (a
|
|
16
|
+
* counter per table against WAL positions): a switch was a copy from scratch, which that service did by itself. The DuckDB
|
|
17
|
+
* prototype keeps its copies through a switch: each table is caught up by one poll from its watermark (src/duck/sync.ts
|
|
18
|
+
* `prepareReplication`), and `mason start` says so.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { replicationNames } from "../cdc/source.js";
|
|
22
|
+
import { connectSource, systemIdentifier } from "./preflight.js";
|
|
23
|
+
import { readServerFacts, readSlotFacts, replicationChecks } from "./replication-checks.js";
|
|
24
|
+
/** Postgres's longest identifier (NAMEDATALEN - 1): a longer slot name is refused, a longer publication's cut with a notice. */
|
|
25
|
+
export const PG_NAME_BYTES = 63;
|
|
26
|
+
/**
|
|
27
|
+
* THE NAME A SOURCE'S SLOT AND PUBLICATION ARE MADE FROM, `dev_<project>_<source>` (src/cdc/source.ts replicationNames
|
|
28
|
+
* puts `mason_` before it): one per project AND source, the source the sources file's name, since a project will sync
|
|
29
|
+
* several databases, each with a slot of its own. Lower case, `_` for any other character, as a slot's name must be. A
|
|
30
|
+
* project (48 at most) and a source (48) can pass Postgres's 63 bytes: then a readable prefix and `_` and 8 hex
|
|
31
|
+
* characters of the whole name's sha256, so two names sharing a long prefix never meet and none is ever cut silently.
|
|
32
|
+
*/
|
|
33
|
+
export function deploymentOf(project, source) {
|
|
34
|
+
const full = `dev_${project}_${source}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
|
35
|
+
const room = PG_NAME_BYTES - "mason_".length;
|
|
36
|
+
if (full.length <= room)
|
|
37
|
+
return full;
|
|
38
|
+
return `${full.slice(0, room - 9)}_${createHash("sha256").update(full).digest("hex").slice(0, 8)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The publication and slot a project's run reads: the publication `named` (the flag, `PG_PUBLICATION` or the recorded
|
|
42
|
+
* one: `namedPublication`), else Mason's own — by the change reader's own rule (src/cdc/source.ts `replicationNames`),
|
|
43
|
+
* which also says whether it is Mason's to make. The slot is always the project's own.
|
|
44
|
+
*/
|
|
45
|
+
export function projectReplication(name, source, named = null) {
|
|
46
|
+
const { publication, slot, owned } = replicationNames(named ? { PG_PUBLICATION: named } : {}, deploymentOf(name, source));
|
|
47
|
+
return { publication, slot, owned };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* WHAT REPLICATION MODE MADE ON THE DATABASE, as the start that made it recorded it (`config.json` `replication`): what
|
|
51
|
+
* `--poll` and `mason destroy` drop. Recorded, never computed again, so a name that changes since — the sources file
|
|
52
|
+
* renamed, the naming rule — never leaves a slot that nobody drops, holding the database's WAL for ever. A project with
|
|
53
|
+
* no record (none started in replication mode since it was kept) has the names the rule gives now.
|
|
54
|
+
*/
|
|
55
|
+
export function madeReplication(cfg, source) {
|
|
56
|
+
return cfg.replication ?? projectReplication(cfg.name, source, cfg.publication ?? null);
|
|
57
|
+
}
|
|
58
|
+
/** The publication an operator named, and where: `--publication`, else `PG_PUBLICATION`, else the one an earlier start
|
|
59
|
+
* of this copy recorded (`config.json` `publication`), else none (Mason's own). */
|
|
60
|
+
export function namedPublication(flag, env, recorded) {
|
|
61
|
+
if (flag)
|
|
62
|
+
return { name: flag, from: "flag" };
|
|
63
|
+
if (env.PG_PUBLICATION)
|
|
64
|
+
return { name: env.PG_PUBLICATION, from: "env" };
|
|
65
|
+
if (recorded)
|
|
66
|
+
return { name: recorded, from: "recorded" };
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** The mode a start runs: the flag, else the project's, else polling. */
|
|
70
|
+
export function changesOf(flag, recorded) {
|
|
71
|
+
return flag ?? recorded ?? "poll";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether the developer's database can be read by replication: what refuses it (wal_level, the role — REPLICATION, a
|
|
75
|
+
* superuser, or RDS's rds_replication membership, as `replicationChecks` reads them — a free slot and WAL sender, a
|
|
76
|
+
* standby before 16, and a standby with no publication named: Mason cannot make its own there), and what is only worth
|
|
77
|
+
* saying (the WAL cap), from the production checks' own rules.
|
|
78
|
+
*/
|
|
79
|
+
export function readinessVerdict(facts, slot, slotFacts, repl) {
|
|
80
|
+
const checks = replicationChecks(facts, slot, slotFacts);
|
|
81
|
+
// a standby takes no CREATE PUBLICATION (it is read-only): the publication must be the primary's, named
|
|
82
|
+
if (facts.standby && repl.owned) {
|
|
83
|
+
checks.push({ name: "publication", ok: false, detail: `this database is a read replica, where Mason cannot make its publication ${repl.publication} (CREATE ` +
|
|
84
|
+
"PUBLICATION is refused on a standby). Create one on the primary for the tables you add, then name it: mason start --replication --publication <name>" });
|
|
85
|
+
}
|
|
86
|
+
// a laptop's database has no cap on its slots by default: worth saying, never a reason to refuse a dev run
|
|
87
|
+
return { refused: checks.filter((x) => x.ok === false && x.name !== "WAL cap"), warnings: checks.filter((x) => (x.name === "WAL cap" && x.ok === false) || x.warn === true) };
|
|
88
|
+
}
|
|
89
|
+
export async function replicationReadiness(source, repl) {
|
|
90
|
+
const c = await connectSource(source);
|
|
91
|
+
if ("failure" in c)
|
|
92
|
+
return { failure: c.failure.message };
|
|
93
|
+
try {
|
|
94
|
+
return readinessVerdict(await readServerFacts(c.pg), repl.slot, await readSlotFacts(c.pg, repl.slot), repl);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
c.pg.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** The statements that remove what replication mode made, for a person to run when Mason could not: the slot, and the
|
|
101
|
+
* publication only when it is Mason's own (a named one is its owner's). */
|
|
102
|
+
export function dropStatements(o) {
|
|
103
|
+
return `SELECT pg_drop_replication_slot('${o.slot}') FROM pg_replication_slots WHERE slot_name = '${o.slot}' AND NOT active;` +
|
|
104
|
+
(o.owned ? ` DROP PUBLICATION IF EXISTS ${o.publication};` : "");
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Drop what replication mode made on the developer's database — the slot, and the publication when it is Mason's own,
|
|
108
|
+
* never one the operator named — and verify it is gone: what was dropped, in words. A slot a reader still holds is never
|
|
109
|
+
* dropped (the run must be stopped first): that is a failure.
|
|
110
|
+
*/
|
|
111
|
+
export async function dropOwnReplication(source, o, from = null) {
|
|
112
|
+
const c = await connectSource(source);
|
|
113
|
+
if ("failure" in c)
|
|
114
|
+
throw new Error(`could not connect to drop them: ${c.failure.message}`);
|
|
115
|
+
const pg = c.pg;
|
|
116
|
+
const lit = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
117
|
+
try {
|
|
118
|
+
// the server the copy came from (its system identifier, recorded at the copy): another one has none of its slot
|
|
119
|
+
if (from?.systemIdentifier) {
|
|
120
|
+
const now = await systemIdentifier(pg);
|
|
121
|
+
if (now !== null && now !== from.systemIdentifier) {
|
|
122
|
+
throw new Error(`${source.database} on ${source.host} is another server than the copy came from (system identifier ${now}, the copy's ${from.systemIdentifier}), where the slot is: put that server's URL back in the sources file and run mason destroy again, or drop them there by hand`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const dropped = [];
|
|
126
|
+
const slot = await readSlotFacts(pg, o.slot);
|
|
127
|
+
if (slot) {
|
|
128
|
+
if (slot.active)
|
|
129
|
+
throw new Error(`replication slot ${o.slot} is in use by a reader: stop it first`);
|
|
130
|
+
await pg.query(`SELECT pg_drop_replication_slot(${lit(o.slot)})`);
|
|
131
|
+
dropped.push(`replication slot ${o.slot}`);
|
|
132
|
+
}
|
|
133
|
+
const hasPub = async () => (await pg.query(`SELECT 1 FROM pg_publication WHERE pubname = ${lit(o.publication)}`)).rows.length > 0;
|
|
134
|
+
if (o.owned && await hasPub()) {
|
|
135
|
+
await pg.query(`DROP PUBLICATION IF EXISTS "${o.publication.replace(/"/g, '""')}"`);
|
|
136
|
+
dropped.push(`publication ${o.publication}`);
|
|
137
|
+
}
|
|
138
|
+
// verified, never assumed: a slot left behind holds the database's WAL for ever
|
|
139
|
+
if (await readSlotFacts(pg, o.slot))
|
|
140
|
+
throw new Error(`replication slot ${o.slot} is still there after pg_drop_replication_slot`);
|
|
141
|
+
if (o.owned && await hasPub())
|
|
142
|
+
throw new Error(`publication ${o.publication} is still there after DROP PUBLICATION`);
|
|
143
|
+
return dropped;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
pg.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RUN'S COMMAND SERVER (`mason start`; a server for the `mason` command alone, the CLI's UI part dropped
|
|
3
|
+
* completely). `mason tables`, `mason add`, `mason status`, `mason wait`, `mason sync` and
|
|
4
|
+
* `mason logs --requests` ask it (src/cli/control.ts): it reads the database's catalog, writes the tables added and
|
|
5
|
+
* removed into the project's sources file (src/cli/sources.ts) — the sync, told of the change, starts copying a table at
|
|
6
|
+
* once — and says where everything is.
|
|
7
|
+
*
|
|
8
|
+
* Every request passes the run's guard (src/cli/security.ts): loopback, the Host header, and the run's token, which
|
|
9
|
+
* the commands send as `Authorization: Bearer` from the run's settings file.
|
|
10
|
+
*
|
|
11
|
+
* What it knows it reads where the run keeps it: the copies from the sync (src/duck/sync.ts), the statements from the
|
|
12
|
+
* request log (src/duck/requests.ts). Added is a fact about the project's files: a table is added when its sources file
|
|
13
|
+
* names it.
|
|
14
|
+
*
|
|
15
|
+
* THE WEB PAGE (src/web/, `mason ui`, docs/WEB-UI.md): under `mason start` it also serves one page and its two files,
|
|
16
|
+
* from an allowlist read when it starts (`pageFiles`), and the users the page manages (`/api/users`, the functions
|
|
17
|
+
* `mason user` calls). The page reads the same JSON the commands read; a refusal carries `failure`, the CLI's Failure
|
|
18
|
+
* shape (message, why, fix, next), so the page says it as the command would. `mason serve`'s run serves no page.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as http from "node:http";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { readBody, sendJson } from "../lib/http-json.js";
|
|
25
|
+
import { projectFingerprint } from "../lib/project-files.js";
|
|
26
|
+
import { guard, SECURITY_HEADERS } from "./security.js";
|
|
27
|
+
import { maskedUrl } from "./preflight.js";
|
|
28
|
+
import { errorJson, nextJson } from "./command.js";
|
|
29
|
+
import { readiness } from "./readiness.js";
|
|
30
|
+
import { addedNext, addUser, listUsers, removeUser, REMOVED_NEXT } from "./user.js";
|
|
31
|
+
import { postgresLink } from "./pg-url.js";
|
|
32
|
+
import { addedTables, catalogTable, chooseColumns, entryName, entryProblems, findEntry, parseSourceText, shownPath, sourceFailure, sourceFileNames, sourcesDir, unaddableReason, withoutTable, withTable } from "./sources.js";
|
|
33
|
+
import { PollRefused } from "../duck/sync.js";
|
|
34
|
+
/** A statement answered in this many milliseconds or more is slow: what mason status lists (src/duck/requests.ts slow). */
|
|
35
|
+
export const SLOW_MS = 1000;
|
|
36
|
+
/**
|
|
37
|
+
* The run's state, in the shape mason status / wait read (`RunState`, src/cli/commands.ts): each table of the database,
|
|
38
|
+
* added or not, and each added one's copy as the sync has it, so `mason wait` is ready exactly when every added table is
|
|
39
|
+
* copied.
|
|
40
|
+
*/
|
|
41
|
+
export function runState(ctx, copies, stream) {
|
|
42
|
+
const folder = ctx.folder();
|
|
43
|
+
const sources = ctx.sources();
|
|
44
|
+
const by = new Map(copies.map((c) => [c.source, c]));
|
|
45
|
+
const project = folder && fs.existsSync(folder) ? projectFingerprint(folder) : null;
|
|
46
|
+
const facts = ctx.facts();
|
|
47
|
+
const tables = facts.tables.map((t) => {
|
|
48
|
+
const c = by.get(t.source);
|
|
49
|
+
return {
|
|
50
|
+
...catalogTable(t, sources.file?.tables.find((e) => e.table === t.source)),
|
|
51
|
+
// a table the sync has not reached is waiting for its copy (no copy yet), never "copying 0"
|
|
52
|
+
// a copy in a retired layout (src/duck/sync.ts RETIRED_LAYOUT) serves until it is copied again: done, and said
|
|
53
|
+
copy: c && c.phase !== "waiting" ? { phase: (c.phase === "copied" ? "done" : c.phase === "failed" ? "failed" : "copying"),
|
|
54
|
+
rowsCopied: c.phase === "copied" ? c.rows ?? 0 : c.rowsSoFar ?? 0, updatedAt: c.copiedAt ?? "",
|
|
55
|
+
// while its chunks are read: the chunks done and reading, the measured rate, the time left (src/duck/sync.ts
|
|
56
|
+
// copyProgressNow), so mason wait and status move between two commits
|
|
57
|
+
...(c.phase === "copying" && c.progress !== undefined ? { progress: c.progress } : {}),
|
|
58
|
+
...(c.phase === "copied" && c.retired !== undefined ? { retired: c.retired } : {}) } : null,
|
|
59
|
+
error: c?.phase === "failed" ? c.error : null,
|
|
60
|
+
// copied and served, but not kept current by the poll (src/duck/sync.ts POLL_ROW_LIMIT): mason status and wait say so
|
|
61
|
+
...(c?.unsynced ? { unsynced: c.unsynced } : {}),
|
|
62
|
+
...(c ? { duck: { rows: c.rows, bytes: c.bytes, seconds: c.seconds, copiedAt: c.copiedAt, error: c.error } } : {}),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
// what mason wait waits on and mason status prints last (src/cli/readiness.ts): said by the run, so the page and the
|
|
67
|
+
// commands say one thing
|
|
68
|
+
readiness: readiness({ tables }),
|
|
69
|
+
// the run serves the web page (mason ui): mason status prints its URL
|
|
70
|
+
ui: ctx.page,
|
|
71
|
+
source: { url: maskedUrl(ctx.source.url), host: ctx.source.host, database: ctx.source.database, remote: ctx.source.remote,
|
|
72
|
+
version: facts.versionText, standby: facts.standby, changes: ctx.changes, name: sources.file?.name ?? null, file: sources.file?.file ?? null,
|
|
73
|
+
...(ctx.publication ? { replication: { slot: ctx.slot, publication: ctx.publication.name, owned: ctx.publication.owned } } : {}) },
|
|
74
|
+
// a sources file edited into a mistake while the run is up: the run copies what it read last, and says so
|
|
75
|
+
warnings: [...ctx.warnings, ...(sources.problem ? [`${sources.problem} (Mason copies the tables it read before; mason start reads the file again)`] : [])],
|
|
76
|
+
folder, project, tables,
|
|
77
|
+
engine: { name: "duckdb", file: ctx.duck.file, bytes: fileBytes(ctx.duck.file), pollSeconds: ctx.duck.pollSeconds },
|
|
78
|
+
jsonPaths: ctx.duck.jsonPaths?.() ?? [],
|
|
79
|
+
// replication mode: where the stream is, its lag and what it applied (mason status and wait render it, streamLines)
|
|
80
|
+
...(stream !== null ? { stream } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** a file's size, or null where it cannot be read (the run's own DuckDB file: it exists while the run does) */
|
|
84
|
+
function fileBytes(file) {
|
|
85
|
+
try {
|
|
86
|
+
return fs.statSync(file).size;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
} // a size is a nicety of mason status: unknown, not an error
|
|
91
|
+
}
|
|
92
|
+
async function state(ctx) {
|
|
93
|
+
return runState(ctx, ctx.duck.copies(), await ctx.duck.stream());
|
|
94
|
+
}
|
|
95
|
+
/** The sources file as it is on disk now, its text and what it says — or why the command server cannot edit it. */
|
|
96
|
+
function sourceOnDisk(ctx) {
|
|
97
|
+
const folder = ctx.folder();
|
|
98
|
+
if (!folder)
|
|
99
|
+
throw new Refusal(409, "choose the project's folder first");
|
|
100
|
+
const names = sourceFileNames(folder);
|
|
101
|
+
if (names.length !== 1)
|
|
102
|
+
throw new Refusal(409, `${path.relative(ctx.root, sourcesDir(folder)) || sourcesDir(folder)}/ has ${names.length ? `${names.length} source files` : "no source file"}: one source per project for now`);
|
|
103
|
+
const file = path.join(sourcesDir(folder), names[0]);
|
|
104
|
+
const text = fs.readFileSync(file, "utf8");
|
|
105
|
+
const parsed = parseSourceText(text, names[0].replace(/\.ya?ml$/, ""), file);
|
|
106
|
+
if ("problems" in parsed) {
|
|
107
|
+
const f = sourceFailure(file, ctx.root, parsed.problems);
|
|
108
|
+
throw new Refusal(409, `${f.message}${f.why ? `\n${f.why}` : ""} — fix it first`);
|
|
109
|
+
}
|
|
110
|
+
// the database the file names NOW must be the one this run copies: its catalog is what an edit is checked against
|
|
111
|
+
const read = ctx.readSource();
|
|
112
|
+
if ("message" in read)
|
|
113
|
+
throw new Refusal(409, `${read.message}${read.why ? `\n${read.why}` : ""} — fix it first`);
|
|
114
|
+
if (read.resolved !== ctx.runUrl) {
|
|
115
|
+
throw new Refusal(409, `${shownPath(ctx.root, file)}: url: now names ${maskedUrl(read.resolved)} (${read.from}), and this run copies ${maskedUrl(ctx.runUrl)}: ` +
|
|
116
|
+
"mason stop && mason start copies from the new one, then add or remove the table");
|
|
117
|
+
}
|
|
118
|
+
return { file, text, parsed };
|
|
119
|
+
}
|
|
120
|
+
/** The file's other entries against the database: an edit is never written over a file the run would not take. */
|
|
121
|
+
function othersHold(ctx, parsed, without, then) {
|
|
122
|
+
const others = { ...parsed, tables: parsed.tables.filter((e) => !without.includes(e.table)) };
|
|
123
|
+
const held = addedTables(others, ctx.facts().tables, ctx.source.database, ctx.root, { replication: ctx.changes === "replication" });
|
|
124
|
+
if (!Array.isArray(held))
|
|
125
|
+
throw new Refusal(409, `${held.message}${held.why ? `\n${held.why}` : ""} — fix it first, then ${then}`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* ADD A TABLE: its entry in the sources file, written — after the last one, or in place of the table's own (`withTable`):
|
|
129
|
+
* every other byte of the file as it was. `columns` are the ones asked (`["*"]`: every one); `key` the columns a table
|
|
130
|
+
* with no primary key is copied by. The file's other entries must hold against the database first: an edit is never
|
|
131
|
+
* written over a file the next start would refuse.
|
|
132
|
+
*/
|
|
133
|
+
export async function pick(ctx, table, columns = ["*"], key = null) {
|
|
134
|
+
const { file, text, parsed } = sourceOnDisk(ctx);
|
|
135
|
+
const facts = ctx.facts();
|
|
136
|
+
const t = facts.tables.find((x) => x.source === table);
|
|
137
|
+
if (!t)
|
|
138
|
+
throw new Refusal(404, `${table} is not a table of ${ctx.source.database} that this user can read`);
|
|
139
|
+
if (key !== null && t.key.length)
|
|
140
|
+
throw new Refusal(400, `${table} has a primary key (${t.key.join(", ")}), which Mason copies it by: leave --key out`);
|
|
141
|
+
if (key === null && !t.key.length)
|
|
142
|
+
throw new Refusal(409, `${table} cannot be added: ${unaddableReason(t)}`);
|
|
143
|
+
if (key !== null && ctx.changes === "replication")
|
|
144
|
+
throw new Refusal(409, `${table} has no primary key, and logical replication cannot apply its changes by --key: copy it by polling (mason start --poll), then add it`);
|
|
145
|
+
const chosen = chooseColumns(t, columns, key);
|
|
146
|
+
if ("problem" in chosen)
|
|
147
|
+
throw new Refusal(400, chosen.problem);
|
|
148
|
+
othersHold(ctx, parsed, [table], `add ${entryName(table)}`);
|
|
149
|
+
const was = parsed.tables.find((e) => e.table === table);
|
|
150
|
+
const entry = { name: was?.name ?? entryName(table), table, columns: chosen.columns, exclude: chosen.exclude, key: key === null ? null : [...key] };
|
|
151
|
+
// the entry itself, as the run will read it: a --key that does not tell the rows apart is refused here, not at the copy
|
|
152
|
+
const own = entryProblems(t, entry, facts.tables, ctx.source.database, { replication: ctx.changes === "replication" });
|
|
153
|
+
if (own.length)
|
|
154
|
+
throw new Refusal(400, own.join("; "));
|
|
155
|
+
const same = was !== undefined && JSON.stringify([was.columns, was.exclude, was.key]) === JSON.stringify([entry.columns, entry.exclude, entry.key]);
|
|
156
|
+
if (!same) {
|
|
157
|
+
const r = withTable(text, entry);
|
|
158
|
+
if ("problem" in r)
|
|
159
|
+
throw new Refusal(409, `${path.relative(ctx.root, file)} cannot be edited: ${r.problem}`);
|
|
160
|
+
fs.writeFileSync(file, r.text);
|
|
161
|
+
}
|
|
162
|
+
return { table, file, entry: entry.name, columns: entry.columns, exclude: entry.exclude, key: entry.key, addedKey: chosen.addedKey, skippedSecrets: chosen.skipped, already: same };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* REMOVE TABLES from the sources file: each one's entry cut (`withoutTable`), every other byte of the file as it was; the
|
|
166
|
+
* run then drops its copy. A name the file does not have is refused with the closest one it has.
|
|
167
|
+
*/
|
|
168
|
+
export async function removeTables(ctx, names) {
|
|
169
|
+
const { file, text, parsed } = sourceOnDisk(ctx);
|
|
170
|
+
const entries = names.map((n) => {
|
|
171
|
+
const e = findEntry(parsed, n);
|
|
172
|
+
if ("problem" in e)
|
|
173
|
+
throw new Refusal(e.usage ? 400 : 404, e.problem);
|
|
174
|
+
return e;
|
|
175
|
+
});
|
|
176
|
+
// what stays must hold: else the run's re-read refuses the file, keeps the removed table and goes on copying it
|
|
177
|
+
othersHold(ctx, parsed, entries.map((e) => e.table), `remove ${names.join(" ")}`);
|
|
178
|
+
let out = text;
|
|
179
|
+
for (const e of entries) {
|
|
180
|
+
const r = withoutTable(out, e.table);
|
|
181
|
+
if (r === null)
|
|
182
|
+
continue;
|
|
183
|
+
if ("problem" in r)
|
|
184
|
+
throw new Refusal(409, `${path.relative(ctx.root, file)} cannot be edited: ${r.problem}`);
|
|
185
|
+
out = r.text;
|
|
186
|
+
}
|
|
187
|
+
fs.writeFileSync(file, out);
|
|
188
|
+
return { removed: [...new Set(entries.map((e) => e.table))], file };
|
|
189
|
+
}
|
|
190
|
+
/** A refusal the command shows as it is; `failure` is how the web page says it (the CLI's Failure: why, fix, next), by
|
|
191
|
+
* default the message alone under the status's code. */
|
|
192
|
+
export class Refusal extends Error {
|
|
193
|
+
status;
|
|
194
|
+
failure;
|
|
195
|
+
constructor(status, message, failure) { super(message); this.status = status; this.failure = failure ?? { message, code: codeOf(status) }; }
|
|
196
|
+
}
|
|
197
|
+
/** The FAILURE_CODES code (src/cli/command.ts) of a refusal by its HTTP status. */
|
|
198
|
+
const codeOf = (status) => status === 400 ? "usage" : status === 404 ? "not_found" : status >= 500 ? "failed" : "refused";
|
|
199
|
+
/** The HTTP status of a Failure by its code: the other way round. */
|
|
200
|
+
const statusOf = (f) => f.code === "usage" ? 400 : f.code === "not_found" ? 404 : f.code === "failed" ? 500 : 409;
|
|
201
|
+
/**
|
|
202
|
+
* EVERY ERROR ANSWER: `error`, the sentence a command prints (src/cli/control.ts reads it), and `failure`, the same
|
|
203
|
+
* refusal in the CLI's Failure shape — { code, message, why?, fix?, next: [{ command, what }] }, what `--json` prints
|
|
204
|
+
* — which the web page renders as the CLI does.
|
|
205
|
+
*/
|
|
206
|
+
export function sendFailure(res, status, f, extra = {}) {
|
|
207
|
+
sendJson(res, status, { error: f.message, ...extra, failure: { ...errorJson(f), next: nextJson(f.next) } });
|
|
208
|
+
}
|
|
209
|
+
/** THE WEB PAGE'S ROUTES, AND THE FILE EACH IS: the only files the server ever reads for a URL. */
|
|
210
|
+
export const PAGE_ROUTES = {
|
|
211
|
+
"/": ["index.html", "text/html; charset=utf-8"],
|
|
212
|
+
"/app.js": ["app.js", "text/javascript; charset=utf-8"],
|
|
213
|
+
"/app.css": ["app.css", "text/css; charset=utf-8"],
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* The web page's files, READ ONCE when the server starts (src/web/ beside src/cli/, in a checkout and in the package
|
|
217
|
+
* alike: cli/build.ts copies them): a map of route to bytes. A request's path is looked up in it, never joined onto a
|
|
218
|
+
* folder, so no URL can name another file. A file missing is a broken install, said with the path.
|
|
219
|
+
*/
|
|
220
|
+
export function pageFiles(dir = fileURLToPath(new URL("../web/", import.meta.url))) {
|
|
221
|
+
return new Map(Object.entries(PAGE_ROUTES).map(([route, [file, type]]) => {
|
|
222
|
+
const at = path.join(dir, file);
|
|
223
|
+
let body;
|
|
224
|
+
try {
|
|
225
|
+
body = fs.readFileSync(at);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
throw new Error(`the web page's ${file} cannot be read (${at}: ${e.message}): this Mason is installed incompletely — npm i -g @mason-data/cli again`);
|
|
229
|
+
}
|
|
230
|
+
return [route, { body, type }];
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
export function createRunServer(ctx) {
|
|
234
|
+
const saidErrors = new Set();
|
|
235
|
+
// read once, here: nothing a request names is ever read from disk (mason serve's run serves no page)
|
|
236
|
+
const page = ctx.page ? pageFiles() : new Map();
|
|
237
|
+
return http.createServer((req, res) => {
|
|
238
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS))
|
|
239
|
+
res.setHeader(k, v);
|
|
240
|
+
const v = guard({ method: req.method ?? "GET", url: req.url ?? "/", host: req.headers.host, origin: req.headers.origin, cookie: req.headers.cookie, authorization: req.headers.authorization }, ctx.port, ctx.token);
|
|
241
|
+
if (!v.ok) {
|
|
242
|
+
if (v.status === 303) {
|
|
243
|
+
res.writeHead(303, { Location: v.location, "Set-Cookie": v.setCookie }).end();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
res.writeHead(v.status, { "Content-Type": "text/plain; charset=utf-8" }).end(`${v.reason}\n`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${ctx.port}`);
|
|
250
|
+
void (async () => {
|
|
251
|
+
try {
|
|
252
|
+
const file = req.method === "GET" || req.method === "HEAD" ? page.get(url.pathname) : undefined;
|
|
253
|
+
if (file !== undefined) {
|
|
254
|
+
res.writeHead(200, { "Content-Type": file.type, "Content-Length": file.body.length }).end(req.method === "HEAD" ? undefined : file.body);
|
|
255
|
+
}
|
|
256
|
+
else if (req.method === "GET" && url.pathname === "/api/ping") {
|
|
257
|
+
// what proves a run is up (src/cli/runs.ts answers): this token answered, by this process
|
|
258
|
+
sendJson(res, 200, { pid: process.pid });
|
|
259
|
+
}
|
|
260
|
+
else if (req.method === "GET" && url.pathname === "/api/state") {
|
|
261
|
+
sendJson(res, 200, await state(ctx));
|
|
262
|
+
}
|
|
263
|
+
else if (req.method === "GET" && url.pathname === "/api/slow") {
|
|
264
|
+
// the last day's slow statements, from the request log (src/duck/requests.ts)
|
|
265
|
+
sendJson(res, 200, { slow: await ctx.duck.requests.slow(SLOW_MS), slowMs: SLOW_MS });
|
|
266
|
+
}
|
|
267
|
+
else if (req.method === "GET" && url.pathname === "/api/refused") {
|
|
268
|
+
sendJson(res, 200, await ctx.duck.requests.refused());
|
|
269
|
+
}
|
|
270
|
+
else if (req.method === "GET" && url.pathname === "/api/requests") {
|
|
271
|
+
const limit = Number(url.searchParams.get("limit") ?? 40);
|
|
272
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
|
|
273
|
+
throw new Refusal(400, "limit is a whole number of requests, 1 to 10000");
|
|
274
|
+
sendJson(res, 200, { requests: await ctx.duck.requests.recent({ limit, failed: url.searchParams.get("failed") === "1" }), keptDays: ctx.duck.requests.days });
|
|
275
|
+
}
|
|
276
|
+
else if (req.method === "POST" && url.pathname === "/api/pick") {
|
|
277
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
278
|
+
if (typeof body.table !== "string" || typeof body.picked !== "boolean")
|
|
279
|
+
throw new Refusal(400, "send { table, picked, columns?, key? }");
|
|
280
|
+
if (body.columns !== undefined && !(Array.isArray(body.columns) && body.columns.every((c) => typeof c === "string")))
|
|
281
|
+
throw new Refusal(400, "columns is a list of column names, or [\"*\"]");
|
|
282
|
+
if (body.key !== undefined && !(Array.isArray(body.key) && body.key.length > 0 && body.key.every((c) => typeof c === "string")))
|
|
283
|
+
throw new Refusal(400, "key is a list of column names");
|
|
284
|
+
if (body.picked) {
|
|
285
|
+
const r = await pick(ctx, body.table, body.columns ?? ["*"], body.key ?? null);
|
|
286
|
+
if (!r.already)
|
|
287
|
+
ctx.log(`added ${r.table} to ${r.file}`);
|
|
288
|
+
ctx.sourcesChanged();
|
|
289
|
+
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
const r = await removeTables(ctx, [body.table]);
|
|
293
|
+
for (const t of r.removed)
|
|
294
|
+
ctx.log(`removed ${t} from ${r.file}`);
|
|
295
|
+
ctx.sourcesChanged();
|
|
296
|
+
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (req.method === "GET" && url.pathname === "/api/users") {
|
|
300
|
+
// mason user list --json's shape: the users, and whether each connects
|
|
301
|
+
const r = listUsers(ctx.users.file, ctx.users.apiKey);
|
|
302
|
+
if ("message" in r) {
|
|
303
|
+
sendFailure(res, statusOf(r), r);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
sendJson(res, 200, { ...r, next: nextJson([["mason user add <name>", "a user for a tool: its password and URL, printed once"]]) });
|
|
307
|
+
}
|
|
308
|
+
else if (req.method === "POST" && url.pathname === "/api/users") {
|
|
309
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
310
|
+
if (typeof body.name !== "string")
|
|
311
|
+
throw new Refusal(400, "send { name }: the user's name");
|
|
312
|
+
// mason user add --json's shape: its password, once, and the URL to paste (this run is up, so it has one)
|
|
313
|
+
const r = addUser(ctx.users.file, ctx.users.apiKey, body.name, { link: postgresLink(ctx.users.postgresUrl), database: ctx.users.database });
|
|
314
|
+
if ("message" in r) {
|
|
315
|
+
sendFailure(res, statusOf(r), r);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const { replaced, ...added } = r;
|
|
319
|
+
ctx.log(`user ${added.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}`);
|
|
320
|
+
sendJson(res, 200, { ...added, next: nextJson(addedNext(added.user, true)) });
|
|
321
|
+
}
|
|
322
|
+
else if (req.method === "DELETE" && url.pathname.startsWith("/api/users/")) {
|
|
323
|
+
let name;
|
|
324
|
+
try {
|
|
325
|
+
name = decodeURIComponent(url.pathname.slice("/api/users/".length));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
throw new Refusal(400, `${url.pathname} does not name a user: DELETE /api/users/<name>`);
|
|
329
|
+
}
|
|
330
|
+
const r = removeUser(ctx.users.file, name);
|
|
331
|
+
if ("message" in r) {
|
|
332
|
+
sendFailure(res, statusOf(r), r);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
ctx.log(`user ${r.removed.name} removed`);
|
|
336
|
+
sendJson(res, 200, { ...r, next: nextJson(REMOVED_NEXT) });
|
|
337
|
+
}
|
|
338
|
+
else if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
339
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
340
|
+
if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((x) => typeof x === "string"))
|
|
341
|
+
throw new Refusal(400, "send { tables: [schema.table, …] }");
|
|
342
|
+
try {
|
|
343
|
+
sendJson(res, 200, await ctx.duck.poll(body.tables));
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
if (e instanceof PollRefused) {
|
|
347
|
+
sendFailure(res, e.code === "not_found" ? 404 : 409, { message: e.message, code: e.code }, { code: e.code });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
throw e;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
else if (req.method === "POST" && url.pathname === "/api/remove") {
|
|
354
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
355
|
+
if (!Array.isArray(body.tables) || !body.tables.length || !body.tables.every((m) => typeof m === "string"))
|
|
356
|
+
throw new Refusal(400, "send { tables: [name, …] }");
|
|
357
|
+
const r = await removeTables(ctx, body.tables);
|
|
358
|
+
// the sync stops copying a removed table, and drops its copy
|
|
359
|
+
ctx.sourcesChanged();
|
|
360
|
+
for (const t of r.removed)
|
|
361
|
+
ctx.log(`removed ${t} from ${r.file}`);
|
|
362
|
+
sendJson(res, 200, { ...r, state: await state(ctx) });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
sendFailure(res, 404, { message: `no ${req.method} ${url.pathname}: this is mason start's server for the mason command (mason --help)`, code: "not_found" });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
if (e instanceof Refusal) {
|
|
370
|
+
sendFailure(res, e.status, e.failure);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (e instanceof SyntaxError) {
|
|
374
|
+
sendFailure(res, 400, { message: `the body is not JSON: ${e.message}`, code: "usage" });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
// said ONCE per distinct reason: a command may ask every second, and a stack a second buried the terminal
|
|
378
|
+
const why = (e.message.split("\n---\n").pop() ?? String(e)).split("\n")[0];
|
|
379
|
+
const key = `${url.pathname} ${why}`;
|
|
380
|
+
if (!saidErrors.has(key)) {
|
|
381
|
+
saidErrors.add(key);
|
|
382
|
+
ctx.log(`command server: ${req.method} ${url.pathname} failed: ${why}`);
|
|
383
|
+
}
|
|
384
|
+
sendFailure(res, 500, { message: e.message, code: "failed", next: [["mason logs", "the run's log: what it was doing"]] });
|
|
385
|
+
}
|
|
386
|
+
})();
|
|
387
|
+
});
|
|
388
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHETHER MASON IS RUNNING FOR A PROJECT, and how a command reaches it (one run per project).
|
|
3
|
+
* `mason start` writes what its commands need into the project's config (`.mason/config.json` `running`, src/cli/project.ts):
|
|
4
|
+
* its pid, its Postgres API, and the command server it answers on with the run's token.
|
|
5
|
+
*
|
|
6
|
+
* UP MEANS IT ANSWERS: a pid alone is not proof — after a reboot the operating system hands the number to
|
|
7
|
+
* another process, and a `mason start` then refused as "already running" a run that did not exist. A run is up when
|
|
8
|
+
* its command server answers `/api/ping` with the run's own token and pid (`answers`).
|
|
9
|
+
*
|
|
10
|
+
* ALIVE AND SILENT IS A THIRD STATE (on the test server a run hung — its main thread blocked — with every port
|
|
11
|
+
* held; `mason stop` read the silence as "not running" and `mason destroy` deleted the copy under it, which the hung
|
|
12
|
+
* process kept open). A pid that is alive, whose command line is a Mason run's (`masonProcess`: after a reboot the
|
|
13
|
+
* number is another process's), and that does not answer is HUNG (`runState`): a command says so and names the way
|
|
14
|
+
* out, `mason stop` ends it (`endRun`: SIGTERM, then SIGKILL), and `mason destroy` removes nothing under it.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { theProject } from "./project.js";
|
|
18
|
+
/** Whether process `pid` is alive (signal 0 tests without signalling). */
|
|
19
|
+
export function alive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return e.code === "EPERM";
|
|
26
|
+
} // alive, and someone else's: still running
|
|
27
|
+
}
|
|
28
|
+
/** The run a project's config says is up (its pid alive), or null. Whether it ANSWERS is `answers`'. */
|
|
29
|
+
export function runOf(p, isAlive = alive) {
|
|
30
|
+
const r = p.config.running;
|
|
31
|
+
if (!r || !isAlive(r.pid))
|
|
32
|
+
return null;
|
|
33
|
+
return { name: p.config.name, root: p.root, home: p.dir, folder: p.folder, pid: r.pid, apiKey: p.config.apiKey,
|
|
34
|
+
control: { url: r.controlUrl, token: r.token }, postgresUrl: r.postgresUrl, postgresListener: r.postgresListener };
|
|
35
|
+
}
|
|
36
|
+
/** What the run's command server says of itself (`/api/ping`: its pid), or null when nothing answers with the run's token. */
|
|
37
|
+
export async function ping(run) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${run.control.url}/api/ping`, { headers: { Authorization: `Bearer ${run.control.token}` }, signal: AbortSignal.timeout(3_000) });
|
|
40
|
+
if (res.status !== 200)
|
|
41
|
+
return null;
|
|
42
|
+
return await res.json();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// nothing listening, a timeout, a body that is not JSON: not this run (whatever holds the port now). "Not up" is
|
|
46
|
+
// the answer the question wants: a command then says Mason is not running, with mason start as the way out
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Whether the run answers with its own token and pid: the only proof it is up. */
|
|
51
|
+
export async function answers(run) {
|
|
52
|
+
return (await ping(run))?.pid === run.pid;
|
|
53
|
+
}
|
|
54
|
+
/** What a command that needs Mason running says when it is not. */
|
|
55
|
+
export function notRunning(p) {
|
|
56
|
+
return { problem: `Mason is not running for ${p.config.name} (${p.root})`, code: "not_running", next: [["mason start", "start it, in the background"]] };
|
|
57
|
+
}
|
|
58
|
+
/** How long a hung run is given to leave on SIGTERM before SIGKILL (the test server's ignored SIGTERM for 15 s). */
|
|
59
|
+
export const END_GRACE_MS = 15_000;
|
|
60
|
+
/** What a command says of a run that is alive and does not answer: what to run, since nothing else reaches it. */
|
|
61
|
+
export function notAnswering(p, run) {
|
|
62
|
+
return { problem: `Mason for ${p.config.name} is running (pid ${run.pid}) but does not answer its commands (${run.control.url}, 3 s)`, code: "not_answering",
|
|
63
|
+
why: "a statement may be holding its main thread",
|
|
64
|
+
next: [["mason stop", `end it (SIGTERM, then SIGKILL after ${END_GRACE_MS / 1000} s); its copy stays and the next mason start resumes`], ["mason logs", "what it was doing"]] };
|
|
65
|
+
}
|
|
66
|
+
/** Whether `pid` is a Mason run, by its command line (`mason start … --attach`, src/cli/background.ts). ps exits 1 for a pid that is gone. */
|
|
67
|
+
export function masonProcess(pid) {
|
|
68
|
+
const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
|
|
69
|
+
return ps.status === 0 && / start\b.*--attach\b/.test(ps.stdout);
|
|
70
|
+
}
|
|
71
|
+
/** The project's run: up (it answers), hung (alive, a Mason run, and silent), or none. */
|
|
72
|
+
export async function runState(p, isAlive = alive, ask = answers, isMason = masonProcess) {
|
|
73
|
+
const r = runOf(p, isAlive);
|
|
74
|
+
if (r === null)
|
|
75
|
+
return { kind: "none", run: null };
|
|
76
|
+
if (await ask(r))
|
|
77
|
+
return { kind: "up", run: r };
|
|
78
|
+
return isMason(r.pid) ? { kind: "hung", run: r } : { kind: "none", run: null };
|
|
79
|
+
}
|
|
80
|
+
/** The project's run when it is up and answers; null when Mason is not running for it (a hung run is not up). */
|
|
81
|
+
export async function liveRun(p, isAlive = alive, ask = answers) {
|
|
82
|
+
const st = await runState(p, isAlive, ask);
|
|
83
|
+
return st.kind === "up" ? st.run : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* ENDS A RUN THAT DOES NOT ANSWER: SIGTERM, then SIGKILL once `graceMs` has passed with it still alive (a blocked main
|
|
87
|
+
* thread never sees SIGTERM), and the pid waited for. `killed` says whether SIGKILL was needed; a pid alive after it
|
|
88
|
+
* is said, never reported ended.
|
|
89
|
+
*/
|
|
90
|
+
export async function endRun(pid, deps = {}) {
|
|
91
|
+
const kill = deps.kill ?? ((p, sig) => process.kill(p, sig));
|
|
92
|
+
const isAlive = deps.isAlive ?? alive;
|
|
93
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
94
|
+
const grace = deps.graceMs ?? END_GRACE_MS;
|
|
95
|
+
kill(pid, "SIGTERM");
|
|
96
|
+
const until = Date.now() + grace;
|
|
97
|
+
while (isAlive(pid) && Date.now() < until)
|
|
98
|
+
await sleep(250);
|
|
99
|
+
if (!isAlive(pid))
|
|
100
|
+
return { killed: false };
|
|
101
|
+
kill(pid, "SIGKILL");
|
|
102
|
+
const gone = Date.now() + 10_000;
|
|
103
|
+
while (isAlive(pid) && Date.now() < gone)
|
|
104
|
+
await sleep(250);
|
|
105
|
+
if (isAlive(pid))
|
|
106
|
+
throw new Error(`pid ${pid} is still running after SIGKILL: it may be another user's, or the system is not reaping it (ps -p ${pid})`);
|
|
107
|
+
return { killed: true };
|
|
108
|
+
}
|
|
109
|
+
/** The run a command means: its project's (src/cli/project.ts theProject), up and answering; else why there is none. */
|
|
110
|
+
export async function theRun(cwd) {
|
|
111
|
+
const t = theProject(cwd);
|
|
112
|
+
if ("problem" in t)
|
|
113
|
+
return { ...t, project: null };
|
|
114
|
+
const st = await runState(t.project);
|
|
115
|
+
if (st.kind === "up")
|
|
116
|
+
return { run: st.run, project: t.project };
|
|
117
|
+
if (st.kind === "hung")
|
|
118
|
+
return { ...notAnswering(t.project, st.run), project: t.project, hung: st.run };
|
|
119
|
+
return { ...notRunning(t.project), project: t.project };
|
|
120
|
+
}
|