@mason-data/cli 0.6.2 → 0.8.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 (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,16 +1,24 @@
1
1
  /**
2
2
  * `mason init [<postgres url>]` (a project directory is initiated as other CLIs do it, by `mason init`):
3
- * makes the folder it is run in a Mason project. The database's URL comes from the argument, else the folder's
4
- * `.env.local` or `.env` (`DATABASE_URL`, the variable web frameworks use), else the shell's. The database is checked as
5
- * `mason start` checks it (checkSource) and nothing is written when it fails, unless --skip-check says the database is
6
- * not up yet.
3
+ * makes the folder it is run in a Mason project. The database's URL comes from the argument, else `MASON_DATABASE_URL`
4
+ * (Mason's own, for a database other than the app's), else `DATABASE_URL` (the variable web frameworks use), each from
5
+ * the folder's `.env.local`, then `.env`, then the shell (`appDatabaseUrl`) — or, when a sources file is here already (a
6
+ * clone), the variable its `url: ${…}` names, read the same way, so the database checked is the one the run copies from.
7
+ * The database is checked as `mason start` checks it (checkSource) and nothing is written when it fails, unless
8
+ * --skip-check says the database is not up yet.
7
9
  *
8
10
  * It writes `.mason/config.json` (0600: the project's name and Mason's key), the project's source file
9
- * (`mason/sources/<database>.yml`, src/cli/sources.ts: `kind: postgres`, `url: ${DATABASE_URL}`, `tables: {}`) — the
10
- * URL itself in `.env`, never in a committed file (the sources file is the developer's to commit, so the URL stays out
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
- * `.mason/` and that .env to the folder's .gitignore. It refuses a folder that is in a project already (here or above)
13
- * — but for a project with no source file (one an older Mason made), whose source file it writes, nothing else changed.
11
+ * (`mason/sources/<database>.yml`, src/cli/sources.ts: `kind: postgres`, `url: ${DATABASE_URL}` — or the variable the
12
+ * URL was read from, `tables: {}`, and `database: <name>` only under `--database`) — the URL itself in `.env`, never in a committed file (the sources file is the
13
+ * developer's to commit, so the URL stays out of it): nothing more when it came from there, the line added when it came
14
+ * from the argument or the shell — and adds `.mason/` and that .env to the folder's .gitignore. It refuses a folder that
15
+ * is in a project already (here or above) — but for a project with no source file (one an older Mason made), whose
16
+ * source file it writes, nothing else changed.
17
+ *
18
+ * `mason init demo` (src/cli/demo.ts) writes the same files for a database Mason hosts itself: the demo's URL, on a port
19
+ * free now, in `.env` (the one place the port is kept), three of the demo's four tables already added (the fourth,
20
+ * `DEMO_TABLE_TO_ADD`, left for the person's first `mason add`), and `demo: true` in the config; there is nothing to
21
+ * connect to yet — `mason start` makes the database in `.mason/demo/`.
14
22
  */
15
23
  import * as fs from "node:fs";
16
24
  import * as path from "node:path";
@@ -22,41 +30,58 @@ import { cliPinProblem, ensurePin } from "./pin.js";
22
30
  import { version } from "./version.js";
23
31
  import { parseVersion, PIN_FILE } from "../lib/project-version.js";
24
32
  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";
33
+ import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
34
+ import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_SYNC, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
35
+ import { freePort } from "./ports.js";
36
+ import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
37
+ import { DEFAULT_SYNC, syncText } from "../lib/table-sync.js";
38
+ import { newRolesText, rolesFile } from "./roles.js";
26
39
  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]
40
+ export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
28
41
 
29
42
  Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
30
43
  nothing when it cannot), then creates:
31
- .mason/ this machine's: Mason's key and, once Mason runs, its copy and state (gitignored)
32
- mason/sources/<db>.yml the database and the tables to copy (mason add adds them) — commit it
44
+ .mason/ this machine's: Mason's key and, once Mason runs, its synced data and state (gitignored)
45
+ mason/sources/<db>.yml the database and the tables to sync (mason add adds them) — commit it
46
+ mason/roles.yml who may read what: one role, everything (tables: all), the default of a user added
47
+ without --role, so every user reads every synced table until you narrow it — commit it
33
48
  mason/mason.yml the Mason version the project is written for — commit it: production runs that version
34
- The source file says url: \${DATABASE_URL}: the URL itself is in .env (written there when you give it here or it is
35
- only in the shell), never in a committed file. .mason/ and that .env go into .gitignore. Every other mason command
36
- then works anywhere in this folder. In a project with no source file (an older Mason's), it writes that file alone.
49
+ With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
50
+ shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
51
+ read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
52
+ there when you give it here or it is only in the shell), never in a committed file. .mason/ and that .env go into
53
+ .gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
54
+ project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
55
+ with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
56
+ mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
57
+ orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
58
+ added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
37
59
 
38
- mason init DATABASE_URL from .env.local, .env or the shell
60
+ mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
39
61
  mason init postgres://me:secret@localhost:5432/shop
62
+ mason init demo a demo database Mason hosts itself: nothing to install
40
63
  mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
41
64
  mason init postgres://me:secret@db.example.com/shop --remote
42
65
 
43
- --name <name> the project's name: the database name a tool connects to on the Postgres API (default: this folder's)
66
+ --name <name> the project's name, which its replication slot is named for (default: this folder's)
67
+ --database <name> the database name a tool connects with, written as database: in the source file (default: none
68
+ written, so tools connect with ${DEFAULT_DATABASE}): your database's own, and a tool keeps the name it has
44
69
  --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
49
- written, its value masked, or null) }, database: { url (password masked), from, version, tables },
50
- warnings, next }
70
+ --remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
71
+ --skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
72
+ --json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
73
+ source: { name (the source's: its file's), file, url (as the file writes it), database (the name
74
+ a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
75
+ database: { url (password masked), from, version, tables }, warnings, next }
51
76
  `;
52
77
  export const INIT = {
53
78
  name: "init",
54
- summary: "make this folder a Mason project on your Postgres: mason init (DATABASE_URL from .env) or mason init postgres://…",
79
+ summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
55
80
  usage: INIT_USAGE,
56
- 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"],
81
+ options: { name: { type: "string" }, database: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
82
+ json: ["name", "root", "folder", "config", "demo", "source", "roles", "database", "warnings", "next"],
58
83
  };
59
- /** Why `name` cannot be this project's, or null: not a name the Postgres API's database and the slot take. */
84
+ /** Why `name` cannot be this project's, or null: not a name its replication slot takes. */
60
85
  export function nameProblem(name) {
61
86
  if (!NAME_RE.test(name))
62
87
  return `${JSON.stringify(name)} is not a project name: lower-case letters, digits, - and _, starting with a letter or digit (--name sets it)`;
@@ -74,7 +99,7 @@ export function withIgnored(gitignore, files = []) {
74
99
  let tail = "";
75
100
  if (!ignoresStateDir(text)) {
76
101
  added.push(`${STATE_DIR}/`);
77
- tail += `# Mason: this machine's key, copy and state (mason init)\n${STATE_DIR}/\n`;
102
+ tail += `# Mason: this machine's key, synced data and state (mason init)\n${STATE_DIR}/\n`;
78
103
  }
79
104
  for (const f of files)
80
105
  if (!ignoresFile(text, f)) {
@@ -112,8 +137,13 @@ export function committedInGit(root, file) {
112
137
  * when the folder's .env.local or .env gives it already. DATABASE_URL when the folder's files give it no other value
113
138
  * (one only in the shell is written down: the next shell may not have it); else MASON_DATABASE_URL, so the app's own
114
139
  * DATABASE_URL is never changed under it. A problem when both name other databases.
140
+ * A URL READ FROM A VARIABLE (`found`, a bare mason init's `appDatabaseUrl`) is kept in that variable: the one read is
141
+ * the one named, and nothing is written when a folder's file gave it — so a MASON_DATABASE_URL is never written again as
142
+ * DATABASE_URL; one only in the shell is written down under its own name.
115
143
  */
116
- export function urlVariable(root, url) {
144
+ export function urlVariable(root, url, found = null) {
145
+ if (found !== null)
146
+ return { variable: found.variable, line: found.file === null ? `${found.variable}=${url}` : null };
117
147
  for (const variable of ["DATABASE_URL", "MASON_DATABASE_URL"]) {
118
148
  const inFiles = appEnvValue(root, variable, {});
119
149
  if (inFiles === null)
@@ -121,10 +151,10 @@ export function urlVariable(root, url) {
121
151
  if (inFiles.value === url)
122
152
  return { variable, line: null };
123
153
  }
124
- return { problem: "this folder's .env names DATABASE_URL and MASON_DATABASE_URL for other databases: set one of them to the database Mason copies from, then mason init again" };
154
+ return { problem: "this folder's .env names DATABASE_URL and MASON_DATABASE_URL for other databases: set one of them to the database Mason syncs from, then mason init again" };
125
155
  }
126
156
  /** `.env` with `line` added (made when there is none). */
127
- const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason copies from (mason/sources/ names it; never commit this file)");
157
+ const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason syncs from (mason/sources/ names it; never commit this file)");
128
158
  /** A `.env` line as it is said: its value's password masked. */
129
159
  const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
130
160
  export async function init(argv, cwd = process.cwd(), deps = {}) {
@@ -137,6 +167,14 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
137
167
  const usage = (message, fix) => failed("init", { message, code: "usage", fix }, { json, io });
138
168
  if (f.positionals.length > 1)
139
169
  return failed("init", { message: "one postgres:// URL at most", code: "usage", usage: INIT_USAGE }, { json, io });
170
+ // THE DEMO (src/cli/demo.ts): a database Mason hosts, made by mason start; there is nothing to check, nor elsewhere
171
+ const demo = f.positionals[0] === DEMO_ARG;
172
+ if (demo) {
173
+ for (const flag of ["remote", "skip-check"]) {
174
+ if (f.values[flag] === true)
175
+ 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 });
176
+ }
177
+ }
140
178
  let root = path.resolve(cwd);
141
179
  const existing = findProject(root);
142
180
  // 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 +193,10 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
155
193
  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
194
  fix: `to point it at another database, ${where}`, next: [["mason status", "what it is doing"]] }, { json, io });
157
195
  }
196
+ if (sourceless && demo) {
197
+ 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",
198
+ 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 });
199
+ }
158
200
  if (sourceless && (f.values.name !== undefined || f.values.folder !== undefined)) {
159
201
  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
202
  }
@@ -170,33 +212,65 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
170
212
  const named = nameProblem(name);
171
213
  if (named)
172
214
  return usage(named);
173
- let url = f.positionals[0] ?? null;
215
+ const connectName = f.values.database ?? null;
216
+ const badDatabase = connectName === null ? null : databaseNameProblem(connectName);
217
+ if (badDatabase !== null)
218
+ return usage(`--database: ${badDatabase}`);
219
+ // THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
220
+ const kept = sourceFileNames(folder)[0] ?? null;
221
+ if (demo && kept !== null) {
222
+ // a clone's sources file names the team's database: the demo never takes its place
223
+ 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",
224
+ 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 });
225
+ }
226
+ const keptPath = kept === null ? null : path.join(sourcesDir(folder), kept);
227
+ if (connectName !== null && keptPath !== null)
228
+ return usage(`--database writes a new source file, and ${path.relative(root, keptPath)} is here already`, `write database: ${connectName} in it`);
229
+ const keptFile = kept === null || keptPath === null ? null : parseSourceText(fs.readFileSync(keptPath, "utf8"), kept.replace(/\.ya?ml$/, ""), keptPath);
230
+ const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
231
+ // the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
232
+ let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
174
233
  let from = "";
175
234
  let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
235
+ let envVariable = null; // the variable envFile holds the URL in, set with it: the warning names it
236
+ // the variable a bare mason init read the URL from: the one the sources file names (urlVariable)
237
+ let found = null;
176
238
  if (url === null) {
177
- const found = appDatabaseUrl(root);
239
+ // THE KEPT FILE DECIDES WHAT IS READ: the variable its url: names, as the run reads it (.env.local, .env, the shell),
240
+ // so the database init checks is the one mason start copies from; MASON_DATABASE_URL, then DATABASE_URL, only when
241
+ // no kept file names one (a clone naming DATABASE_URL was checked against a MASON_DATABASE_URL in the shell)
242
+ if (keptVar !== null && keptPath !== null) {
243
+ const v = appEnvValue(root, keptVar, deps.env ?? process.env);
244
+ const file = shownPath(root, keptPath);
245
+ if (v === null) {
246
+ return failed("init", { message: `which database? ${file} names url: \${${keptVar}}, and this folder has no ${keptVar}`, code: "usage",
247
+ why: `mason start reads the URL from ${keptVar}, so mason init checks that one`,
248
+ fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or give its URL on the command line: mason init writes it there`,
249
+ next: [["mason init postgres://user:password@localhost:5432/your_app", `its URL, given: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
250
+ }
251
+ found = { url: v.value, variable: keptVar, from: `${keptVar} in ${v.from}`, file: v.file };
252
+ }
253
+ else
254
+ found = appDatabaseUrl(root, deps.env ?? process.env);
178
255
  if (!found) {
179
- // the two fixes ARE the next commands, so a coding agent reads them as such too
180
- return failed("init", { message: "which database? none is named, and this folder has no DATABASE_URL", code: "usage",
181
- 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 });
256
+ // the fixes ARE the next commands, so a coding agent reads them as such too
257
+ return failed("init", { message: "which database? none is named, and this folder has no MASON_DATABASE_URL or DATABASE_URL", code: "usage",
258
+ fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
259
+ next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
260
+ ["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
183
261
  }
184
262
  url = found.url;
185
- from = ` (DATABASE_URL in ${found.from})`;
263
+ from = ` (${found.from})`;
186
264
  envFile = found.file;
265
+ envVariable = found.variable;
187
266
  }
188
267
  const parsed = parseSource(url);
189
268
  if ("problem" in parsed)
190
269
  return usage(parsed.problem);
191
270
  const remote = f.values.remote === true || sourceless?.config.remote === true;
192
- // THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
193
- const kept = sourceFileNames(folder)[0] ?? null;
194
- const keptText = kept === null ? null : fs.readFileSync(path.join(sourcesDir(folder), kept), "utf8");
195
- const keptFile = kept === null ? null : parseSourceText(keptText, kept.replace(/\.ya?ml$/, ""), path.join(sourcesDir(folder), kept));
196
- const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
197
271
  // a kept file's variable gets this URL in .env when the folder's files give it none; one they give another value is said
198
272
  const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
199
- const where = kept === null ? urlVariable(root, url)
273
+ const where = kept === null ? urlVariable(root, url, found)
200
274
  : { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
201
275
  if ("problem" in where)
202
276
  return failed("init", { message: where.problem, code: "refused" }, { json, io });
@@ -211,10 +285,12 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
211
285
  line(st.bold(`Making ${name} a Mason project in ${root}`));
212
286
  const notes = [];
213
287
  if (keptValue !== null && keptValue.value !== url)
214
- notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start copies from that one, not ${db}`);
288
+ notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start syncs from that one, not ${db}`);
215
289
  let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };
216
290
  const rel = (p) => path.relative(root, p) || ".";
217
291
  const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
292
+ const rolesPath = rolesFile(folder);
293
+ const rolesKept = fs.existsSync(rolesPath);
218
294
  // the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
219
295
  const oldTables = path.join(folder, OLD_TABLES_DIR);
220
296
  if (fs.existsSync(oldTables))
@@ -222,10 +298,14 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
222
298
  // A DATABASE ON ANOTHER MACHINE, NOT CONFIRMED: not a failed step (the check passed, or was skipped) but a stop before
223
299
  // anything is written, said as one failure (remoteConfirmation) — the same words whether the check ran or not
224
300
  const confirmRemote = () => failed("init", remoteConfirmation(parsed.source.host, { cmd: "mason init",
225
- wayOut: "run the same mason init again with --remote: that says yes, copy from that machine",
301
+ wayOut: "run the same mason init again with --remote: that says yes, sync from that machine",
226
302
  next: [["mason init --remote <url>", "the same command, confirmed"]] }), { json, io });
227
303
  try {
228
- if (f.values["skip-check"] === true) {
304
+ if (demo) {
305
+ // nothing exists yet: mason start makes the database (src/demo/source.ts) at its first start
306
+ 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)`);
307
+ }
308
+ else if (f.values["skip-check"] === true) {
229
309
  if (parsed.source.remote && !remote)
230
310
  return confirmRemote();
231
311
  await steps.step(`Not checking ${db}${from}`, () => null, () => `Not checking ${db}${from} (--skip-check): mason start checks it`);
@@ -254,27 +334,40 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
254
334
  database = { ...database, version: checked.facts.versionText, tables: checked.facts.tables.length };
255
335
  }
256
336
  if (sourceless)
257
- await steps.step(`Keeping ${STATE_DIR}/config.json`, () => undefined, () => `Kept ${STATE_DIR}/config.json: the project ${name}, its key and its copy`);
337
+ await steps.step(`Keeping ${STATE_DIR}/config.json`, () => undefined, () => `Kept ${STATE_DIR}/config.json: the project ${name}, its key and its synced data`);
258
338
  else {
259
339
  await steps.step(`Writing ${STATE_DIR}/config.json`, () => {
260
- writeConfig(path.join(root, STATE_DIR), newConfig(name, path.relative(root, folder) || ".", remote));
340
+ writeConfig(path.join(root, STATE_DIR), newConfig(name, path.relative(root, folder) || ".", remote, { demo }));
261
341
  }, () => `Wrote ${STATE_DIR}/config.json: Mason's key (this machine's)`);
262
342
  }
263
343
  if (kept !== null) {
264
- await steps.step(`Keeping ${rel(sourceFile)}`, () => undefined, () => `Kept ${rel(sourceFile)}: the database and the tables this project copies`);
344
+ await steps.step(`Keeping ${rel(sourceFile)}`, () => undefined, () => `Kept ${rel(sourceFile)}: the database and the tables this project syncs`);
265
345
  if (keptFile !== null && "problems" in keptFile)
266
346
  notes.push(`${rel(sourceFile)} has ${keptFile.problems.length === 1 ? "a mistake" : "mistakes"}, which mason start refuses: ${keptFile.problems.join("; ")}`);
267
347
  }
268
348
  else {
269
349
  await steps.step(`Writing ${rel(sourceFile)}`, () => {
270
350
  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`);
351
+ fs.writeFileSync(sourceFile, newSourceText(`\${${where.variable}}`, demo ? DEMO_ADDED_TABLES : [], demo ? DEMO_SYNC : DEFAULT_SYNC, connectName));
352
+ }, () => `Wrote ${rel(sourceFile)}: the database (url: \${${where.variable}}), ${connectName === null ? "" : `database: ${connectName}, `}sync: ${syncText(demo ? DEMO_SYNC : DEFAULT_SYNC)} ` +
353
+ `(${demo ? "every table polled every 5 s" : "each table read once, then again only by mason sync <table>"}) and the tables to sync, ` +
354
+ `${demo ? `the demo's ${DEMO_ADDED_TABLES.length} added — mason add ${DEMO_TABLE_TO_ADD} adds the fourth` : "none yet (mason add)"} — commit it`);
355
+ }
356
+ // THE ROLES FILE (src/cli/roles.ts newRolesText): today's access said in a committed file — every user reads every
357
+ // copied table, as the default role everything — kept when one is here (a clone of the project)
358
+ if (rolesKept)
359
+ await steps.step(`Keeping ${rel(rolesPath)}`, () => undefined, () => `Kept ${rel(rolesPath)}: the roles and the default for a user added without --role`);
360
+ else {
361
+ await steps.step(`Writing ${rel(rolesPath)}`, () => {
362
+ fs.mkdirSync(path.dirname(rolesPath), { recursive: true });
363
+ fs.writeFileSync(rolesPath, newRolesText());
364
+ }, () => `Wrote ${rel(rolesPath)}: every user reads every synced table (default: everything, tables: all) — narrow it with a second role — commit it`);
273
365
  }
274
366
  if (where.line !== null) {
275
367
  const envLine = where.line;
276
368
  await steps.step(`Writing ${where.variable} to .env`, () => addEnvLine(root, envLine), () => `Wrote ${maskedLine(envLine)} to .env: the URL the source file names, kept out of git`);
277
369
  envFile = ENV_FILE;
370
+ envVariable = where.variable;
278
371
  }
279
372
  // THE VERSION PIN (src/cli/pin.ts): what production's image is held to. A checkout's mason (`dev`) has none to write
280
373
  const cli = deps.version ?? version();
@@ -295,7 +388,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
295
388
  return after === null ? "already" : before === null ? "made" : after.added.join(" and ");
296
389
  }, (r) => r === "already" ? `.gitignore already ignores ${ignored}` : r === "made" ? `Made .gitignore, ignoring ${ignored}` : `Added ${r} to .gitignore`);
297
390
  if (envFile && committedInGit(root, envFile)) {
298
- notes.push(`${envFile} holds the database URL (DATABASE_URL) and is committed in git: git rm --cached ${envFile} takes it out of the next commit (the file stays); then change the password`);
391
+ notes.push(`${envFile} holds the database URL (${envVariable}) and is committed in git: git rm --cached ${envFile} takes it out of the next commit (the file stays); then change the password`);
299
392
  }
300
393
  }
301
394
  catch (e) {
@@ -307,20 +400,28 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
307
400
  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
401
  why: parsed.source.remote ? undefined : `the database's URL is ${db}${from}` }, { json, io });
309
402
  }
310
- const next = [
403
+ const next = demo ? [
404
+ ["mason start", "the demo database and Mason, in the background: its tables synced, then kept current"],
405
+ [`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its first sync begin"],
406
+ ["mason status", "the synced data keeping up with the demo's changes"],
407
+ ["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
408
+ ] : [
311
409
  ["mason start", "Mason in the background, for this folder"],
312
410
  ["mason tables", "the database's tables and their columns"],
313
- ["mason add <table>", "copy a table into Mason"],
411
+ ["mason add <table>", "sync a table into Mason"],
314
412
  ];
315
413
  if (json) {
316
- say(jsonText(INIT, { name, root, folder: path.relative(root, folder) || ".", config: path.join(root, STATE_DIR, "config.json"),
414
+ say(jsonText(INIT, { name, root, folder: path.relative(root, folder) || ".", config: path.join(root, STATE_DIR, "config.json"), demo,
317
415
  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}}`,
416
+ database: keptFile === null ? connectName ?? DEFAULT_DATABASE : "problems" in keptFile ? null : keptFile.database ?? DEFAULT_DATABASE,
318
417
  env: where.line === null ? null : maskedLine(where.line) },
418
+ roles: { file: rolesPath, written: !rolesKept },
319
419
  database, warnings: notes }, next));
320
420
  return 0;
321
421
  }
322
422
  for (const n of notes)
323
423
  say(` ! ${n}`);
324
- say(endText({ tips: ["Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
424
+ 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 synced data`
425
+ : "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
325
426
  return 0;
326
427
  }
@@ -18,15 +18,16 @@ import { dropOwnReplication, dropStatements, madeReplication } from "./replicati
18
18
  import { sourceFileNames } from "./sources.js";
19
19
  export const DESTROY_USAGE = `usage: mason destroy [--json]
20
20
 
21
- Removes this folder's project's copy of your data from this machine — its file — and Mason's state in its
21
+ Removes this folder's project's synced data from this machine — its file — and Mason's state in its
22
22
  .mason/ (the logs, the request log). Mason is stopped first if it runs. Your files (mason/sources/), .mason/config.json and
23
- the users (mason user) stay: the next mason start copies the tables again, and every tool connects as before.
23
+ the users (mason user) stay: the next mason start syncs the tables again, and every tool connects as before.
24
24
 
25
25
  mason destroy this folder's project
26
26
 
27
- mason stop keeps the copy and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
27
+ mason stop keeps the synced data 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 synced data: the next mason start makes and seeds it again.
30
31
 
31
32
  --json as data: { removed: [{ name, removed: [what], error }], next }
32
33
 
@@ -35,7 +36,7 @@ export const DESTROY_USAGE = `usage: mason destroy [--json]
35
36
  export const DESTROY = {
36
37
  needs: "project",
37
38
  name: "destroy",
38
- summary: "remove this project's copy of the data and Mason's state from this machine (your files and users stay)",
39
+ summary: "remove this project's synced data and Mason's state from this machine (your files and users stay)",
39
40
  usage: DESTROY_USAGE,
40
41
  options: { json: { type: "boolean" } },
41
42
  json: ["removed", "next"],
@@ -70,11 +71,12 @@ export async function removeProject(p, o = {}) {
70
71
  if (sup !== null)
71
72
  removed.push(`stopped its supervisor (pid ${sup}), which was about to start it again`);
72
73
  }
73
- // REPLICATION MODE made a publication and a slot on the database (src/cli/replication-mode.ts): the slot holds its WAL
74
- // for as long as it exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
74
+ // REPLICATION made a publication and a slot on the database (src/cli/replication-mode.ts), recorded by the start or
75
+ // the run that made them (an older start recorded its mode, `changes`): the slot holds its WAL for as long as it
76
+ // exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
75
77
  let replication = null;
76
78
  let objects = "publication and replication slot";
77
- if (p.config.changes === "replication") {
79
+ if ((p.config.replication ?? null) !== null || p.config.changes === "replication") {
78
80
  // the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
79
81
  const src = projectSource(p);
80
82
  // what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
@@ -89,7 +91,7 @@ export async function removeProject(p, o = {}) {
89
91
  // and the real slot goes on holding its WAL (the review of the sources file)
90
92
  const from = p.config.sourceServer ?? null;
91
93
  if (from !== null && from.database !== parsed.source.database) {
92
- throw new Error(`the sources file names ${parsed.source.database} now, and the copy came from ${from.database}, where the slot is: ` +
94
+ throw new Error(`the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
93
95
  `put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand`);
94
96
  }
95
97
  for (const d of await (o.dropReplication ?? dropOwnReplication)(parsed.source, own, from))
@@ -101,7 +103,9 @@ export async function removeProject(p, o = {}) {
101
103
  }
102
104
  // the users (src/duck/users.ts) are logins a BI tool keeps, like the API key their passwords are made with, and the
103
105
  // 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
106
+ // start, and config.selfSignedTls stale): the next start copies again, and every tool connects as before. A demo
107
+ // project's database (`.mason/demo/`, src/cli/demo.ts) is state like the copy: it goes, and config.demo stays, so the
108
+ // next start makes and seeds it again
105
109
  const kept = new Set(["config.json", USERS_FILE, SELF_SIGNED_FILES.cert, SELF_SIGNED_FILES.key]);
106
110
  const gone = fs.existsSync(p.dir) ? fs.readdirSync(p.dir).filter((x) => !kept.has(x)) : [];
107
111
  for (const x of gone)
@@ -142,7 +146,7 @@ export async function destroy(argv, deps = {}) {
142
146
  catch (e) {
143
147
  result = { name, removed: [], error: e.message };
144
148
  }
145
- const next = result.error ? [] : [["mason start", "copies the tables again"]];
149
+ const next = result.error ? [] : [["mason start", "syncs the tables again"]];
146
150
  if (json) {
147
151
  out(jsonText(DESTROY, { removed: [result] }, next));
148
152
  return result.error ? 1 : 0;
@@ -151,7 +155,9 @@ export async function destroy(argv, deps = {}) {
151
155
  return failed("destroy", { message: `${name}: ${result.error}`, code: "failed" }, { json: false, io });
152
156
  out(withEnd(reportText({
153
157
  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`]],
158
+ details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
159
+ // the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
160
+ (t.project.config.demo === true ? ` — not the demo database, which went with the synced data: the next mason start seeds it again` : "")]],
155
161
  }), { next }));
156
162
  return 0;
157
163
  }
@@ -32,17 +32,17 @@ 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"]] },
39
39
  docs: { message: "mason docs is now mason help", next: [["mason help agent", "driving Mason from a coding agent"], ["mason help production", "running Mason on a server"]] },
40
40
  // named for what it does to the copy, never for the mechanism
41
- poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "make the copy of a table current now"], ["mason status", "each table's copy, and which the sync leaves out"]] },
42
- purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its copy and state, never its files"]] },
41
+ poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "bring a synced table up to date now"], ["mason status", "each synced table, and which the sync leaves out"]] },
42
+ purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its synced data and state, never its files"]] },
43
43
  // mason rm read as the opposite of mason add: it removed a project's copy and state, never a table;
44
44
  // the opposite of mason add is mason remove, so both are offered
45
- rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's copy", next: [["mason remove <table>", "a table, and its copy"], ["mason destroy", "this folder's project: its copy and state, never its files"]] },
45
+ rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's synced data", next: [["mason remove <table>", "a table, and its synced data"], ["mason destroy", "this folder's project: its synced data and state, never its files"]] },
46
46
  // a user's password does not expire, so mason user is enough and mason connect was dropped
47
47
  connect: { message: "mason connect is gone: mason user add <name> makes a user whose password does not expire, and prints the URL to paste", next: [["mason user add <name>", "a user for a BI tool or your app"], ["mason status", "where the Postgres API listens"]] },
48
48
  // a project is its folder (mason list was dropped): nothing on the machine lists them
@@ -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);