@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
@@ -11,6 +11,11 @@
11
11
  * of it): nothing more when it came from there, the line added when it came from the argument or the shell — and adds
12
12
  * `.mason/` and that .env to the folder's .gitignore. It refuses a folder that is in a project already (here or above)
13
13
  * — but for a project with no source file (one an older Mason made), whose source file it writes, nothing else changed.
14
+ *
15
+ * `mason init demo` (src/cli/demo.ts) writes the same files for a database Mason hosts itself: the demo's URL, on a port
16
+ * free now, in `.env` (the one place the port is kept), three of the demo's four tables already added (the fourth,
17
+ * `DEMO_TABLE_TO_ADD`, left for the person's first `mason add`), and `demo: true` in the config; there is nothing to
18
+ * connect to yet — `mason start` makes the database in `.mason/demo/`.
14
19
  */
15
20
  import * as fs from "node:fs";
16
21
  import * as path from "node:path";
@@ -22,9 +27,11 @@ import { cliPinProblem, ensurePin } from "./pin.js";
22
27
  import { version } from "./version.js";
23
28
  import { parseVersion, PIN_FILE } from "../lib/project-version.js";
24
29
  import { appDatabaseUrl, appEnvValue, appendEnvLine, ENV_FILE } from "../lib/dotenv.js";
25
- import { NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
30
+ import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
31
+ import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
32
+ import { freePort } from "./ports.js";
26
33
  import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
27
- export const INIT_USAGE = `usage: mason init [<postgres url>] [--name <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
34
+ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
28
35
 
29
36
  Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
30
37
  nothing when it cannot), then creates:
@@ -34,27 +41,31 @@ export const INIT_USAGE = `usage: mason init [<postgres url>] [--name <name>] [-
34
41
  The source file says url: \${DATABASE_URL}: the URL itself is in .env (written there when you give it here or it is
35
42
  only in the shell), never in a committed file. .mason/ and that .env go into .gitignore. Every other mason command
36
43
  then works anywhere in this folder. In a project with no source file (an older Mason's), it writes that file alone.
44
+ mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
45
+ orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
46
+ added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its copy begin.
37
47
 
38
48
  mason init DATABASE_URL from .env.local, .env or the shell
39
49
  mason init postgres://me:secret@localhost:5432/shop
50
+ mason init demo a demo database Mason hosts itself: nothing to install
40
51
  mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
41
52
  mason init postgres://me:secret@db.example.com/shop --remote
42
53
 
43
54
  --name <name> the project's name: the database name a tool connects to on the Postgres API (default: this folder's)
44
55
  --folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
45
- --remote go ahead with a database on another machine (read the warnings mason init prints)
46
- --skip-check write the project without connecting (the database is not up yet); mason start checks it
47
- --json as data: { name, root, folder, config, source: { name (the source's: its file's), file, url (as the
48
- file writes it), env (the .env line
56
+ --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
57
+ --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
58
+ --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
59
+ source: { name (the source's: its file's), file, url (as the file writes it), env (the .env line
49
60
  written, its value masked, or null) }, database: { url (password masked), from, version, tables },
50
61
  warnings, next }
51
62
  `;
52
63
  export const INIT = {
53
64
  name: "init",
54
- summary: "make this folder a Mason project on your Postgres: mason init (DATABASE_URL from .env) or mason init postgres://…",
65
+ summary: "make this folder a Mason project on your Postgres: mason init (DATABASE_URL from .env), mason init postgres://… or demo",
55
66
  usage: INIT_USAGE,
56
67
  options: { name: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
57
- json: ["name", "root", "folder", "config", "source", "database", "warnings", "next"],
68
+ json: ["name", "root", "folder", "config", "demo", "source", "database", "warnings", "next"],
58
69
  };
59
70
  /** Why `name` cannot be this project's, or null: not a name the Postgres API's database and the slot take. */
60
71
  export function nameProblem(name) {
@@ -137,6 +148,14 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
137
148
  const usage = (message, fix) => failed("init", { message, code: "usage", fix }, { json, io });
138
149
  if (f.positionals.length > 1)
139
150
  return failed("init", { message: "one postgres:// URL at most", code: "usage", usage: INIT_USAGE }, { json, io });
151
+ // THE DEMO (src/cli/demo.ts): a database Mason hosts, made by mason start; there is nothing to check, nor elsewhere
152
+ const demo = f.positionals[0] === DEMO_ARG;
153
+ if (demo) {
154
+ for (const flag of ["remote", "skip-check"]) {
155
+ if (f.values[flag] === true)
156
+ return failed("init", { message: `--${flag} means nothing for the demo database: it is on this machine, made and hosted by mason start`, code: "usage", fix: "mason init demo, without it", usage: INIT_USAGE }, { json, io });
157
+ }
158
+ }
140
159
  let root = path.resolve(cwd);
141
160
  const existing = findProject(root);
142
161
  // a project whose config cannot be read is still this folder's: a second init over it would hide the first's data
@@ -155,6 +174,10 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
155
174
  return failed("init", { message: `${existing.root === root ? "this folder is" : `this folder is inside ${existing.root}, which is`} already the Mason project ${existing.config.name}`, code: "refused",
156
175
  fix: `to point it at another database, ${where}`, next: [["mason status", "what it is doing"]] }, { json, io });
157
176
  }
177
+ if (sourceless && demo) {
178
+ return failed("init", { message: `this folder is the Mason project ${sourceless.config.name} already: mason init demo makes a project of its own`, code: "refused",
179
+ fix: "run it in a new folder (mkdir mason-demo && cd mason-demo && mason init demo)", next: [["mason init", "this project's source file, on your database"]] }, { json, io });
180
+ }
158
181
  if (sourceless && (f.values.name !== undefined || f.values.folder !== undefined)) {
159
182
  return usage(`this folder is the Mason project ${sourceless.config.name} already, whose source file is all mason init writes here: it takes no --name or --folder`, "mason init [<postgres url>], in the project's folder");
160
183
  }
@@ -170,16 +193,18 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
170
193
  const named = nameProblem(name);
171
194
  if (named)
172
195
  return usage(named);
173
- let url = f.positionals[0] ?? null;
196
+ // the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
197
+ let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
174
198
  let from = "";
175
199
  let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
176
200
  if (url === null) {
177
201
  const found = appDatabaseUrl(root);
178
202
  if (!found) {
179
- // the two fixes ARE the next commands, so a coding agent reads them as such too
203
+ // the fixes ARE the next commands, so a coding agent reads them as such too
180
204
  return failed("init", { message: "which database? none is named, and this folder has no DATABASE_URL", code: "usage",
181
205
  fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually)",
182
- next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has DATABASE_URL"]] }, { json, io });
206
+ next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has DATABASE_URL"],
207
+ ["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
183
208
  }
184
209
  url = found.url;
185
210
  from = ` (DATABASE_URL in ${found.from})`;
@@ -191,6 +216,11 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
191
216
  const remote = f.values.remote === true || sourceless?.config.remote === true;
192
217
  // THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
193
218
  const kept = sourceFileNames(folder)[0] ?? null;
219
+ if (demo && kept !== null) {
220
+ // a clone's sources file names the team's database: the demo never takes its place
221
+ return failed("init", { message: `${path.relative(root, path.join(sourcesDir(folder), kept))} is already here: mason init demo makes a project of its own`, code: "refused",
222
+ fix: "run it in a new folder (mkdir mason-demo && cd mason-demo && mason init demo), or mason init here for the database that file names" }, { json, io });
223
+ }
194
224
  const keptText = kept === null ? null : fs.readFileSync(path.join(sourcesDir(folder), kept), "utf8");
195
225
  const keptFile = kept === null ? null : parseSourceText(keptText, kept.replace(/\.ya?ml$/, ""), path.join(sourcesDir(folder), kept));
196
226
  const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
@@ -225,7 +255,11 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
225
255
  wayOut: "run the same mason init again with --remote: that says yes, copy from that machine",
226
256
  next: [["mason init --remote <url>", "the same command, confirmed"]] }), { json, io });
227
257
  try {
228
- if (f.values["skip-check"] === true) {
258
+ if (demo) {
259
+ // nothing exists yet: mason start makes the database (src/demo/source.ts) at its first start
260
+ await steps.step("Choosing the demo database's port", () => null, () => `The demo database will be ${db}: mason start makes it in ${STATE_DIR}/${DEMO_DIR}/ and seeds it (nothing to connect to yet)`);
261
+ }
262
+ else if (f.values["skip-check"] === true) {
229
263
  if (parsed.source.remote && !remote)
230
264
  return confirmRemote();
231
265
  await steps.step(`Not checking ${db}${from}`, () => null, () => `Not checking ${db}${from} (--skip-check): mason start checks it`);
@@ -257,7 +291,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
257
291
  await steps.step(`Keeping ${STATE_DIR}/config.json`, () => undefined, () => `Kept ${STATE_DIR}/config.json: the project ${name}, its key and its copy`);
258
292
  else {
259
293
  await steps.step(`Writing ${STATE_DIR}/config.json`, () => {
260
- writeConfig(path.join(root, STATE_DIR), newConfig(name, path.relative(root, folder) || ".", remote));
294
+ writeConfig(path.join(root, STATE_DIR), newConfig(name, path.relative(root, folder) || ".", remote, { demo }));
261
295
  }, () => `Wrote ${STATE_DIR}/config.json: Mason's key (this machine's)`);
262
296
  }
263
297
  if (kept !== null) {
@@ -268,8 +302,8 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
268
302
  else {
269
303
  await steps.step(`Writing ${rel(sourceFile)}`, () => {
270
304
  fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
271
- fs.writeFileSync(sourceFile, newSourceText(`\${${where.variable}}`));
272
- }, () => `Wrote ${rel(sourceFile)}: the database (url: \${${where.variable}}) and the tables to copy, none yet (mason add) — commit it`);
305
+ fs.writeFileSync(sourceFile, newSourceText(`\${${where.variable}}`, demo ? DEMO_ADDED_TABLES : []));
306
+ }, () => `Wrote ${rel(sourceFile)}: the database (url: \${${where.variable}}) and the tables to copy, ${demo ? `the demo's ${DEMO_ADDED_TABLES.length} added — mason add ${DEMO_TABLE_TO_ADD} adds the fourth` : "none yet (mason add)"} — commit it`);
273
307
  }
274
308
  if (where.line !== null) {
275
309
  const envLine = where.line;
@@ -307,13 +341,18 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
307
341
  return failed("init", { message: first ?? e.message, code: e.exit === 2 ? "usage" : "failed", exit: e.exit, fix: rest.length ? rest.map((l) => l.replace(/^\s*- /, "")).join("\n") : undefined,
308
342
  why: parsed.source.remote ? undefined : `the database's URL is ${db}${from}` }, { json, io });
309
343
  }
310
- const next = [
344
+ const next = demo ? [
345
+ ["mason start", "the demo database and Mason, in the background: its tables copied, then kept current"],
346
+ [`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its copy begin"],
347
+ ["mason status", "the copy keeping up with the demo's changes"],
348
+ ["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
349
+ ] : [
311
350
  ["mason start", "Mason in the background, for this folder"],
312
351
  ["mason tables", "the database's tables and their columns"],
313
352
  ["mason add <table>", "copy a table into Mason"],
314
353
  ];
315
354
  if (json) {
316
- say(jsonText(INIT, { name, root, folder: path.relative(root, folder) || ".", config: path.join(root, STATE_DIR, "config.json"),
355
+ say(jsonText(INIT, { name, root, folder: path.relative(root, folder) || ".", config: path.join(root, STATE_DIR, "config.json"), demo,
317
356
  source: { name: path.basename(sourceFile).replace(/\.ya?ml$/, ""), file: sourceFile, url: keptFile !== null && !("problems" in keptFile) ? ("env" in keptFile.url ? `\${${keptFile.url.env}}` : maskedUrl(keptFile.url.literal)) : `\${${where.variable}}`,
318
357
  env: where.line === null ? null : maskedLine(where.line) },
319
358
  database, warnings: notes }, next));
@@ -321,6 +360,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
321
360
  }
322
361
  for (const n of notes)
323
362
  say(` ! ${n}`);
324
- say(endText({ tips: ["Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
363
+ say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; mason destroy removes it with the copy`
364
+ : "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
325
365
  return 0;
326
366
  }
@@ -26,7 +26,8 @@ export const DESTROY_USAGE = `usage: mason destroy [--json]
26
26
 
27
27
  mason stop keeps the copy and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
28
28
  the next mason start begins again. In replication mode the slot (and Mason's own publication) are dropped from the
29
- database too: they hold its WAL for as long as they exist.
29
+ database too: they hold its WAL for as long as they exist. A demo project's database (mason init demo, in .mason/demo/)
30
+ goes with the copy: the next mason start makes and seeds it again.
30
31
 
31
32
  --json as data: { removed: [{ name, removed: [what], error }], next }
32
33
 
@@ -101,7 +102,9 @@ export async function removeProject(p, o = {}) {
101
102
  }
102
103
  // the users (src/duck/users.ts) are logins a BI tool keeps, like the API key their passwords are made with, and the
103
104
  // self-signed certificate is the fingerprint a tool pinned (the review of the cut: a new one at the next
104
- // start, and config.selfSignedTls stale): the next start copies again, and every tool connects as before
105
+ // start, and config.selfSignedTls stale): the next start copies again, and every tool connects as before. A demo
106
+ // project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and config.demo stays, so the
107
+ // next start makes and seeds it again
105
108
  const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
106
109
  const gone = fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)) : [];
107
110
  for (const x of gone)
@@ -151,7 +154,9 @@ export async function destroy(argv, deps = {}) {
151
154
  return failed("destroy", { message: `${name}: ${result.error}`, code: "failed" }, { json: false, io });
152
155
  out(withEnd(reportText({
153
156
  headline: result.removed.length ? `Removed ${name}` : `Nothing was left of ${name} to remove`,
154
- details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users`]],
157
+ details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
158
+ // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
159
+ (t.project.config.demo === true ? ` — not the demo database, which went with the copy: the next mason start seeds it again` : "")]],
155
160
  }), { next }));
156
161
  return 0;
157
162
  }
@@ -32,7 +32,7 @@ async function main(argv) {
32
32
  return 0;
33
33
  }
34
34
  const cmd = COMMANDS.find((c) => c.name === command);
35
- const json = wantsJson(args);
35
+ const json = wantsJson(args, cmd);
36
36
  // the commands' old names: said, never run as different ones
37
37
  const RENAMED = {
38
38
  dev: { message: "mason dev is now mason start", next: [["mason init <postgres url>", "once, in your app's folder"], ["mason start", "Mason in the background"]] },
@@ -76,7 +76,7 @@ main(process.argv.slice(2)).then(exitWhenWritten, (e) => {
76
76
  // MASON_DEBUG=1 prints where it happened, for the bug report it deserves. The same shape as every failure, JSON under --json.
77
77
  const message = e instanceof Error ? e.message : String(e);
78
78
  const [command, ...args] = process.argv.slice(2);
79
- const code = failed(command ?? "", { message, code: "failed", fix: process.env.MASON_DEBUG ? undefined : "this is a bug in mason: MASON_DEBUG=1 prints where it happened, for the report" }, { json: wantsJson(args) });
79
+ const code = failed(command ?? "", { message, code: "failed", fix: process.env.MASON_DEBUG ? undefined : "this is a bug in mason: MASON_DEBUG=1 prints where it happened, for the report" }, { json: wantsJson(args, COMMANDS.find((c) => c.name === command)) });
80
80
  if (process.env.MASON_DEBUG)
81
81
  process.stderr.write(`${e.stack ?? ""}\n`);
82
82
  exitWhenWritten(code);