@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.
Files changed (45) hide show
  1. package/README.md +71 -12
  2. package/dist/src/api/postgres/catalog.js +36 -1
  3. package/dist/src/api/postgres/server.js +133 -32
  4. package/dist/src/cdc/pg-wire.js +144 -38
  5. package/dist/src/cli/background.js +29 -5
  6. package/dist/src/cli/command.js +4 -3
  7. package/dist/src/cli/commands.js +259 -127
  8. package/dist/src/cli/demo.js +149 -0
  9. package/dist/src/cli/dev.js +113 -20
  10. package/dist/src/cli/doctor.js +6 -3
  11. package/dist/src/cli/guides.js +32 -8
  12. package/dist/src/cli/init.js +58 -18
  13. package/dist/src/cli/machine.js +8 -3
  14. package/dist/src/cli/main.js +2 -2
  15. package/dist/src/cli/mcp.js +726 -0
  16. package/dist/src/cli/preflight.js +16 -7
  17. package/dist/src/cli/project.js +5 -3
  18. package/dist/src/cli/registry.js +8 -5
  19. package/dist/src/cli/run-server.js +29 -106
  20. package/dist/src/cli/serve.js +6 -1
  21. package/dist/src/cli/source-edit.js +116 -0
  22. package/dist/src/cli/sources.js +6 -4
  23. package/dist/src/demo/seed.js +237 -0
  24. package/dist/src/demo/source.js +655 -0
  25. package/dist/src/duck/distinct-key.js +228 -0
  26. package/dist/src/duck/executor-worker.js +7 -2
  27. package/dist/src/duck/executor.js +67 -26
  28. package/dist/src/duck/explain.js +128 -9
  29. package/dist/src/duck/functions.js +60 -5
  30. package/dist/src/duck/gaps.js +9 -4
  31. package/dist/src/duck/jsonb-order.js +175 -0
  32. package/dist/src/duck/jsonb.js +10 -8
  33. package/dist/src/duck/kept-route.js +82 -2
  34. package/dist/src/duck/kept.js +206 -39
  35. package/dist/src/duck/pgtree.js +40 -0
  36. package/dist/src/duck/plan.js +21 -8
  37. package/dist/src/duck/rewrite.js +1168 -68
  38. package/dist/src/duck/store.js +132 -56
  39. package/dist/src/duck/sync.js +90 -25
  40. package/dist/src/duck/verified.js +6 -0
  41. package/dist/src/lib/timezone.js +3 -2
  42. package/dist/src/web/app.css +11 -0
  43. package/dist/src/web/app.js +167 -12
  44. package/dist/src/web/index.html +6 -1
  45. 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
+ }
@@ -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, new Set(), cfg.pgPort ?? null);
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
- say(` closing the copy: ${e.message}`);
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 and the README. */
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.";
@@ -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")
@@ -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}${noJson.has(c.name) ? "" : " --json"}`.padEnd(w)} ${c.summary}`).join("\n")}`).join("\n");
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 logs --json is the log's lines, or with --requests the request log. Nothing a command prints
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, keep the rest as it was, and take effect at once; an edit by hand is
78
- read at the next mason start (or the next add or remove). Column types are read from the database at every start.
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 && mason start --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 && mason wait --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 "On a server", offline and short — what travels, what the server needs, how it runs, how it is upgraded.
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