@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -6,14 +6,18 @@
6
6
  */
7
7
  import { failed, jsonText, nextBlock, parseCommand } from "./command.js";
8
8
  import { out as styleOut, wrap } from "./style.js";
9
- import { heldPgPort, namedPgPort, portHolder } from "./ports.js";
9
+ import { DEFAULT_UI_PORT, heldPgPort, namedPgPort, portHolder } from "./ports.js";
10
10
  import { callRun, RunUnreachable } from "./control.js";
11
11
  import { readiness } from "./commands.js";
12
12
  import { connectSource, maskedUrl, parseSource, readSourceFacts, versionProblem } from "./preflight.js";
13
13
  import { theRun } from "./runs.js";
14
14
  import { projectSource } from "./project.js";
15
15
  import { DEFAULT_PG_PORT } from "../api/postgres/listener.js";
16
- import { defaultMemoryLimit, machineMemoryBytes, MEMORY_LIMIT_RE } from "../duck/store.js";
16
+ import { defaultMemoryLimit, machineMemory, memoryLimitBytes, memoryProblems, memoryProblemText, memorySize, memoryWarning, OUTSIDE_RESERVE_BYTES } from "../duck/store.js";
17
+ import { probeExtensions } from "../duck/extensions.js";
18
+ import { readRoleFacts, roleChecks, roleUnknown, ROLE_CODES } from "./source-role.js";
19
+ import { replicatedEntries, shownPath } from "./sources.js";
20
+ import { namedPublication } from "./replication-mode.js";
17
21
  const out = (m) => { process.stdout.write(`${m}\n`); };
18
22
  /**
19
23
  * THE NODE THE PACKAGE NEEDS, major.minor: cli/package.json's `engines`, this check, `mason --help` and the README read
@@ -25,17 +29,28 @@ const out = (m) => { process.stdout.write(`${m}\n`); };
25
29
  export const NODE_MIN = "22.13";
26
30
  export const DOCTOR_USAGE = `usage: mason doctor [--json]
27
31
 
28
- Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (Mason's copy uses up to half of it by
29
- default; MASON_DUCKDB_MEMORY=4GiB sets it), the ports Mason listens on — with the way out for anything missing. In a
32
+ Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (what the engine may use and what it
33
+ leaves beside it; MASON_DUCKDB_MEMORY=4GiB sets it), Mason's postgres and inet extensions (installed with Mason for this
34
+ platform, so nothing is downloaded; else the first sync downloads them), the ports Mason listens on — with the way
35
+ out for anything missing. In a
30
36
  project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
31
- database, whether every added table is copied, the statements of the last day it refused or answered slowly.
37
+ database, whether every added table is synced, the statements of the last day it refused or answered slowly. Both
38
+ say whether the user Mason connects as could change a synced table's rows (a superuser, BYPASSRLS, an owner of a
39
+ synced table, a write privilege on one or CREATE on its schema; or a write that reaches its rows through a
40
+ partition, an inheritance child or parent, a foreign key's action, a view or a rule; SECURITY DEFINER code it
41
+ reaches; a foreign server; ADMIN OPTION or a server role; directly or through a role it is a member of), with the
42
+ statements that make a role that only reads.
32
43
 
33
44
  mason doctor
34
45
 
35
- --json as data: { ready, verdict, checks: [{ name, ok, detail }], next: [{ command, what }] }; ok is true, false,
36
- or null when it could not tell
46
+ --json as data: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
47
+ is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
48
+ (the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
49
+ ${ROLE_CODES.slice(5).join(", ")};
50
+ fix (with code): the statements and the edit that make a role that only reads
37
51
 
38
- Exit status: 0 everything checked is fine; 1 something is not (said, with the way out).
52
+ Exit status: 0 nothing failed — a warning (!) and a check that could not tell (?) are said, and leave it 0; 1
53
+ something failed (said, with the way out).
39
54
  `;
40
55
  export const DOCTOR = {
41
56
  name: "doctor",
@@ -45,7 +60,7 @@ export const DOCTOR = {
45
60
  json: ["ready", "verdict", "checks", "next"],
46
61
  };
47
62
  /** The ports a mason start takes by default, and what listens on each. */
48
- export const PORTS = [[4039, "the mason commands"], [DEFAULT_PG_PORT, "the Postgres API"]];
63
+ export const PORTS = [[DEFAULT_UI_PORT, "the mason commands"], [DEFAULT_PG_PORT, "the Postgres API"]];
49
64
  /** Node's version against the package's floor, major then minor. */
50
65
  export function nodeCheck(version = process.versions.node) {
51
66
  const [major, minor] = version.split(".").map(Number);
@@ -54,21 +69,38 @@ export function nodeCheck(version = process.versions.node) {
54
69
  ? { name: "node", ok: true, detail: `Node ${version}` }
55
70
  : { name: "node", ok: false, detail: `Node ${version} is too old: mason needs ${NODE_MIN} or later (nodejs.org, or nvm install ${minMajor})` };
56
71
  }
57
- /** The machine's memory and what the copy's engine may use of it (src/duck/store.ts DEFAULT_MEMORY_LIMIT, MASON_DUCKDB_MEMORY). */
58
- export function memoryCheck(totalBytes = machineMemoryBytes(), env = process.env) {
59
- const gib = (totalBytes / 1024 ** 3).toFixed(1);
60
- const set = env.MASON_DUCKDB_MEMORY;
61
- if (set !== undefined && !MEMORY_LIMIT_RE.test(set)) {
62
- return { name: "memory", ok: false, detail: `MASON_DUCKDB_MEMORY=${JSON.stringify(set)} is not a size: write it as 4GiB, 512MiB or 2GB (mason start refuses it too)` };
72
+ /**
73
+ * The machine's memory and what the copy's engine may use of it (src/duck/store.ts DEFAULT_MEMORY_LIMIT,
74
+ * MASON_DUCKDB_MEMORY): what a start refuses is said in the start's own words (`memoryProblems`), and what it warns of
75
+ * under the floor in the run's own words (`memoryWarning`), a warning, never a failure, since the start goes on; so is
76
+ * a share the operator set that leaves less than OUTSIDE_RESERVE_BYTES beside it.
77
+ */
78
+ export function memoryCheck(memory = machineMemory(), env = process.env) {
79
+ const problems = memoryProblems(env);
80
+ if (problems.length)
81
+ return { name: "memory", ok: false, detail: problems.map(memoryProblemText).join("; ") };
82
+ const warned = memoryWarning(memory, env);
83
+ const set = env.MASON_DUCKDB_MEMORY || undefined;
84
+ const share = set ?? defaultMemoryLimit(memory.bytes);
85
+ const outside = memory.bytes - memoryLimitBytes(share);
86
+ if (warned !== null) {
87
+ return { name: "memory", ok: true, warn: true,
88
+ detail: `${memoryProblemText(warned)} (the engine uses up to ${share.trim()}, the default, leaving ${memorySize(Math.max(0, outside))} beside it)` };
63
89
  }
64
- const share = set ?? defaultMemoryLimit(totalBytes);
65
- const low = totalBytes < 2 * 1024 ** 3;
66
90
  return {
67
- name: "memory", ok: !low,
68
- detail: `${gib} GiB; Mason's copy uses up to ${share.trim()}${set !== undefined ? " (MASON_DUCKDB_MEMORY)" : " (half, the default; MASON_DUCKDB_MEMORY sets it)"}` +
69
- (low ? ". Under 2 GiB, a large table's copy and a query starve each other: expect statements refused for memory" : ""),
91
+ name: "memory", ok: true, ...(outside < OUTSIDE_RESERVE_BYTES ? { warn: true } : {}),
92
+ detail: `${memorySize(memory.bytes)}; the engine uses up to ${share.trim()}${set !== undefined ? " (MASON_DUCKDB_MEMORY)" : " (the default; MASON_DUCKDB_MEMORY sets it)"}, ` +
93
+ `leaving ${memorySize(Math.max(0, outside))} for Mason beside it` +
94
+ (outside < OUTSIDE_RESERVE_BYTES ? `, under the ${memorySize(OUTSIDE_RESERVE_BYTES)} it needs (measured, with a margin): the system may stop it in a first sync, ` +
95
+ "a generated table's build or a burst of writes (a smaller MASON_DUCKDB_MEMORY, or more memory)" : ""),
70
96
  };
71
97
  }
98
+ /** The copy's extensions (src/duck/extensions.ts): shipped with Mason and loaded by an engine of their own as the run's
99
+ * will load them, or not shipped, and why. `probe` is a test's. */
100
+ export async function extensionsCheck(probe = () => probeExtensions()) {
101
+ const p = await probe();
102
+ return { name: "extensions", ok: p.ok, detail: p.detail };
103
+ }
72
104
  /**
73
105
  * Mason's ports in one line, decided by the probe mason start uses (`portHolder`, src/cli/ports.ts: every address, so
74
106
  * doctor and start agree on a port another program holds on the wildcard). A taken command port only moves Mason to
@@ -110,25 +142,72 @@ function portOf(url) {
110
142
  return null;
111
143
  } // not a URL: no port to compare with
112
144
  }
113
- /** The database a URL names: reachable, its version, what this user can read. */
114
- export async function databaseCheck(url, o = {}) {
115
- const parsed = parseSource(url);
145
+ /**
146
+ * The database a URL names: reachable, its version, what this user can read — and, given `role` (the project's synced
147
+ * tables), whether that user could write to it (src/cli/source-role.ts), on the same connection. `name` says where the
148
+ * URL is (`DATABASE_URL in .env`): a refusal names it, never the value (src/cli/preflight.ts shownUrl).
149
+ */
150
+ export async function databaseChecks(url, o = {}) {
151
+ const parsed = parseSource(url, o.name);
116
152
  if ("problem" in parsed)
117
- return { name: "database", ok: false, detail: parsed.problem };
153
+ return [{ name: "database", ok: false, detail: parsed.problem }];
118
154
  const connected = await connectSource(parsed.source, o);
119
155
  if ("failure" in connected)
120
- return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` };
156
+ return [{ name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` }];
157
+ try {
158
+ const facts = await readSourceFacts(connected.pg);
159
+ const tooOld = versionProblem(facts);
160
+ if (tooOld)
161
+ return [{ name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` }];
162
+ const database = { name: "database", ok: true, detail: `${maskedUrl(parsed.source.url)}: Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read` };
163
+ return o.role ? [database, ...await askRole(connected.pg, o.role)] : [database];
164
+ }
165
+ finally {
166
+ connected.pg.close();
167
+ }
168
+ }
169
+ /** The role's lines, from its one catalog read; a read that failed is "could not tell", never "only reads". */
170
+ async function askRole(pg, ask) {
121
171
  let facts;
122
172
  try {
123
- facts = await readSourceFacts(connected.pg);
173
+ facts = await readRoleFacts(pg, ask.tables);
174
+ }
175
+ catch (e) {
176
+ return [roleUnknown(e.message)];
177
+ }
178
+ return roleChecks(facts, ask);
179
+ }
180
+ /** What the role check asks of a project's sources file: its tables, whether one streams, its publication, its URL. */
181
+ export function roleAsk(src, root, env = process.env) {
182
+ return {
183
+ tables: src.tables.map((e) => { const at = e.table.indexOf("."); return { schema: e.table.slice(0, at), table: e.table.slice(at + 1) }; }),
184
+ replicated: replicatedEntries(src).map((e) => e.table),
185
+ publication: namedPublication(src, env)?.name ?? null,
186
+ urlVariable: "env" in src.url ? src.url.env : null, urlFrom: src.from, file: shownPath(root, src.file), url: src.resolved,
187
+ };
188
+ }
189
+ /**
190
+ * The role's lines with Mason running (its own checks never connect to the database): the project's sources file read
191
+ * and the database asked, on a connection of its own. What cannot be read is "could not tell".
192
+ */
193
+ export async function runRoleChecks(project) {
194
+ if (project.config.demo === true)
195
+ return []; // the demo database is Mason's own, inside the run
196
+ const src = projectSource(project);
197
+ if ("message" in src)
198
+ return [roleUnknown(`${src.message} (mason status)`)];
199
+ const parsed = parseSource(src.resolved);
200
+ if ("problem" in parsed)
201
+ return [roleUnknown(parsed.problem)];
202
+ const connected = await connectSource(parsed.source);
203
+ if ("failure" in connected)
204
+ return [roleUnknown(`${maskedUrl(parsed.source.url)}: ${connected.failure.message}`)];
205
+ try {
206
+ return await askRole(connected.pg, roleAsk(src, project.root));
124
207
  }
125
208
  finally {
126
209
  connected.pg.close();
127
210
  }
128
- const tooOld = versionProblem(facts);
129
- if (tooOld)
130
- return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` };
131
- return { name: "database", ok: true, detail: `${maskedUrl(parsed.source.url)}: Postgres ${facts.versionText}${facts.standby ? " (a read replica)" : ""}, ${facts.tables.length} table(s) this user can read` };
132
211
  }
133
212
  /** The run's health, from its own state: every table copied, failing, or still at work; and what it refused or answered slowly. */
134
213
  export async function runChecks(run) {
@@ -153,7 +232,7 @@ export async function runChecks(run) {
153
232
  else if (ready.waiting.length)
154
233
  checks.push({ name: "project", ok: null, detail: `still at work: ${ready.waiting.join("; ")} (mason wait)` });
155
234
  else
156
- checks.push({ name: "project", ok: true, detail: picked ? `${picked} table(s) copied and kept current` : "no table added yet (mason tables, then mason add <table>)" });
235
+ checks.push({ name: "project", ok: true, detail: picked ? `${picked} table(s) synced and kept current` : "no table added yet (mason tables, then mason add <table>)" });
157
236
  const slow = await callRun(run, "GET", "/api/slow"), refused = await callRun(run, "GET", "/api/refused");
158
237
  const slowN = slow.status === 200 ? slow.body.slow.length : null, refusedN = refused.status === 200 ? refused.body.refused.length : null;
159
238
  checks.push(slowN === null || refusedN === null
@@ -170,12 +249,12 @@ export function nextSteps(checks, run, project) {
170
249
  return [["mason doctor", "again, once .mason/config.json is repaired or moved aside (the detail above says how)"]];
171
250
  // the demo database is hosted by the run itself (src/cli/demo.ts): down until mason start makes it
172
251
  if (bad("database") && project?.config.demo === true)
173
- return [["mason start", "hosts the demo database, then copies its tables"]];
252
+ return [["mason start", "hosts the demo database, then syncs its tables"]];
174
253
  if (bad("database"))
175
254
  return [["mason start", `once the connection works (the detail above says how; the URL is in ${project ? `${project.dir}/config.json` : ".mason/config.json"})`]];
176
255
  if (project === null)
177
256
  return [
178
- ["mason init", "in your app's folder: the database from DATABASE_URL in .env.local, .env or the shell"],
257
+ ["mason init", "in your app's folder: the database from MASON_DATABASE_URL or DATABASE_URL in .env.local, .env or the shell"],
179
258
  ["mason init postgres://user:password@localhost:5432/your_app", "or name it"],
180
259
  ];
181
260
  if (run === null)
@@ -185,7 +264,7 @@ export function nextSteps(checks, run, project) {
185
264
  if (bad("project"))
186
265
  return [["mason wait", "each problem, with the way out"]];
187
266
  if (checks.some((c) => c.name === "project" && /no table added/.test(c.detail)))
188
- return [["mason tables", "the database's tables and their columns"], ["mason add <table>", "copy a table into Mason"]];
267
+ return [["mason tables", "the database's tables and their columns"], ["mason add <table>", "sync a table into Mason"]];
189
268
  if (bad("queries"))
190
269
  return [["mason status", "the refused and slow statements, each with what to do"]];
191
270
  return [["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
@@ -205,7 +284,7 @@ export function verdictOf(checks, run, project) {
205
284
  }
206
285
  /** A check's mark: ok, !! a failure, ? could not tell. */
207
286
  function markOf(c, s) {
208
- return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : s.green("ok ");
287
+ return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : c.warn === true ? s.yellow("! ") : s.green("ok ");
209
288
  }
210
289
  export async function doctor(argv, deps = {}) {
211
290
  const f = parseCommand(DOCTOR, argv);
@@ -222,12 +301,18 @@ export async function doctor(argv, deps = {}) {
222
301
  if (run === null) {
223
302
  checks.push(nodeCheck());
224
303
  checks.push(memoryCheck());
304
+ checks.push(await extensionsCheck());
225
305
  checks.push(await portsCheck([], portHolder, process.env, project?.config.pgPort ?? null));
226
306
  if (project) {
227
307
  // the database the project's sources file names (src/cli/sources.ts): a file that cannot be read is the failing line
228
308
  const src = projectSource(project);
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() }
230
- : await databaseCheck(src.resolved, { demo: project.config.demo === true }));
309
+ if ("message" in src)
310
+ checks.push({ name: "database", ok: false, detail: `${src.message}${src.why ? `: ${src.why.replace(/\n/g, "; ")}` : ""}. ${src.fix?.split("\n")[0] ?? ""}`.trim() });
311
+ // the demo database is Mason's own, inside the run: whom it connects as is not the person's to change
312
+ else if (project.config.demo === true)
313
+ checks.push(...await databaseChecks(src.resolved, { demo: true, name: src.from }));
314
+ else
315
+ checks.push(...await databaseChecks(src.resolved, { role: roleAsk(src, project.root), name: src.from }));
231
316
  }
232
317
  // a project here whose config cannot be read: init would refuse, so "Ready: mason init" would send a person nowhere
233
318
  if ("code" in which && which.code === "bad_config")
@@ -235,6 +320,8 @@ export async function doctor(argv, deps = {}) {
235
320
  }
236
321
  else {
237
322
  checks.push(...await runChecks(run));
323
+ if (project)
324
+ checks.push(...await runRoleChecks(project));
238
325
  }
239
326
  const next = nextSteps(checks, run, project);
240
327
  const { ready, verdict } = verdictOf(checks, run, project);
@@ -252,8 +339,14 @@ export async function doctor(argv, deps = {}) {
252
339
  const mark = markOf(c, s);
253
340
  out(` ${mark} ${c.name.padEnd(w)} ${wrap(c.detail, s.width, 2 + 3 + 1 + w + 2)}`);
254
341
  }
342
+ // a way out several lines share, once, under them: SQL as what you type, the rest as its words
343
+ for (const fix of new Set(checks.map((c) => c.fix).filter((x) => x !== undefined))) {
344
+ out("");
345
+ for (const line of fix.split("\n"))
346
+ out(` ${/;(\s+--.*)?$/.test(line) ? s.cyan(line) : line.startsWith("--") ? s.dim(line) : wrap(line, s.width, 4)}`);
347
+ }
255
348
  if (run !== null)
256
- out(s.dim(` its copy of the data and its state in ${run.home}. mason status for the details`));
349
+ out(s.dim(` its synced data and its state in ${run.home}. mason status for the details`));
257
350
  if (run === null || !ready)
258
351
  out(nextBlock(next));
259
352
  return ready ? 0 : 1;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * THE PROJECT'S GENERATED TABLES (issue 124): a folder beside the sources file, one file per table, the project's to
3
+ * commit and review. The file's name is the table's: `requests.yml` is `public.requests`, `reports.daily.yml` is
4
+ * `reports.daily`.
5
+ *
6
+ * kind: scheduled what the table promises: `scheduled` (any SELECT, up to target_lag behind its base)
7
+ * target_lag: 5m how far behind it may be
8
+ * sql: | what it holds
9
+ * SELECT tenant_id, count(*) AS requests FROM events GROUP BY 1
10
+ * order_by: tenant_id optional: what follows ORDER BY, over its columns, one SQL string
11
+ * comment: Requests per tenant. optional: COMMENT ON TABLE
12
+ *
13
+ * Read here for its shape alone: each mistake at its key, with the reason and the way out, all of a file's at once. What
14
+ * its SQL means is the catalog's to say (src/duck/generated-plan.ts). A mistake never stops the run or another table,
15
+ * unlike a sources file's: a table already deployed keeps serving what it was built from (src/duck/generated.ts).
16
+ *
17
+ * `GENERATED_KEYS` is the one list of keys: a key the reader takes and the guide says (`mason help generated`), so neither
18
+ * can name one the other does not.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { parseDocument } from "yaml";
23
+ import { didYouMean } from "../lib/did-you-mean.js";
24
+ import { parseLag } from "../lib/table-sync.js";
25
+ import { shownPath, tableNameProblem } from "./sources.js";
26
+ /** Where a project's generated tables are: `<folder>/generated_tables/`, beside `sources/`. */
27
+ export const GENERATED_DIR = "generated_tables";
28
+ export const generatedDir = (folder) => path.join(folder, GENERATED_DIR);
29
+ /** Every key a file takes, and what it says, as a person reads it. */
30
+ export const GENERATED_KEYS = {
31
+ kind: "required: how the table stays current. scheduled: any SELECT, up to target_lag behind its base. live is not available yet",
32
+ sql: "required: the one SELECT that gives the table, over the tables the sources file syncs",
33
+ target_lag: "required for scheduled: how far behind its base it may be, like 30s, 5m or 1h (at least 1s, at most 24h). Mason looks at each table every 5s, so a table under 5s can be up to 5s behind",
34
+ order_by: "optional: the table's rows are written in this order, as what follows ORDER BY, over its columns, one SQL string",
35
+ comment: "optional: the table's comment, as COMMENT ON TABLE",
36
+ };
37
+ const KEYS = Object.keys(GENERATED_KEYS);
38
+ const LIVE_WHY = "A table kept current in its base's own write isn't built yet, and Mason never runs a table as another kind than its file says.";
39
+ const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
40
+ const refusal = (key, message, options = [], why = null) => ({ key, message, why, options });
41
+ /** The file's text read as a definition, or everything wrong with it. */
42
+ export function parseGeneratedText(text, name, file) {
43
+ const at = { name: `${name.schema}.${name.table}`, schema: name.schema, table: name.table, file };
44
+ const doc = parseDocument(text, { uniqueKeys: true });
45
+ if (doc.errors.length)
46
+ return { ...at, kind: null, refusals: [refusal(null, `it is not YAML: ${doc.errors[0].message.split("\n")[0]}`)] };
47
+ const v = doc.toJS();
48
+ if (!isPlain(v))
49
+ return { ...at, kind: null, refusals: [refusal(null, `it is ${v === null || v === undefined ? "empty" : "not a mapping"}`, ["write kind: scheduled, target_lag: 5m and sql: SELECT …"])] };
50
+ const refusals = [];
51
+ for (const k of Object.keys(v))
52
+ if (!KEYS.includes(k))
53
+ refusals.push(refusal(k, `is not a key${didYouMean(k, KEYS)}`, [`a generated table takes ${KEYS.join(", ")}`]));
54
+ const kind = typeof v.kind === "string" ? v.kind : null;
55
+ const lagWritten = v.target_lag !== undefined;
56
+ const lag = lagWritten ? parseLag(v.target_lag) : null;
57
+ if (v.kind === undefined || v.kind === null)
58
+ refusals.push(refusal("kind", "is missing", ["write kind: scheduled with a target_lag, e.g. target_lag: 5m"], "Mason never decides how a table stays current for you."));
59
+ else if (kind === "live") {
60
+ refusals.push(refusal("kind", "live isn't available yet", ["set kind: scheduled with a target_lag, e.g. target_lag: 5m"], LIVE_WHY));
61
+ if (lagWritten)
62
+ refusals.push(refusal("target_lag", "contradicts kind: live", ["remove target_lag", "or set kind: scheduled"], "A live table is as fresh as its base, so it has no lag to choose."));
63
+ }
64
+ else if (kind !== "scheduled")
65
+ refusals.push(refusal("kind", `${String(v.kind)} is not a kind${kind === null ? "" : didYouMean(kind, ["live", "scheduled"])}`, ["write kind: scheduled with a target_lag, e.g. target_lag: 5m"]));
66
+ else if (!lagWritten)
67
+ refusals.push(refusal("target_lag", "is missing", ["write how stale is acceptable, e.g. target_lag: 5m"], "A scheduled table may lag its base, so its file says by how much."));
68
+ if (lag !== null && "problem" in lag)
69
+ refusals.push(refusal("target_lag", lag.problem));
70
+ const text1 = (key) => {
71
+ const x = v[key];
72
+ if (x === undefined)
73
+ return null;
74
+ if (typeof x === "string" && x.trim() !== "")
75
+ return x;
76
+ refusals.push(refusal(key, key === "order_by" ? "is one SQL string, as after ORDER BY" : "is empty or not text", key === "order_by" ? ["write order_by: day, tenant_id: a list would split on the commas inside an expression"] : []));
77
+ return null;
78
+ };
79
+ const sql = text1("sql");
80
+ if (v.sql === undefined)
81
+ refusals.push(refusal("sql", "is missing", ["write sql: | and the SELECT that gives the table"]));
82
+ const orderBy = text1("order_by"), comment = text1("comment");
83
+ if (refusals.length > 0 || sql === null || lag === null || "problem" in lag)
84
+ return { ...at, kind, refusals };
85
+ return { ...at, sql, orderBy: orderBy?.trim() ?? null, targetLag: lag, comment };
86
+ }
87
+ /** a file's name as the table it names: `requests.yml`, `reports.daily.yml`; or why it names none */
88
+ function tableOfFile(file) {
89
+ const name = file.replace(/\.ya?ml$/, "");
90
+ const bad = tableNameProblem(name);
91
+ if (bad !== null)
92
+ return bad;
93
+ const dot = name.indexOf(".");
94
+ const schema = dot < 0 ? "public" : name.slice(0, dot), table = dot < 0 ? name : name.slice(dot + 1);
95
+ if (schema === "_mason" || schema === "information_schema" || schema.startsWith("pg_") || table.startsWith("pg_") || table.includes("__mason"))
96
+ return `${name} is a name Postgres or Mason keeps for itself`;
97
+ return { schema, table };
98
+ }
99
+ /** The folder as it is now: every definition in shape, and every file refused. A folder that is not there holds none. */
100
+ export function readGenerated(folder, root) {
101
+ const dir = generatedDir(folder);
102
+ let names;
103
+ try {
104
+ names = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
105
+ }
106
+ catch (e) {
107
+ if (e.code === "ENOENT")
108
+ return { definitions: [], refused: [] };
109
+ throw e;
110
+ }
111
+ const out = { definitions: [], refused: [] };
112
+ const seen = new Map();
113
+ for (const name of names) {
114
+ const full = path.join(dir, name), shown = shownPath(root, full);
115
+ const stem = name.replace(/\.ya?ml$/, "");
116
+ const t = tableOfFile(name);
117
+ if (typeof t === "string") {
118
+ const dot = stem.indexOf(".");
119
+ out.refused.push({ name: dot < 0 ? `public.${stem}` : stem, schema: dot < 0 ? "public" : stem.slice(0, dot), table: dot < 0 ? stem : stem.slice(dot + 1), file: shown, kind: null, refusals: [refusal(null, t, ["rename the file: its name is the table's"])] });
120
+ continue;
121
+ }
122
+ const table = `${t.schema}.${t.table}`, before = seen.get(table);
123
+ if (before !== undefined) {
124
+ out.refused.push({ name: table, ...t, file: shown, kind: null, refusals: [refusal(null, `names the table ${table}, as ${before} does`, ["keep one"])] });
125
+ continue;
126
+ }
127
+ seen.set(table, shown);
128
+ let text;
129
+ try {
130
+ text = fs.readFileSync(full, "utf8");
131
+ }
132
+ catch (e) {
133
+ out.refused.push({ name: table, ...t, file: shown, kind: null, refusals: [refusal(null, `it cannot be read: ${e.message}`)] });
134
+ continue;
135
+ }
136
+ const read = parseGeneratedText(text, t, shown);
137
+ if ("refusals" in read)
138
+ out.refused.push(read);
139
+ else
140
+ out.definitions.push(read);
141
+ }
142
+ return out;
143
+ }