@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.
Files changed (102) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +209 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +442 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1670 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +482 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +174 -0
  22. package/dist/src/cli/command.js +172 -0
  23. package/dist/src/cli/commands.js +1145 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/dev.js +576 -0
  26. package/dist/src/cli/doctor.js +257 -0
  27. package/dist/src/cli/guides.js +134 -0
  28. package/dist/src/cli/init.js +326 -0
  29. package/dist/src/cli/machine.js +157 -0
  30. package/dist/src/cli/main.js +83 -0
  31. package/dist/src/cli/pg-tls.js +100 -0
  32. package/dist/src/cli/pg-url.js +52 -0
  33. package/dist/src/cli/pin.js +36 -0
  34. package/dist/src/cli/ports.js +118 -0
  35. package/dist/src/cli/preflight.js +338 -0
  36. package/dist/src/cli/project.js +127 -0
  37. package/dist/src/cli/readiness.js +76 -0
  38. package/dist/src/cli/registry.js +217 -0
  39. package/dist/src/cli/replication-checks.js +120 -0
  40. package/dist/src/cli/replication-mode.js +148 -0
  41. package/dist/src/cli/run-server.js +388 -0
  42. package/dist/src/cli/runs.js +120 -0
  43. package/dist/src/cli/security.js +86 -0
  44. package/dist/src/cli/self-signed-cert.js +64 -0
  45. package/dist/src/cli/serve.js +296 -0
  46. package/dist/src/cli/sources.js +562 -0
  47. package/dist/src/cli/state.js +58 -0
  48. package/dist/src/cli/style.js +187 -0
  49. package/dist/src/cli/supervise.js +248 -0
  50. package/dist/src/cli/sync.js +94 -0
  51. package/dist/src/cli/ui.js +107 -0
  52. package/dist/src/cli/user.js +229 -0
  53. package/dist/src/cli/version.js +23 -0
  54. package/dist/src/duck/analyze.js +113 -0
  55. package/dist/src/duck/cancel.js +49 -0
  56. package/dist/src/duck/executor-worker.js +263 -0
  57. package/dist/src/duck/executor.js +526 -0
  58. package/dist/src/duck/explain.js +119 -0
  59. package/dist/src/duck/functions.js +764 -0
  60. package/dist/src/duck/gaps.js +89 -0
  61. package/dist/src/duck/jsonb.js +186 -0
  62. package/dist/src/duck/kept-route.js +219 -0
  63. package/dist/src/duck/kept.js +797 -0
  64. package/dist/src/duck/late.js +221 -0
  65. package/dist/src/duck/libc-order.js +190 -0
  66. package/dist/src/duck/pg-eval-worker.js +34 -0
  67. package/dist/src/duck/pg-eval.js +59 -0
  68. package/dist/src/duck/pgtree.js +338 -0
  69. package/dist/src/duck/plan.js +246 -0
  70. package/dist/src/duck/publication.js +207 -0
  71. package/dist/src/duck/requests.js +209 -0
  72. package/dist/src/duck/resolved.js +248 -0
  73. package/dist/src/duck/rewrite.js +4001 -0
  74. package/dist/src/duck/store.js +831 -0
  75. package/dist/src/duck/stream.js +518 -0
  76. package/dist/src/duck/sync.js +2864 -0
  77. package/dist/src/duck/to-char.js +482 -0
  78. package/dist/src/duck/token.js +31 -0
  79. package/dist/src/duck/types.js +427 -0
  80. package/dist/src/duck/users.js +231 -0
  81. package/dist/src/duck/verified.js +995 -0
  82. package/dist/src/duck/views.js +150 -0
  83. package/dist/src/lib/database-url.js +74 -0
  84. package/dist/src/lib/did-you-mean.js +43 -0
  85. package/dist/src/lib/dotenv.js +72 -0
  86. package/dist/src/lib/exit-when-written.js +20 -0
  87. package/dist/src/lib/float-equal.js +69 -0
  88. package/dist/src/lib/http-json.js +22 -0
  89. package/dist/src/lib/log.js +161 -0
  90. package/dist/src/lib/pg-numeric.js +489 -0
  91. package/dist/src/lib/port.js +22 -0
  92. package/dist/src/lib/project-file.js +49 -0
  93. package/dist/src/lib/project-files.js +43 -0
  94. package/dist/src/lib/project-version.js +76 -0
  95. package/dist/src/lib/runnable.js +14 -0
  96. package/dist/src/lib/shell-word.js +4 -0
  97. package/dist/src/lib/timezone.js +236 -0
  98. package/dist/src/lib/zone-names.js +114 -0
  99. package/dist/src/web/app.css +65 -0
  100. package/dist/src/web/app.js +336 -0
  101. package/dist/src/web/index.html +39 -0
  102. package/package.json +22 -4
@@ -0,0 +1,187 @@
1
+ /**
2
+ * COLOUR AND WIDTH, for a person at a terminal only (a little colour makes output easier to read and scan). One
3
+ * palette everywhere:
4
+ *
5
+ * bold a step's heading, the Next heading, a command's one-line headline
6
+ * cyan what you can type (a command, under Next and in `mason --help`)
7
+ * dim a description, a tip, a secondary fact
8
+ * green / red / yellow a state only: ok and ready, failed, under way or unsure
9
+ *
10
+ * and lines wrapped at the terminal's width with a hanging indent, so a long description continues under its own
11
+ * column instead of under the command names.
12
+ *
13
+ * Styled only when the stream is a terminal and NO_COLOR is unset (https://no-color.org); FORCE_COLOR forces it.
14
+ * Piped, redirected, under --json, or read by a coding agent, every command prints plain, unwrapped text — what an agent
15
+ * parses never changes. tests/conventions.test.ts refuses a colour code written anywhere else in src/cli.
16
+ */
17
+ import * as fs from "node:fs";
18
+ import { Worker } from "node:worker_threads";
19
+ const sgr = (on, off) => (s) => s === "" ? s : `\x1b[${on}m${s}\x1b[${off}m`;
20
+ const plainText = (s) => s;
21
+ /** No colour, no wrapping: what a pipe, a file, a test and a coding agent read. */
22
+ export const PLAIN = { bold: plainText, dim: plainText, cyan: plainText, green: plainText, red: plainText, yellow: plainText, width: null };
23
+ const COLOUR = {
24
+ bold: sgr(1, 22), dim: sgr(2, 22), cyan: sgr(36, 39), green: sgr(32, 39), red: sgr(31, 39), yellow: sgr(33, 39),
25
+ };
26
+ /** Whether to colour `stream`: FORCE_COLOR (not "0") forces it, NO_COLOR (any value) or a non-terminal turns it off. */
27
+ export function colourOn(stream, env = process.env) {
28
+ if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0")
29
+ return true;
30
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== "")
31
+ return false;
32
+ return stream.isTTY === true;
33
+ }
34
+ /** The style for a stream: colour when colourOn, and its width when it is a terminal that says one. */
35
+ export function styleFor(stream, env = process.env) {
36
+ const width = stream.isTTY === true && stream.columns !== undefined && stream.columns >= 40 ? stream.columns : null;
37
+ return colourOn(stream, env) ? { ...COLOUR, width } : { ...PLAIN, width };
38
+ }
39
+ /** stdout's style (what a command prints) and stderr's (its progress), read when first asked. */
40
+ export const out = () => styleFor(process.stdout);
41
+ export const err = () => styleFor(process.stderr);
42
+ /** A string's length as the terminal shows it: without its colour codes. */
43
+ export function visibleLength(s) {
44
+ return s.replace(/\x1b\[[0-9;]*m/g, "").length;
45
+ }
46
+ /**
47
+ * `text` wrapped at `width`, every line after the first indented by `indent` spaces — the first line's own prefix is the
48
+ * caller's (` mason init ` before a description). A word longer than a line is left whole. Null width: unwrapped.
49
+ */
50
+ export function wrap(text, width, indent, firstUsed = indent) {
51
+ if (width === null)
52
+ return text;
53
+ const room = (used) => Math.max(20, width - used - 1);
54
+ const lines = [];
55
+ let line = "", used = firstUsed;
56
+ for (const word of text.split(" ")) {
57
+ if (line && visibleLength(line) + 1 + visibleLength(word) > room(used)) {
58
+ lines.push(line);
59
+ line = word;
60
+ used = indent;
61
+ }
62
+ else
63
+ line = line ? `${line} ${word}` : word;
64
+ }
65
+ lines.push(line);
66
+ return lines.join(`\n${" ".repeat(indent)}`);
67
+ }
68
+ /** The spinner's frames, and how long a command may be quiet before it shows. */
69
+ const FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
70
+ export const SPIN_AFTER_MS = 300;
71
+ export function spinUntilOutput(label, o = {}) {
72
+ const out = o.out ?? process.stdout, err = o.err ?? process.stderr;
73
+ if (err.isTTY !== true)
74
+ return () => { };
75
+ const s = styleFor(err, o.env);
76
+ const fd = o.fd ?? 2;
77
+ const writeOut = out.write, writeErr = err.write;
78
+ // the shared cell: [0] 0 turning / 1 stop asked / 2 stopped; [1] 1 once a frame was drawn
79
+ const state = new SharedArrayBuffer(8);
80
+ const flag = new Int32Array(state);
81
+ const frames = [...FRAMES].map((f) => `\r\x1b[2K${s.cyan(f)} ${s.dim(`${label} …`)}`);
82
+ const worker = new Worker(SPINNER_WORKER, { eval: true, workerData: { fd, frames, delayMs: o.delayMs ?? SPIN_AFTER_MS, state } });
83
+ worker.unref(); // the command's own end ends it: a worker never keeps mason alive
84
+ // a frame that cannot be written (the terminal went away: EBADF, EPIPE) is a frame nobody sees; the command's own
85
+ // output and exit code are untouched by it, so the worker's failure is logged nowhere and ends nothing
86
+ worker.on("error", () => { Atomics.store(flag, 0, 2); Atomics.notify(flag, 0); });
87
+ let done = false;
88
+ const stop = () => {
89
+ if (done)
90
+ return;
91
+ done = true;
92
+ out.write = writeOut;
93
+ err.write = writeErr;
94
+ Atomics.store(flag, 0, 1);
95
+ Atomics.notify(flag, 0);
96
+ // the worker's last frame, if one is being written, lands before the clear: wait for it to say it stopped
97
+ Atomics.wait(flag, 0, 1, 500);
98
+ if (Atomics.load(flag, 1) === 1)
99
+ fs.writeSync(fd, "\r\x1b[2K");
100
+ void worker.terminate();
101
+ };
102
+ // the command's first output stops it: the line is cleared, then the output written as it was given
103
+ const first = (write, target) => ((...a) => { stop(); return write.apply(target, a); });
104
+ out.write = first(writeOut, out);
105
+ err.write = first(writeErr, err);
106
+ return stop;
107
+ }
108
+ /** The worker: waits the delay (a stop in the meantime ends it with nothing drawn), then a frame every 80 ms until
109
+ * asked to stop; it writes to the file descriptor it was given, never through a stream the main thread holds. */
110
+ const SPINNER_WORKER = `
111
+ const { workerData } = require("node:worker_threads");
112
+ const fs = require("node:fs");
113
+ const { fd, frames, delayMs, state } = workerData;
114
+ const flag = new Int32Array(state);
115
+ if (Atomics.wait(flag, 0, 0, delayMs) === "timed-out") {
116
+ let i = 0;
117
+ while (Atomics.load(flag, 0) === 0) {
118
+ try { fs.writeSync(fd, frames[i++ % frames.length]); } catch { break; } // the terminal went away: stop drawing
119
+ Atomics.store(flag, 1, 1);
120
+ Atomics.wait(flag, 0, 0, 80);
121
+ }
122
+ }
123
+ Atomics.store(flag, 0, 2);
124
+ Atomics.notify(flag, 0);
125
+ `;
126
+ /** A step that failed: what its runner throws, carrying what to tell and the exit code (the step's line is already ✘). */
127
+ export class StepFailed extends Error {
128
+ exit;
129
+ constructor(message, exit) { super(message); this.exit = exit; }
130
+ }
131
+ /**
132
+ * STEPS AS THEY HAPPEN, drawn (the renderer under checklist, and what mason start's relay draws its run's steps with):
133
+ * `begin` turns a spinner beside the step's label (a terminal only), `done` and `fail` replace it with the step's line,
134
+ * `print` writes any other line above it without breaking the spinner. Anywhere but a terminal only the finished lines.
135
+ */
136
+ export function stepRenderer(o) {
137
+ const s = o.style ?? PLAIN;
138
+ const live = o.live ?? null;
139
+ let label = null, frame = 0, timer;
140
+ const draw = () => { if (live && label !== null)
141
+ live.write(`\r\x1b[2K${s.cyan(FRAMES[frame++ % FRAMES.length])} ${label} …`); };
142
+ const clear = () => { if (live && label !== null)
143
+ live.write("\r\x1b[2K"); };
144
+ const end = (text) => { if (timer)
145
+ clearInterval(timer); timer = undefined; clear(); label = null; o.line(text); };
146
+ return {
147
+ begin(text) {
148
+ if (label !== null)
149
+ end(`${s.green(live ? "✔" : "✓")} ${label}`); // a step left open by the next one: it ended
150
+ label = text;
151
+ if (live) {
152
+ draw();
153
+ timer = setInterval(draw, 80);
154
+ timer.unref();
155
+ }
156
+ },
157
+ done(text) { end(`${s.green(live ? "✔" : "✓")} ${text ?? label ?? ""}`); },
158
+ fail(text) { end(`${s.red(live ? "✘" : "✗")} ${text ?? label ?? ""}`); },
159
+ print(text) { clear(); o.line(text); draw(); },
160
+ /** a step still turning: its label (null when none) */
161
+ open: () => label,
162
+ };
163
+ }
164
+ /**
165
+ * A CHECKLIST OF WHAT A COMMAND DOES (first `mason init`'s: each item it is doing, with a spinner left of it that turns
166
+ * into a checkmark when done, so it is seen working live, and the end result a checklist of what it did, all checked
167
+ * off). On a terminal each step turns (`⠋ Connecting to …`) until it ends, then becomes
168
+ * its line: `✔ Connected to …`, or `✘ Connecting to …` when it throws. Anywhere else only the finished lines are written
169
+ * (`✓ Connected to …`), one each, so a log or a coding agent reads the same checklist without the animation.
170
+ */
171
+ export function checklist(o) {
172
+ const r = stepRenderer(o);
173
+ return {
174
+ async step(label, run, done = () => label) {
175
+ r.begin(label);
176
+ try {
177
+ const v = await run();
178
+ r.done(done(v));
179
+ return v;
180
+ }
181
+ catch (e) {
182
+ r.fail(label);
183
+ throw e;
184
+ }
185
+ },
186
+ };
187
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE RUN'S SUPERVISOR (a run that crashed — `SELECT *` of 23 M rows ran it out of V8's heap — stayed dead,
4
+ * and the sync, the listener and every client's session with it). `mason start` in the background (src/cli/
5
+ * background.ts) starts THIS, detached, and this starts the run (`mason start … --attach`), its terminal the
6
+ * run's `dev.log` as before, and starts it again when it dies:
7
+ * - only AFTER IT WAS READY: a start that fails (a wrong password, a port held) is said and exits, as before;
8
+ * - only while its config still says it runs: `mason stop` sends SIGTERM and the run records `running: null` before it
9
+ * exits, so a stop is never undone; neither is a run that exits 0 on its own;
10
+ * - never after a stop BETWEEN restarts (the Fable review of #517: the run is dead while it waits, so `mason stop` said
11
+ * "not running" and the run came back seconds later): it records its pid in `.mason/supervisor.json`, `mason stop` and
12
+ * `mason destroy` signal it when no run answers (`stopSupervisor`), and the config is read again after the wait;
13
+ * - never over a run `mason stop` KILLED because it did not answer (on the test server the kill was recorded
14
+ * as a crash, "often the system out of memory"): stop signals the supervisor FIRST (`signalSupervisor`, no wait: the
15
+ * supervisor is waiting for the run, which is hung), then ends the run, then waits for the supervisor (`supervisorGone`),
16
+ * and the run's exit is a stop. The packaged CLI runs this file as `supervise.js` (cli/build.ts), and the supervisor is
17
+ * known by EITHER name (`isSupervisorCommand`): known by `.ts` alone, no packaged stop ever found it;
18
+ * - never in a loop: after RESTART_LIMIT restarts within RESTART_WINDOW_MS it gives up, and says so;
19
+ * - on the same Postgres port, so a tool's saved connection still reaches it (the run chose it, or MASON_PG_PORT).
20
+ * Each restart is a line in the run's log and an entry in `.mason/restarts.json` (why, when, after how long up), which
21
+ * mason status shows. DuckDB recovers the copy from its write-ahead log: a restart costs seconds, never a fresh copy.
22
+ *
23
+ * node supervise.ts <project's .mason dir> <dev.log> <the run's command…>
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { spawn, spawnSync } from "node:child_process";
28
+ import { fileURLToPath } from "node:url";
29
+ import { exitWhenWritten } from "../lib/exit-when-written.js";
30
+ export const READY_LINE = "mason start: ready";
31
+ export const RESTARTS_FILE = "restarts.json";
32
+ export const SUPERVISOR_FILE = "supervisor.json";
33
+ export const RESTART_LIMIT = 5;
34
+ export const RESTART_WINDOW_MS = 10 * 60_000;
35
+ /** the wait before each restart, by how many came before it in the window */
36
+ const BACKOFF_MS = [1_000, 2_000, 5_000, 15_000, 30_000];
37
+ /** The restarts recorded for a project's run, oldest first ([] when none; a file that cannot be read is said, not hidden). */
38
+ export function readRestarts(home) {
39
+ const file = path.join(home, RESTARTS_FILE);
40
+ if (!fs.existsSync(file))
41
+ return [];
42
+ const v = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ return Array.isArray(v.restarts) ? v.restarts : [];
44
+ }
45
+ /** Why a run died, in the words a person reads: V8's own fatal line when it printed one */
46
+ export function crashReason(code, signal, fatal) {
47
+ // Mason's own fatal line (src/cli/dev.ts: the copy's thread stopped) is said as Mason said it
48
+ if (fatal !== null)
49
+ return /heap out of memory/i.test(fatal) && !fatal.startsWith("Mason: ") ? "it ran out of JavaScript heap (V8: heap out of memory)"
50
+ : fatal.startsWith("Mason: ") ? fatal.slice("Mason: ".length) : `V8: ${fatal}`;
51
+ if (signal === "SIGKILL")
52
+ return "it was killed (SIGKILL: often the system out of memory)";
53
+ if (signal !== null)
54
+ return `it crashed (${signal})`;
55
+ return `it exited with code ${code}`;
56
+ }
57
+ /** Whether a process's command line (ps) is a run's supervisor: this file, by its checkout name or its packaged one. */
58
+ export function isSupervisorCommand(command) {
59
+ return /supervise\.[tj]s\b/.test(command);
60
+ }
61
+ /** The supervisor recorded for a project's run, when it is still alive and is a supervisor (a pid alone is no proof:
62
+ * after a reboot the number is another process's, src/cli/runs.ts), else null. */
63
+ export function supervisorOf(home) {
64
+ const file = path.join(home, SUPERVISOR_FILE);
65
+ if (!fs.existsSync(file))
66
+ return null;
67
+ let pid;
68
+ // a file that cannot be read names no process: nothing is signalled over it (ps below is what decides, never the file)
69
+ try {
70
+ pid = JSON.parse(fs.readFileSync(file, "utf8")).pid;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
76
+ return null;
77
+ const ps = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
78
+ // ps exits 1 for a pid that is gone; its command line names this file for a supervisor (background.ts starts it so)
79
+ return ps.status === 0 && isSupervisorCommand(ps.stdout) ? pid : null;
80
+ }
81
+ /** Tells the project's supervisor to stop (SIGTERM: it passes it to the run and starts nothing after); its pid, or
82
+ * null when there is none. It does not wait: a supervisor over a hung run leaves only once the run is ended. */
83
+ export function signalSupervisor(home) {
84
+ const pid = supervisorOf(home);
85
+ if (pid === null)
86
+ return null;
87
+ process.kill(pid, "SIGTERM");
88
+ return pid;
89
+ }
90
+ /** Waits for a signalled supervisor to leave; one still there after `ms` is said, never reported gone. */
91
+ export async function supervisorGone(home, pid, ms = 10_000) {
92
+ const until = Date.now() + ms;
93
+ while (supervisorOf(home) === pid && Date.now() < until)
94
+ await new Promise((r) => setTimeout(r, 100));
95
+ if (supervisorOf(home) === pid)
96
+ throw new Error(`the supervisor of Mason's run (pid ${pid}) is still running after ${ms / 1000} s: kill ${pid}`);
97
+ }
98
+ /** Stops the project's supervisor when one is waiting to start the run again; its pid, or null when there was none. */
99
+ export async function stopSupervisor(home, ms = 10_000) {
100
+ const pid = signalSupervisor(home);
101
+ if (pid === null)
102
+ return null;
103
+ await supervisorGone(home, pid, ms);
104
+ return pid;
105
+ }
106
+ /** whether the run's config still says it runs (a stop records null first) */
107
+ function stillRunning(home, note) {
108
+ try {
109
+ const c = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8"));
110
+ return c.running ?? null;
111
+ }
112
+ catch (e) {
113
+ // a config that cannot be read is no run that claims to be up: nothing is restarted over it, and it is said
114
+ note(`.mason/config.json could not be read (${e.message}): not starting the run again`);
115
+ return null;
116
+ }
117
+ }
118
+ /** What the run wrote to the log from `from` on: whether it said it was ready, and V8's fatal line if it printed one.
119
+ * Read after it exits, in chunks: a run's log with every statement traced (MASON_PG_TRACE) runs to hundreds of MB. */
120
+ export function scanLog(file, from) {
121
+ let ready = false;
122
+ let fatal = null;
123
+ const fd = fs.openSync(file, "r");
124
+ try {
125
+ const buf = Buffer.alloc(1 << 20);
126
+ let partial = "";
127
+ for (let at = from;;) {
128
+ const n = fs.readSync(fd, buf, 0, buf.length, at);
129
+ const lines = (partial + buf.toString("utf8", 0, n)).split("\n");
130
+ partial = n === 0 ? "" : lines.pop() ?? "";
131
+ for (const l of lines) {
132
+ if (l === READY_LINE)
133
+ ready = true;
134
+ const f = /^FATAL ERROR: (.*)$/.exec(l.trim());
135
+ if (f)
136
+ fatal = f[1];
137
+ }
138
+ if (n === 0)
139
+ break;
140
+ at += n;
141
+ }
142
+ }
143
+ finally {
144
+ fs.closeSync(fd);
145
+ }
146
+ return { ready, fatal };
147
+ }
148
+ /** Runs the run until it stops for good; the exit code is the last run's (a start that failed: its own). */
149
+ export async function supervise(o) {
150
+ const now = o.now ?? Date.now;
151
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
152
+ const fd = fs.openSync(o.logFile, "a", 0o600);
153
+ const note = (line) => { fs.writeSync(fd, `mason supervisor: ${line}\n`); };
154
+ let stopping = false;
155
+ let child = null;
156
+ let wake = () => undefined;
157
+ const stopped = new Promise((r) => { wake = r; });
158
+ // a signal to the supervisor (a shutdown, the terminal's group, mason stop between restarts) is the run's: passed on,
159
+ // the wait before a restart cut short, and nothing restarted after
160
+ const pass = (s) => () => { stopping = true; child?.kill(s); wake(); };
161
+ const handlers = ["SIGTERM", "SIGINT", "SIGHUP"].map((s) => [s, pass(s)]);
162
+ for (const [s, h] of handlers)
163
+ process.on(s, h);
164
+ const recent = [];
165
+ let env = {};
166
+ const mine = path.join(o.home, SUPERVISOR_FILE);
167
+ fs.writeFileSync(mine, `${JSON.stringify({ pid: process.pid })}\n`, { mode: 0o600 });
168
+ try {
169
+ for (;;) {
170
+ const started = now();
171
+ const from = fs.fstatSync(fd).size;
172
+ child = o.start(env, fd);
173
+ const c = child;
174
+ const { code, signal } = await new Promise((resolve) => c.once("exit", (code, signal) => resolve({ code, signal })));
175
+ const { ready, fatal } = scanLog(o.logFile, from);
176
+ if (stopping || !ready || (code === 0 && signal === null))
177
+ return code ?? 1;
178
+ const running = stillRunning(o.home, note);
179
+ if (running === null)
180
+ return code ?? 1;
181
+ // died after it was ready, while its config says it runs: a crash
182
+ const reason = crashReason(code, signal, fatal);
183
+ const upSeconds = Math.round((now() - started) / 1000);
184
+ while (recent.length > 0 && now() - recent[0] > RESTART_WINDOW_MS)
185
+ recent.shift();
186
+ if (recent.length >= RESTART_LIMIT) {
187
+ note(`the run stopped (${reason}) after ${upSeconds} s, its ${RESTART_LIMIT + 1}th crash in ${RESTART_WINDOW_MS / 60_000} minutes: not restarting again. mason logs --start shows why; mason start starts it`);
188
+ return code ?? 1;
189
+ }
190
+ const wait = BACKOFF_MS[Math.min(recent.length, BACKOFF_MS.length - 1)];
191
+ recent.push(now());
192
+ let before = [];
193
+ // the record is for mason status; one that cannot be read (cut short, edited) never costs the run its restart:
194
+ // said in the log, and the record starts again from this restart
195
+ try {
196
+ before = readRestarts(o.home);
197
+ }
198
+ catch (e) {
199
+ note(`${RESTARTS_FILE} could not be read (${e.message}): it is written again from this restart`);
200
+ }
201
+ const restarts = [...before, { at: new Date(now()).toISOString(), reason, upSeconds }].slice(-20);
202
+ fs.writeFileSync(path.join(o.home, RESTARTS_FILE), `${JSON.stringify({ restarts }, null, 2)}\n`, { mode: 0o600 });
203
+ note(`the run stopped (${reason}) after ${upSeconds} s: starting it again in ${wait / 1000} s (mason status shows each restart)`);
204
+ // the same Postgres port, so a client's saved connection reaches the new run
205
+ const port = running.postgresUrl === undefined ? "" : new URL(running.postgresUrl.replace(/^postgres(ql)?:/, "http:")).port;
206
+ env = port !== "" && !process.env.MASON_PG_PORT ? { MASON_PG_PORT: port } : {};
207
+ await Promise.race([sleep(wait), stopped]);
208
+ // a stop while it waited (a signal), or a config that no longer says it runs (mason destroy cleared it)
209
+ if (stopping || stillRunning(o.home, note) === null)
210
+ return code ?? 1;
211
+ }
212
+ }
213
+ finally {
214
+ for (const [s, h] of handlers)
215
+ process.off(s, h);
216
+ // only our own record goes: a new mason start may have started a supervisor of its own meanwhile
217
+ try {
218
+ if (JSON.parse(fs.readFileSync(mine, "utf8")).pid === process.pid)
219
+ fs.rmSync(mine);
220
+ }
221
+ catch (e) {
222
+ if (e.code !== "ENOENT")
223
+ note(`${SUPERVISOR_FILE} could not be removed (${e.message})`);
224
+ } // gone already: mason destroy emptied .mason/
225
+ fs.closeSync(fd);
226
+ }
227
+ }
228
+ function isMain() {
229
+ if (!process.argv[1])
230
+ return false;
231
+ try {
232
+ return fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
233
+ }
234
+ catch {
235
+ return false;
236
+ } // argv[1] names no file (node -e): not run as a script
237
+ }
238
+ if (isMain()) {
239
+ const [home, logFile, command, ...args] = process.argv.slice(2);
240
+ if (home === undefined || logFile === undefined || command === undefined) {
241
+ process.stderr.write("usage: supervise.ts <.mason dir> <dev.log> <the run's command…>\n");
242
+ exitWhenWritten(2);
243
+ }
244
+ else {
245
+ exitWhenWritten(await supervise({ home, logFile,
246
+ start: (env, fd) => spawn(command, args, { stdio: ["ignore", fd, fd], env: { ...process.env, ...env } }) }));
247
+ }
248
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `mason sync <table>…`: Mason's poll of exactly these tables, now — named for what it does to the copy
3
+ * (made current, incrementally), never for the mechanism (`poll` was). The passes poll every added table but one over
4
+ * the poll's row limit (src/duck/sync.ts POLL_ROW_LIMIT: each poll scans its table whole on the database), which is
5
+ * copied and served as it was copied; this reads what changed in it since, when asked. The run's `POST /api/sync`
6
+ * (src/cli/run-server.ts) answers it, through the sync's own poll (`Sync.pollNow`).
7
+ */
8
+ import { failed, jsonText, parseCommand, reportText } from "./command.js";
9
+ import { out as styleOut } from "./style.js";
10
+ import { callRun } from "./control.js";
11
+ import { reach } from "./state.js";
12
+ import { findTable, guarded, readState, tableArg } from "./commands.js";
13
+ import { didYouMean } from "../lib/did-you-mean.js";
14
+ import { POLL_ROW_LIMIT } from "../duck/sync.js";
15
+ const out = (m) => { process.stdout.write(`${m}\n`); };
16
+ const n = (x) => x.toLocaleString("en-US");
17
+ export const SYNC_USAGE = `usage: mason sync <table>… [--json]
18
+
19
+ Makes Mason's copy of these tables current now: it reads what changed in your database since Mason last read them —
20
+ the rows inserted or updated, and the ones deleted. Mason syncs every added table by itself every few seconds, but
21
+ a table over ${n(POLL_ROW_LIMIT)} rows is copied and served without that (each sync scans a table whole on your
22
+ database): mason status says which, and this syncs one when you need it current. mason start --replication keeps
23
+ every table current from the database's change log, whatever its size.
24
+
25
+ mason sync orders
26
+ mason sync orders public.events
27
+
28
+ --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }], next }
29
+ `;
30
+ export const SYNC = {
31
+ needs: "run",
32
+ name: "sync",
33
+ summary: "make the copy of these tables current now: mason sync orders (a table over the sync's row limit is synced only so)",
34
+ usage: SYNC_USAGE,
35
+ options: { json: { type: "boolean" } },
36
+ json: ["syncedAt", "tables", "next"],
37
+ };
38
+ export async function sync(argv) {
39
+ const f = parseCommand(SYNC, argv);
40
+ if (typeof f === "number")
41
+ return f;
42
+ const json = f.values.json === true;
43
+ const names = [...new Set(f.positionals)];
44
+ if (!names.length)
45
+ return failed("sync", { message: "name a table: mason sync orders (mason status lists the added ones)", code: "usage", usage: SYNC_USAGE }, { json });
46
+ const w = await reach(SYNC, json);
47
+ if (w === null || !("run" in w))
48
+ return 1;
49
+ const run = w.run;
50
+ return guarded("sync", json, async () => {
51
+ const s = await readState(run);
52
+ const added = s.tables.filter((t) => t.added);
53
+ const picked = [];
54
+ for (const name of names) {
55
+ const t = findTable(added, name);
56
+ if (!("message" in t)) {
57
+ picked.push(t.table);
58
+ continue;
59
+ }
60
+ // a table of the database that is not added is told so, with the command that adds it
61
+ const db = findTable(s.tables, name);
62
+ if ("message" in db && db.code === "usage")
63
+ return failed("sync", db, { json });
64
+ if (!("message" in db)) {
65
+ return failed("sync", { message: `${db.table} is not added, so Mason has no copy of it to sync`, code: "not_found", fix: `mason add ${tableArg(db.table)}`,
66
+ next: [["mason status", "the added tables"]] }, { json });
67
+ }
68
+ return failed("sync", { message: `there is no added table ${JSON.stringify(name)}${didYouMean(name, added.flatMap((x) => [x.table, tableArg(x.table)]))}`, code: "not_found",
69
+ next: [["mason status", "the added tables"], ["mason tables", "the database's tables"]] }, { json });
70
+ }
71
+ // a sync scans each table whole on the database (and waits for a copy under way): minutes, not the commands' 30 s
72
+ const r = await callRun(run, "POST", "/api/sync", { tables: picked }, 60 * 60_000);
73
+ if (r.status !== 200) {
74
+ const code = typeof r.body.code === "string" ? r.body.code : r.status === 404 ? "not_found" : "failed";
75
+ return failed("sync", { message: `${picked.join(", ")} ${picked.length === 1 ? "was" : "were"} not synced`, code, why: String(r.body.error),
76
+ next: [["mason status", "each table's copy"], ["mason logs", "the run's log"]] }, { json });
77
+ }
78
+ const b = r.body;
79
+ const unsynced = added.filter((t) => picked.includes(t.table) && t.unsynced);
80
+ const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "each table's copy, and which the sync leaves out"]];
81
+ if (json) {
82
+ out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables }, next));
83
+ return 0;
84
+ }
85
+ const changed = b.tables.reduce((a, t) => a + t.changed, 0), deleted = b.tables.reduce((a, t) => a + t.deleted, 0);
86
+ out(reportText({
87
+ headline: `Synced ${picked.join(", ")}: ${n(changed)} row(s) written, ${n(deleted)} deleted.`,
88
+ details: b.tables.map((t) => [t.table, `${n(t.changed)} changed, ${n(t.deleted)} deleted, ${n(t.rows)} rows now · ${n(t.ms)} ms`]),
89
+ tips: unsynced.map((t) => `${t.table} is ${t.unsynced.reason}: it stays as of this sync until the next mason sync (mason start --replication keeps every table current)`),
90
+ next,
91
+ }, styleOut()));
92
+ return 0;
93
+ });
94
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): what `mason status` says,
3
+ * refreshed while the tab is seen, and the users of `mason user`. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
4
+ * it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
5
+ * password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
6
+ * `mason status`, which prints the URL without it.
7
+ *
8
+ * Before it prints anything it ASKS the page (`GET /` with the run's token as the commands send it), so a run that serves
9
+ * none (`mason serve`'s, or one from before the page) is said, never a URL that answers 404.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { failed, jsonText, parseCommand, reportText } from "./command.js";
13
+ import { reach } from "./state.js";
14
+ import { RunUnreachable } from "./control.js";
15
+ const out = (m) => { process.stdout.write(`${m}\n`); };
16
+ export const UI = {
17
+ needs: "run",
18
+ name: "ui",
19
+ summary: "the status page and the users, in your browser",
20
+ usage: `usage: mason ui [--no-open] [--json]
21
+
22
+ Opens the running Mason's web page in your browser: what mason status says (the database, each table's copy and
23
+ its progress, the slow and refused statements, whether it is ready), refreshed every 2 s while the tab is open, and
24
+ the users of mason user — add one (its password shown once, with the URL to paste) or remove one.
25
+
26
+ The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
27
+ bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
28
+ token, so after a restart run mason ui again. The page is mason start's: mason serve serves none (off this
29
+ machine it would need TLS and a login).
30
+
31
+ mason ui
32
+ mason ui --no-open print the URL, open no browser
33
+ mason ui --json
34
+
35
+ --no-open print the URL only
36
+ --json as data: { url (with this run's token), port }, next; it never opens a browser
37
+ `,
38
+ options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false } },
39
+ json: ["url", "port", "next"],
40
+ };
41
+ const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
42
+ /** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
43
+ export function openCommand(platform, url) {
44
+ if (platform === "darwin")
45
+ return ["open", [url]];
46
+ // start's first quoted argument is a window title: "" so the URL is the thing started
47
+ if (platform === "win32")
48
+ return ["cmd", ["/c", "start", "", url]];
49
+ return ["xdg-open", [url]];
50
+ }
51
+ /** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
52
+ function openBrowser(url) {
53
+ const [command, args] = openCommand(process.platform, url);
54
+ return new Promise((resolve) => {
55
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
56
+ child.once("error", (e) => resolve(`${command}: ${e.message}`));
57
+ child.once("spawn", () => { child.unref(); resolve(null); });
58
+ });
59
+ }
60
+ /** Whether the run serves the page: its `GET /` answered, with the token the commands send. */
61
+ async function servesPage(run) {
62
+ let res;
63
+ try {
64
+ res = await fetch(`${run.control.url}/`, { headers: { Authorization: `Bearer ${run.control.token}` }, signal: AbortSignal.timeout(10_000) });
65
+ }
66
+ catch (e) {
67
+ throw new RunUnreachable(`Mason for ${run.name} (pid ${run.pid}) does not answer at ${run.control.url}`, e.cause?.code ?? e.message);
68
+ }
69
+ await res.arrayBuffer();
70
+ return res.status === 200 && /^text\/html/.test(res.headers.get("content-type") ?? "");
71
+ }
72
+ export async function ui(argv) {
73
+ const f = parseCommand(UI, argv);
74
+ if (typeof f === "number")
75
+ return f;
76
+ const json = f.values.json === true;
77
+ if (f.positionals.length)
78
+ return failed("ui", { message: `mason ui takes no argument: ${f.positionals.join(" ")}`, code: "usage", usage: UI.usage }, { json });
79
+ const w = await reach(UI, json);
80
+ if (w === null || w.state !== "up")
81
+ return 1;
82
+ const run = w.run;
83
+ if (!await servesPage(run)) {
84
+ return failed("ui", { message: `Mason for ${run.name} serves no web page`, code: "refused",
85
+ why: "the page is mason start's: this is mason serve's run, or a run started by a Mason from before the page",
86
+ fix: "on your machine, mason stop && mason start runs it with the page; a server's Mason is read with mason status",
87
+ next: [["mason status", "what it is doing, in this terminal"]] }, { json });
88
+ }
89
+ const url = `${run.control.url}/?token=${encodeURIComponent(run.control.token)}`;
90
+ const port = Number(new URL(run.control.url).port);
91
+ if (json) {
92
+ out(jsonText(UI, { url, port }, NEXT));
93
+ return 0;
94
+ }
95
+ const couldNot = f.values["no-open"] === true ? null : await openBrowser(url);
96
+ const opened = f.values["no-open"] !== true && couldNot === null;
97
+ out(reportText({
98
+ headline: opened ? `Mason's web page, opened in your browser` : `Mason's web page`,
99
+ details: [["url", url], ["project", run.name]],
100
+ tips: [
101
+ ...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
102
+ "The URL carries this run's token, traded for a cookie on the first visit: paste it nowhere else. After a restart, mason ui again.",
103
+ ],
104
+ next: NEXT,
105
+ }));
106
+ return 0;
107
+ }