@mason-data/cli 0.6.2 → 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/README.md +71 -12
- package/dist/src/api/postgres/catalog.js +36 -1
- package/dist/src/api/postgres/server.js +133 -32
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cli/background.js +29 -5
- package/dist/src/cli/command.js +4 -3
- package/dist/src/cli/commands.js +259 -127
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +113 -20
- package/dist/src/cli/doctor.js +6 -3
- package/dist/src/cli/guides.js +32 -8
- package/dist/src/cli/init.js +58 -18
- package/dist/src/cli/machine.js +8 -3
- package/dist/src/cli/main.js +2 -2
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/preflight.js +16 -7
- package/dist/src/cli/project.js +5 -3
- package/dist/src/cli/registry.js +8 -5
- package/dist/src/cli/run-server.js +29 -106
- package/dist/src/cli/serve.js +6 -1
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +6 -4
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +7 -2
- package/dist/src/duck/executor.js +67 -26
- package/dist/src/duck/explain.js +128 -9
- package/dist/src/duck/functions.js +60 -5
- package/dist/src/duck/gaps.js +9 -4
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +206 -39
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +21 -8
- package/dist/src/duck/rewrite.js +1168 -68
- package/dist/src/duck/store.js +132 -56
- package/dist/src/duck/sync.js +90 -25
- package/dist/src/duck/verified.js +6 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +11 -0
- package/dist/src/web/app.js +167 -12
- package/dist/src/web/index.html +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DEMO PROJECT, THE CLI'S SIDE (`mason init demo`; the database itself is src/demo/source.ts): a project whose
|
|
3
|
+
* database is one Mason hosts itself — a Postgres in this process, persisted in `.mason/demo/`, served on a loopback
|
|
4
|
+
* port over the Postgres wire protocol and seeded with a small shop schema — so a person tries Mason with nothing to
|
|
5
|
+
* install. The project stays an ordinary one: its sources file names `url: ${DATABASE_URL}` and three of the four
|
|
6
|
+
* tables (the fourth, `DEMO_TABLE_TO_ADD`, is the person's to add), `.env` the demo's URL, and `mason start` reads it
|
|
7
|
+
* over the wire as it reads any Postgres, so the demo runs the read path production runs (docs/DESIGN.md "The demo
|
|
8
|
+
* database").
|
|
9
|
+
*
|
|
10
|
+
* - THE PORT LIVES IN THE URL ALONE: `mason init demo` picks one free and writes it into `.env`; `mason start` reads
|
|
11
|
+
* it back from the resolved URL (`demoPort`), never from a second record that could disagree with it.
|
|
12
|
+
* - POLL ONLY: the demo database has no change log (no WAL sender), so `--replication` is refused before anything
|
|
13
|
+
* starts (`demoChangesRefusal`).
|
|
14
|
+
* - A WRITER: every MASON_DEMO_CHANGE_MS (`demoSettings`) the run makes one round of changes (`demoChanges`), so the
|
|
15
|
+
* copy is seen keeping current.
|
|
16
|
+
*
|
|
17
|
+
* The demo's module is imported when it is used (`demoModule`): a command that never hosts it never loads its database;
|
|
18
|
+
* its URL and names are src/demo/seed.ts's, which loads none.
|
|
19
|
+
*/
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { parseSource, isLocalHost } from "./preflight.js";
|
|
22
|
+
import { changesOf } from "./replication-mode.js";
|
|
23
|
+
import { heldText, portHolder } from "./ports.js";
|
|
24
|
+
import { DEMO_DIR, STATE_DIR } from "./project.js";
|
|
25
|
+
import { DEMO_DATABASE, DEMO_USER, demoUrl } from "../demo/seed.js";
|
|
26
|
+
/** What `mason init` takes in the URL's place to make a demo project. */
|
|
27
|
+
export const DEMO_ARG = "demo";
|
|
28
|
+
/** The port `mason init demo` prefers for the demo database (the next free one when it is held): beside Mason's 5433. */
|
|
29
|
+
export const DEMO_PORT = 5434;
|
|
30
|
+
/** The demo database's tables (the seed's own list), the ones `mason init demo` adds to the sources file and the one it
|
|
31
|
+
* leaves for the person to add, and its URL (src/demo/seed.ts: no PGlite is loaded by it, so `mason init demo` and
|
|
32
|
+
* `demoPort` never load the database). */
|
|
33
|
+
export { DEMO_ADDED_TABLES, DEMO_TABLES, DEMO_TABLE_TO_ADD, demoUrl } from "../demo/seed.js";
|
|
34
|
+
/** How often the run changes the demo database by default (MASON_DEMO_CHANGE_MS). */
|
|
35
|
+
export const DEMO_CHANGE_MS = 5000;
|
|
36
|
+
const DEMO_CHANGE_MIN_MS = 100;
|
|
37
|
+
/** The demo database's module (`DemoSource`, and PGlite with it), loaded when the run hosts it, never to build or read
|
|
38
|
+
* its URL (src/demo/seed.ts has those). */
|
|
39
|
+
export const demoModule = () => import("../demo/source.js");
|
|
40
|
+
/** What the run reads of the demo from its environment: how often it changes the database. Throws on a value that is no
|
|
41
|
+
* number of milliseconds, said with the way out (as src/duck/sync.ts copyParallelOf). */
|
|
42
|
+
export function demoSettings(env) {
|
|
43
|
+
const v = env.MASON_DEMO_CHANGE_MS;
|
|
44
|
+
if (v === undefined || v === "")
|
|
45
|
+
return { changeMs: DEMO_CHANGE_MS };
|
|
46
|
+
const n = Number(v);
|
|
47
|
+
if (!/^\d+$/.test(v.trim()) || n < DEMO_CHANGE_MIN_MS) {
|
|
48
|
+
throw new Error(`MASON_DEMO_CHANGE_MS=${JSON.stringify(v)} is not a number of milliseconds: how often the demo database changes (a new order, an older one's status moved on); ` +
|
|
49
|
+
`set it to ${DEMO_CHANGE_MIN_MS} or more, or unset it for ${DEMO_CHANGE_MS}`);
|
|
50
|
+
}
|
|
51
|
+
return { changeMs: n };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* THE DEMO DATABASE'S PORT, READ FROM THE URL THE SOURCES FILE RESOLVED (`from` says where: `DATABASE_URL in .env`) — or
|
|
55
|
+
* why that URL is not the demo's, with the way out: a host off this machine (the demo database listens on 127.0.0.1
|
|
56
|
+
* alone), or any other change to `postgres://demo@127.0.0.1:<port>/demo`. The port is the one fact that may differ.
|
|
57
|
+
*/
|
|
58
|
+
export function demoPort(url, from) {
|
|
59
|
+
const wayOut = "mason destroy and mason init demo again in a new folder, or put the URL back";
|
|
60
|
+
const parsed = parseSource(url);
|
|
61
|
+
if ("problem" in parsed)
|
|
62
|
+
return { problem: `${from} was changed: ${parsed.problem}. The demo database is postgres://${DEMO_USER}@127.0.0.1:<port>/${DEMO_DATABASE}; ${wayOut}` };
|
|
63
|
+
const s = parsed.source;
|
|
64
|
+
// loopback alone: the demo database listens on nothing else (0.0.0.0 or a container's host name is not it)
|
|
65
|
+
if (!isLocalHost(s.host) || s.host === "0.0.0.0" || s.host === "host.docker.internal") {
|
|
66
|
+
return { problem: `${from} names ${s.host}, which is not this machine's loopback: the demo database is hosted by mason start on 127.0.0.1 alone (${demoUrl(s.port)}); ${wayOut}` };
|
|
67
|
+
}
|
|
68
|
+
if (s.url !== demoUrl(s.port))
|
|
69
|
+
return { problem: `${from} was changed: the demo database is ${demoUrl(s.port)}; ${wayOut}` };
|
|
70
|
+
return { port: s.port };
|
|
71
|
+
}
|
|
72
|
+
/** The refusal of logical replication for a demo project (the flag, or a mode the config was edited to): before
|
|
73
|
+
* anything starts, with the way out; null for any other project, or a demo one polled. */
|
|
74
|
+
export function demoChangesRefusal(cfg, flag) {
|
|
75
|
+
if (cfg.demo !== true || changesOf(flag, cfg.changes) !== "replication")
|
|
76
|
+
return null;
|
|
77
|
+
return { message: "the demo database is read by polling only: --replication does not apply to it", code: "refused",
|
|
78
|
+
why: "the demo database has no change log for logical replication to read; mason start polls it with SELECTs, every few seconds",
|
|
79
|
+
fix: flag === "replication" ? "mason start, without --replication" : `remove "changes": "replication" from ${STATE_DIR}/config.json, then mason start`,
|
|
80
|
+
next: [["mason start", "the demo database and Mason, polled"]] };
|
|
81
|
+
}
|
|
82
|
+
/** The step's done line: where the demo database listens, and whether it was seeded now or kept from the last run. */
|
|
83
|
+
export function demoDoneLine(port, seeded) {
|
|
84
|
+
return `Demo database on 127.0.0.1:${port} (Postgres, hosted by this process in ${STATE_DIR}/${DEMO_DIR}; ${seeded ? "seeded with the shop schema" : "kept from the last run"})`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* THE DEMO DATABASE OPENED for the run in `home`/demo (`home` the project's `.mason/`), its events to `log`: the handle and
|
|
88
|
+
* the step's done line, or why it did not open — its own message (what failed), and when something holds the port, the
|
|
89
|
+
* way out ONCE, with its choices: mason stop for a run of this project's that may still hold it, stop what does, or move
|
|
90
|
+
* the demo database by changing the port in its URL, where `from` says the URL is (`DATABASE_URL in .env`: the port
|
|
91
|
+
* lives there alone). The port is free for every other reason, so the probe tells a held port apart without reading
|
|
92
|
+
* the message.
|
|
93
|
+
*/
|
|
94
|
+
export async function openDemo(o) {
|
|
95
|
+
const open = o.open ?? (async (a) => (await demoModule()).DemoSource.open(a));
|
|
96
|
+
try {
|
|
97
|
+
const demo = await open({ dir: path.join(o.home, DEMO_DIR), port: o.port, log: o.log });
|
|
98
|
+
return { demo, done: demoDoneLine(demo.port, demo.seeded) };
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
const problem = `the demo database did not start: ${e.message}`;
|
|
102
|
+
const held = await portHolder(o.port);
|
|
103
|
+
if (held === null)
|
|
104
|
+
return { problem };
|
|
105
|
+
const move = `move the demo database: change the port in ${o.from} (postgres://${DEMO_USER}@127.0.0.1:<another>/${DEMO_DATABASE}) and mason start again`;
|
|
106
|
+
return { problem: held.code === "EACCES"
|
|
107
|
+
? `${problem} (${heldText(o.port, held)}): ${move}`
|
|
108
|
+
: `${problem}: mason stop ends a run that may still hold it, else stop what does (lsof -nP -iTCP:${o.port} -sTCP:LISTEN names it), or ${move}` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* THE WRITER: one round of changes every `ms`, each said to `log` as the demo says it; a round that fails is said and
|
|
113
|
+
* the next one tried, and a round is never begun while the last one runs. Returns what stops it (idempotent).
|
|
114
|
+
*/
|
|
115
|
+
export function demoChanges(demo, ms, log) {
|
|
116
|
+
let busy = false;
|
|
117
|
+
const timer = setInterval(() => {
|
|
118
|
+
if (busy)
|
|
119
|
+
return;
|
|
120
|
+
busy = true;
|
|
121
|
+
demo.change().then((line) => log(line), (e) => log(`a change failed (the next one is tried in ${ms / 1000} s): ${e.message}`))
|
|
122
|
+
.finally(() => { busy = false; });
|
|
123
|
+
}, ms);
|
|
124
|
+
return () => clearInterval(timer);
|
|
125
|
+
}
|
|
126
|
+
/** The demo database closed once, however often it is asked: null when it closed, else what went wrong (said by the
|
|
127
|
+
* caller, never swallowed). */
|
|
128
|
+
export function closingOnce(demo) {
|
|
129
|
+
let closing = null;
|
|
130
|
+
return () => closing ??= demo.close().then(() => null, (e) => e.message);
|
|
131
|
+
}
|
|
132
|
+
/** What the crash path says of a demo database still closing when it stops waiting (`closedWithin`). */
|
|
133
|
+
export const DEMO_STILL_CLOSING = (ms) => `closing the demo database: still closing after ${ms / 1000} s; its data may need seeding again at the next start`;
|
|
134
|
+
/**
|
|
135
|
+
* A close waited for `ms` at most, and SAID when it is not done by then (`late`), never left silent: the crash path
|
|
136
|
+
* stops waiting so the supervisor can start the run again. The timer is cleared when the close wins, and is not
|
|
137
|
+
* unref'd, so a close that hangs is said before the process ends. A close that rejects rejects this.
|
|
138
|
+
*/
|
|
139
|
+
export async function closedWithin(closing, ms, late) {
|
|
140
|
+
let timer;
|
|
141
|
+
const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(true), ms); });
|
|
142
|
+
try {
|
|
143
|
+
if (await Promise.race([closing.then(() => false), timedOut]))
|
|
144
|
+
late();
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
}
|
|
149
|
+
}
|
package/dist/src/cli/dev.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* `mason start` (one run per project): Mason for the project of this folder
|
|
3
3
|
* (src/cli/project.ts), on the database its sources file names (src/cli/sources.ts), copying the tables it names.
|
|
4
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.
|
|
5
8
|
* 1. THE DATABASE (src/cli/preflight.ts checkSource): connect as the run will, say what to change when it cannot;
|
|
6
9
|
* a database on another machine only when the project was made with --remote. A database restored to another server
|
|
7
10
|
* is read again (its rows compared, the unchanged kept); another database is refused (mason destroy, then mason start).
|
|
@@ -30,7 +33,8 @@ import { createRunServer } from "./run-server.js";
|
|
|
30
33
|
import { addedTables, rereadSources } from "./sources.js";
|
|
31
34
|
import { newToken } from "./security.js";
|
|
32
35
|
import { liveRun } from "./runs.js";
|
|
33
|
-
import { DUCK_FILE, projectSource, theProject, writeConfig } from "./project.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";
|
|
34
38
|
import { postgresApiUrl, readPgTls } from "../api/postgres/listener.js";
|
|
35
39
|
import { certificateFingerprint, elsewhereTip, ensureSelfSigned, listenerText, runListenerSettings, SELF_SIGNED_FILES } from "./pg-tls.js";
|
|
36
40
|
import { startPostgresApi, timeoutMs } from "../api/postgres/server.js";
|
|
@@ -73,7 +77,34 @@ export function sourceMove(recorded, now) {
|
|
|
73
77
|
return { action: "reread" };
|
|
74
78
|
return { action: "same" };
|
|
75
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
|
+
*/
|
|
76
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) {
|
|
77
108
|
const t = theProject(process.cwd());
|
|
78
109
|
if ("problem" in t) {
|
|
79
110
|
say(`mason start: ${t.problem}`);
|
|
@@ -89,13 +120,37 @@ export async function dev(o) {
|
|
|
89
120
|
say(`mason start: Mason is already running for ${cfg.name} (pid ${other.pid}): mason status says what it is doing`);
|
|
90
121
|
return 1;
|
|
91
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
|
+
}
|
|
92
137
|
const pinned = cliPinProblem(folder, o.version);
|
|
93
138
|
if (pinned) {
|
|
94
139
|
say(`mason start: ${pinned}`);
|
|
95
140
|
return 1;
|
|
96
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] : []);
|
|
97
152
|
// THE PORT IS THE PROJECT'S (src/cli/ports.ts): the first start's choice recorded, every later start on it or refused
|
|
98
|
-
const pg = await choosePgPort(process.env.MASON_PG_PORT,
|
|
153
|
+
const pg = await choosePgPort(process.env.MASON_PG_PORT, demoPorts, cfg.pgPort ?? null);
|
|
99
154
|
if ("problem" in pg)
|
|
100
155
|
return failed("start", pg, { json: false });
|
|
101
156
|
if (cfg.pgPort !== pg.port) {
|
|
@@ -110,17 +165,41 @@ export async function dev(o) {
|
|
|
110
165
|
const pgTls = listenerTls(listen, o.selfSignedTls, home, cfg);
|
|
111
166
|
if ("problem" in pgTls)
|
|
112
167
|
return failed("start", pgTls, { json: false });
|
|
113
|
-
const chosen = new Set([pg.port]);
|
|
168
|
+
const chosen = new Set([pg.port, ...demoPorts]);
|
|
114
169
|
// a server's project folder is the team's, committed, and may be mounted read-only: mason serve writes nothing there
|
|
115
170
|
const wrotePin = o.serve ? null : ensurePin(folder, o.version);
|
|
116
171
|
if (wrotePin)
|
|
117
172
|
say(` wrote ${path.relative(project.root, wrotePin)}: version ${o.version}, the Mason this project is written for — commit it`);
|
|
118
|
-
// THE SOURCE (src/cli/sources.ts): the database and the tables to copy, from the project's sources file; a server's
|
|
119
|
-
// URL from its environment first (mason serve is configured by it alone)
|
|
120
|
-
const readSources = () => projectSource(project, process.env, { shellFirst: o.serve !== undefined });
|
|
121
|
-
const src = readSources();
|
|
122
173
|
if ("message" in src)
|
|
123
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
|
+
}
|
|
124
203
|
const db = maskedUrl(src.resolved);
|
|
125
204
|
begin(`Connecting to ${db}`);
|
|
126
205
|
const checkSaid = [];
|
|
@@ -261,15 +340,6 @@ export async function dev(o) {
|
|
|
261
340
|
say(`mason start: ${e.message}`);
|
|
262
341
|
return 1;
|
|
263
342
|
}
|
|
264
|
-
const logFile = path.join(home, "mason.log");
|
|
265
|
-
const log = fs.createWriteStream(logFile, { flags: "a" });
|
|
266
|
-
// one entry a line: a multi-line message's further lines indented under it (src/lib/log.ts `textEntry`);
|
|
267
|
-
// mason serve gets the same entry, for the container's stdout (src/cli/serve.ts)
|
|
268
|
-
const logLine = (l) => {
|
|
269
|
-
const entry = textEntry(`${new Date().toISOString()} `, l);
|
|
270
|
-
log.write(entry);
|
|
271
|
-
o.serve?.log(entry.trimEnd());
|
|
272
|
-
};
|
|
273
343
|
begin("Opening the copy");
|
|
274
344
|
const duckFile = path.join(home, DUCK_FILE);
|
|
275
345
|
let store;
|
|
@@ -430,6 +500,7 @@ export async function dev(o) {
|
|
|
430
500
|
slot: changes === "replication" ? repl.slot : "", changes, port: setupPort, token, folder: () => folder, root: project.root,
|
|
431
501
|
// the web page (mason ui) is mason start's alone: mason serve's run serves none (src/cli/run-server.ts RunContext.page)
|
|
432
502
|
page: o.serve === undefined,
|
|
503
|
+
demo: cfg.demo === true,
|
|
433
504
|
users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database: cfg.name, postgresUrl },
|
|
434
505
|
...(changes === "replication" ? { publication: { name: repl.publication, owned: repl.owned } } : {}),
|
|
435
506
|
// the sources file is the list of what is added: a change to it is read by the sync at once
|
|
@@ -458,6 +529,9 @@ export async function dev(o) {
|
|
|
458
529
|
writeConfig(home, cfg);
|
|
459
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"})`
|
|
460
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}`));
|
|
461
535
|
// the stop is in place BEFORE the ready line: a supervisor or a test signals on reading it, and a SIGTERM that found
|
|
462
536
|
// no handler would end the run with the default action, never recording `running: null`
|
|
463
537
|
let stopping = false;
|
|
@@ -470,11 +544,15 @@ export async function dev(o) {
|
|
|
470
544
|
stopping = true;
|
|
471
545
|
say(`FATAL ERROR: Mason: the copy's thread stopped (${reason})`);
|
|
472
546
|
clearInterval(refreshFacts);
|
|
547
|
+
hosted.stopChanges?.();
|
|
473
548
|
server.close();
|
|
474
549
|
void (async () => {
|
|
475
550
|
// the sessions were told; the listener is closed so that none waits on a copy that cannot answer
|
|
476
551
|
const closed = api.close().catch((e) => say(` closing the listener: ${e.message}`));
|
|
477
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)}`));
|
|
478
556
|
log.end();
|
|
479
557
|
resolve(1);
|
|
480
558
|
})();
|
|
@@ -486,6 +564,7 @@ export async function dev(o) {
|
|
|
486
564
|
o.serve?.stopping();
|
|
487
565
|
say("\nmason start: stopping …");
|
|
488
566
|
clearInterval(refreshFacts);
|
|
567
|
+
hosted.stopChanges?.();
|
|
489
568
|
server.close();
|
|
490
569
|
void (async () => {
|
|
491
570
|
try {
|
|
@@ -501,13 +580,25 @@ export async function dev(o) {
|
|
|
501
580
|
catch (e) {
|
|
502
581
|
say(` closing the request log: ${e.message}`);
|
|
503
582
|
}
|
|
583
|
+
let syncStop = null;
|
|
504
584
|
try {
|
|
505
585
|
await sync.stop();
|
|
506
|
-
await store.close();
|
|
507
|
-
say(` closed the copy (it stays in ${duckFile})`);
|
|
508
586
|
}
|
|
509
587
|
catch (e) {
|
|
510
|
-
|
|
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
|
+
}
|
|
511
602
|
}
|
|
512
603
|
if (syncMode === "replication")
|
|
513
604
|
say(` replication slot ${repl.slot} stays on ${source.database}, holding its WAL until the next mason start reads it (mason destroy drops it)` +
|
|
@@ -528,6 +619,8 @@ export async function dev(o) {
|
|
|
528
619
|
// the web page by its command, never its URL: the URL carries the run's token, and this goes to dev.log
|
|
529
620
|
if (o.serve === undefined)
|
|
530
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`);
|
|
531
624
|
const elsewhere = elsewhereTip(pgTls.listener);
|
|
532
625
|
if (elsewhere)
|
|
533
626
|
say(` ${elsewhere}`);
|
|
@@ -571,6 +664,6 @@ function listenerTls(listen, selfSigned, home, cfg) {
|
|
|
571
664
|
/** The line a run writes once it answers commands: what `mason start` in the background waits for (src/cli/background.ts),
|
|
572
665
|
* and what tells its supervisor a death after it is a crash to restart (src/cli/supervise.ts, which defines it). */
|
|
573
666
|
export const READY = READY_LINE;
|
|
574
|
-
/** The one sentence of how the pieces fit, printed first by `mason start` and at the top of its help
|
|
667
|
+
/** The one sentence of how the pieces fit, printed first by `mason start` and at the top of its help. */
|
|
575
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; " +
|
|
576
669
|
"this machine's copy of your data and Mason's state are in .mason/, rebuilt from that file.";
|
package/dist/src/cli/doctor.js
CHANGED
|
@@ -111,11 +111,11 @@ function portOf(url) {
|
|
|
111
111
|
} // not a URL: no port to compare with
|
|
112
112
|
}
|
|
113
113
|
/** The database a URL names: reachable, its version, what this user can read. */
|
|
114
|
-
export async function databaseCheck(url) {
|
|
114
|
+
export async function databaseCheck(url, o = {}) {
|
|
115
115
|
const parsed = parseSource(url);
|
|
116
116
|
if ("problem" in parsed)
|
|
117
117
|
return { name: "database", ok: false, detail: parsed.problem };
|
|
118
|
-
const connected = await connectSource(parsed.source);
|
|
118
|
+
const connected = await connectSource(parsed.source, o);
|
|
119
119
|
if ("failure" in connected)
|
|
120
120
|
return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` };
|
|
121
121
|
let facts;
|
|
@@ -168,6 +168,9 @@ export function nextSteps(checks, run, project) {
|
|
|
168
168
|
return [["mason doctor", `again, once Node ${NODE_MIN} or later is installed`]];
|
|
169
169
|
if (bad("project") && run === null)
|
|
170
170
|
return [["mason doctor", "again, once .mason/config.json is repaired or moved aside (the detail above says how)"]];
|
|
171
|
+
// the demo database is hosted by the run itself (src/cli/demo.ts): down until mason start makes it
|
|
172
|
+
if (bad("database") && project?.config.demo === true)
|
|
173
|
+
return [["mason start", "hosts the demo database, then copies its tables"]];
|
|
171
174
|
if (bad("database"))
|
|
172
175
|
return [["mason start", `once the connection works (the detail above says how; the URL is in ${project ? `${project.dir}/config.json` : ".mason/config.json"})`]];
|
|
173
176
|
if (project === null)
|
|
@@ -224,7 +227,7 @@ export async function doctor(argv, deps = {}) {
|
|
|
224
227
|
// the database the project's sources file names (src/cli/sources.ts): a file that cannot be read is the failing line
|
|
225
228
|
const src = projectSource(project);
|
|
226
229
|
checks.push("message" in src ? { name: "database", ok: false, detail: `${src.message}${src.why ? `: ${src.why.replace(/\n/g, "; ")}` : ""}. ${src.fix?.split("\n")[0] ?? ""}`.trim() }
|
|
227
|
-
: await databaseCheck(src.resolved));
|
|
230
|
+
: await databaseCheck(src.resolved, { demo: project.config.demo === true }));
|
|
228
231
|
}
|
|
229
232
|
// a project here whose config cannot be read: init would refuse, so "Ready: mason init" would send a person nowhere
|
|
230
233
|
if ("code" in which && which.code === "bad_config")
|
package/dist/src/cli/guides.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* A guide's name is never a command's (tests/cli-help.test.ts): one word never means two pages.
|
|
9
9
|
*/
|
|
10
|
+
import { DEMO_ADDED_TABLES, DEMO_TABLES, DEMO_TABLE_TO_ADD } from "../demo/seed.js";
|
|
10
11
|
/** The guides, by the name `mason help <guide>` takes. */
|
|
11
12
|
export const GUIDES = {
|
|
12
13
|
agent: "driving Mason from a coding agent: the commands in order, --json and its shapes, exit codes, failures",
|
|
@@ -29,16 +30,24 @@ async function agentGuide() {
|
|
|
29
30
|
// imported here, not at the top: the registry imports this file for the HELP usage
|
|
30
31
|
const { STEPS } = await import("./registry.js");
|
|
31
32
|
const { FAILURE_CODES } = await import("./command.js");
|
|
32
|
-
const noJson = new Set(["help"]);
|
|
33
33
|
const w = Math.max(...STEPS.flatMap(([, cs]) => cs.map((c) => c.name.length))) + "mason --json".length;
|
|
34
|
-
const commands = STEPS.map(([step, cs]) => ` ${step}\n${cs.map((c) => ` ${`mason ${c.name}${
|
|
34
|
+
const commands = STEPS.map(([step, cs]) => ` ${step}\n${cs.map((c) => ` ${`mason ${c.name}${Object.hasOwn(c.options, "json") ? " --json" : ""}`.padEnd(w)} ${c.summary}`).join("\n")}`).join("\n");
|
|
35
35
|
const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(12)} ${what}`).join("\n");
|
|
36
36
|
return `DRIVING MASON FROM A CODING AGENT — the same commands a person runs, answered as data
|
|
37
37
|
|
|
38
38
|
Every command prints for a person, or with --json one JSON object for a program. One prints text alone: mason help
|
|
39
|
-
(this reference); mason
|
|
39
|
+
(this reference); mason mcp's stdout is the MCP protocol's, so it takes no --json and says a failure to start on
|
|
40
|
+
stderr; mason logs --json is the log's lines, or with --requests the request log. Nothing a command prints
|
|
40
41
|
refers to a file this package does not ship.
|
|
41
42
|
|
|
43
|
+
AN AGENT WITH MCP (Claude Code, Claude Desktop, any MCP client) adds Mason as an MCP server first, then asks with
|
|
44
|
+
its tools rather than through a shell: tables (each copy's rows and how current it is), describe, query (rows with
|
|
45
|
+
their Postgres types, or the refusal with what to write instead) and explain. In the project's folder, Mason running:
|
|
46
|
+
mason user add claude --json its url, printed once
|
|
47
|
+
claude mcp add mason --env MASON_URL="<url>" -- mason mcp
|
|
48
|
+
(the server's name before --env, which takes every value after it). mason help mcp says the rest: Claude Desktop's
|
|
49
|
+
config, a mason serve elsewhere.
|
|
50
|
+
|
|
42
51
|
THE COMMANDS, in the order a project takes them (mason --help lists the same):
|
|
43
52
|
${commands}
|
|
44
53
|
|
|
@@ -74,21 +83,26 @@ ${codes}
|
|
|
74
83
|
exclude: [raw_body] every column but these
|
|
75
84
|
sessions:
|
|
76
85
|
key: [tenant_id, id] only for a table with no primary key: the unique columns to copy it by (polling only)
|
|
77
|
-
mason add and mason remove edit one entry of it
|
|
78
|
-
|
|
86
|
+
mason add and mason remove edit one entry of it and keep the rest as it was, Mason running or not: with it running
|
|
87
|
+
they take effect at once, else at mason start. An edit by hand is read at the next mason start (or the next add or
|
|
88
|
+
remove). Column types are read from the database at every start.
|
|
79
89
|
|
|
80
90
|
A FIRST SESSION, as a shell runs it:
|
|
81
|
-
mason doctor --json && mason init --json
|
|
91
|
+
mason doctor --json && mason init --json
|
|
82
92
|
mason tables --json choose the tables (rows, key, whether one can be added)
|
|
83
|
-
mason add orders customers --json
|
|
93
|
+
mason add orders customers --json added to the sources file, checked against the database
|
|
94
|
+
mason start --json && mason wait --json Mason in the background; until every added table is copied
|
|
84
95
|
mason user add my-tool --json the URL and password a tool connects with
|
|
85
96
|
mason status --json each table's copy, and the last day's slow and refused statements
|
|
97
|
+
With no database to hand, in a new folder: mason init demo --json && mason start --json — a demo database Mason
|
|
98
|
+
hosts itself (${DEMO_TABLES.join(", ")}), changed every few seconds, read by polling only. Init adds
|
|
99
|
+
${DEMO_ADDED_TABLES.join(", ")}; ${DEMO_TABLE_TO_ADD} is left to add: mason add ${DEMO_TABLE_TO_ADD} --json && mason wait --json, after mason start.
|
|
86
100
|
`;
|
|
87
101
|
}
|
|
88
102
|
const REPO = "https://github.com/embeddable-hq/mason-data/blob/main";
|
|
89
103
|
/**
|
|
90
104
|
* `mason help production` (the journey from running it locally to deploying it, on the mason-data image): the
|
|
91
|
-
* README's "
|
|
105
|
+
* README's "Going to production", offline and short — what travels, what the server needs, how it runs, how it is upgraded.
|
|
92
106
|
*/
|
|
93
107
|
export function productionGuide() {
|
|
94
108
|
return `GOING TO PRODUCTION — the project folder (mason/sources/, mason/mason.yml) is all that travels: its sources file
|
|
@@ -113,6 +127,16 @@ names the tables the server copies, and its url: names the variable that holds t
|
|
|
113
127
|
write its file, so run one replica, with no autoscaling, and stop the old container before the new one starts.
|
|
114
128
|
GET /healthz on MASON_HEALTH_PORT (4140) answers 503 while it starts and 200 once it serves; the first start takes
|
|
115
129
|
about 15 seconds before that, then copies the tables.
|
|
130
|
+
MEMORY: the copy may use half the container's memory limit by default (else half the machine's RAM); the other
|
|
131
|
+
half is the page cache, Node and the catalog (~820 MB). Give the container enough that the page cache holds the
|
|
132
|
+
columns your dashboards read: a query over a column it does not hold runs at the disk's speed. The benchmark's copy
|
|
133
|
+
was about a fifth of its tables in Postgres (10.1 GiB to 1.8 GiB, on a 15 GiB machine); mason status says yours.
|
|
134
|
+
To set it, MASON_DUCKDB_MEMORY=4GB with the container's limit about twice that. A statement past it fails alone
|
|
135
|
+
(53200) and the run goes on; a value near the container's limit is an OOM kill.
|
|
136
|
+
CPU: memory first, then cores. The benchmark's machine (4 vCPU Graviton3, 15 GiB) answered 32 clients at 10 M rows,
|
|
137
|
+
12 statements a second at a p95 of 3.5 s, as many a second as at 8 clients: more clients waited longer. A copy's
|
|
138
|
+
readers take about a core each while it runs (MASON_COPY_PARALLEL, default 3). The image is arm64 (the measured
|
|
139
|
+
one) and amd64. Which settings to decide: ${REPO}/deploy/mason-data/README.md#settings
|
|
116
140
|
|
|
117
141
|
3. CONNECT THE TOOLS: beside the server (docker compose exec mason …), a user is a password a tool keeps:
|
|
118
142
|
mason user add metabase its URL and password, printed once
|