@mason-data/cli 0.8.0 → 0.10.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -29,16 +29,19 @@ import * as path from "node:path";
29
29
  import { choosePgPort, freePort } from "./ports.js";
30
30
  import { failed } from "./command.js";
31
31
  import { checkSource, connectSource, maskedUrl, readSourceFacts, remoteConfirmation, remoteWarning } from "./preflight.js";
32
- import { createRunServer, metricsFacts, usageFacts } from "./run-server.js";
32
+ import { createRunServer, listenOnLoopback, metricsFacts, usageFacts } from "./run-server.js";
33
+ import { readGenerated } from "./generated-files.js";
33
34
  import { addedTables, effectiveSync, entryName, replicatedEntries, rereadSources, shownPath, syncWritten } from "./sources.js";
34
35
  import { newToken } from "./security.js";
35
- import { liveRun } from "./runs.js";
36
- import { DEMO_DIR, DUCK_FILE, projectSource, startSettingRefusal, theProject, writeConfig } from "./project.js";
36
+ import { alive, liveRun } from "./runs.js";
37
+ import { DEMO_DIR, DUCK_FILE, projectReadOnlySetting, projectSource, startSettingRefusal, theProject, writeConfig } from "./project.js";
37
38
  import { closedWithin, closingOnce, DEMO_STILL_CLOSING, demoChanges, demoChangesRefusal, demoPort, demoSettings, openDemo } from "./demo.js";
38
39
  import { postgresApiUrl, readPgTls } from "../api/postgres/listener.js";
39
40
  import { connectDatabase } from "../lib/database-name.js";
40
41
  import { certificateFingerprint, elsewhereTip, ensureSelfSigned, listenerText, runListenerSettings, selfSignedPair, SELF_SIGNED_FILES } from "./pg-tls.js";
41
42
  import { RunShares } from "./share-run.js";
43
+ import { PageShares } from "./page-share.js";
44
+ import { sweepTunnelFiles } from "./tunnel.js";
42
45
  import { startPostgresApi, timeoutMs } from "../api/postgres/server.js";
43
46
  import { RequestLedger, REQUESTS_TABLE } from "../duck/requests.js";
44
47
  import { historyDaysSetting } from "../duck/history.js";
@@ -47,17 +50,19 @@ import { USAGE_TABLE, UsageSampler } from "../duck/usage.js";
47
50
  import { withPlaceholders } from "./pg-url.js";
48
51
  import { UI } from "./ui.js";
49
52
  import { cliPinProblem, ensurePin } from "./pin.js";
50
- import { DEFAULT_MEMORY_LIMIT, DuckStore, DUCKDB_VERSION } from "../duck/store.js";
51
- import { copyChunkSecondsOf, copyParallelOf, Sync, syncModeOf } from "../duck/sync.js";
53
+ import { DEFAULT_MEMORY_LIMIT, DuckStore, DUCKDB_VERSION, machineMemory, memoryProblems, memoryProblemText, memoryWarning } from "../duck/store.js";
54
+ import { copyChunkSecondsOf, copyParallelOf, humanBytes, Sync, syncModeOf } from "../duck/sync.js";
52
55
  import { storeJsonPathsOf } from "../duck/kept.js";
53
- import { readLibcOrder } from "../duck/libc-order.js";
54
- import { isLibcCollation } from "../duck/rewrite.js";
56
+ import { libcOrderNote, readLibcOrder } from "../duck/libc-order.js";
57
+ import { isLibcCollation } from "../duck/collation.js";
58
+ import { pgIdent } from "../cdc/pg-wire.js";
55
59
  import { listenerLogins, LiveUsers, readUsers, USERS_FILE } from "../duck/users.js";
56
60
  import { exampleRole, LiveRoles, missingAttributesText, noRoleWayOut, readRoles, resolveRoles, rolesDefaultLine, rolesFile, usersMissingAttributes, usersWithNoRole, usersWithoutRole } from "./roles.js";
57
61
  import { READY_LINE, readRestarts } from "./supervise.js";
58
62
  import { dropOwnReplication, dropStatements, liveReadinessFailure, madeReplication, namedPublication, projectReplication, recordRefusal, replicationReadiness, staleReplication } from "./replication-mode.js";
59
63
  import { textEntry } from "../lib/log.js";
60
64
  import { LogBuffer } from "../lib/log-buffer.js";
65
+ import { logPath } from "../lib/log-read.js";
61
66
  import { BIG_TABLE_ROWS, syncText } from "../lib/table-sync.js";
62
67
  const say = (m) => { process.stdout.write(`${m}\n`); };
63
68
  /**
@@ -115,6 +120,9 @@ async function closeDemo(hosted, closed) {
115
120
  }
116
121
  /** how soon a pass that failed (the database away) is tried again, and replication mode's pass when a table waits */
117
122
  const PASS_RETRY_MS = 5000;
123
+ /** how long a libc collation's order may take to read from the source (a few seconds of read-only SELECTs): past it the
124
+ * read is given up, and the statements that need it refused by name */
125
+ const LIBC_READ_MS = 120_000;
118
126
  /** the tables by their sync, as the start line says them: ` (2 polled, every 5s and 30s; 1 replicated; 1 off)` */
119
127
  function syncCounts(tables) {
120
128
  const polled = tables.flatMap((t) => t.sync?.mode === "poll" ? [t.sync.every] : []);
@@ -201,11 +209,29 @@ async function run(o, hosted) {
201
209
  const setting = startSettingRefusal(project, process.env, { serve: o.serve !== undefined });
202
210
  if (setting !== null)
203
211
  return failed("start", setting, { json: false });
212
+ // THE PROJECT'S FILES DEPLOYED READ-ONLY (MASON_PROJECT_READONLY=1): recorded in the config at every start, and removed
213
+ // at a start without, so a command beside the run — up or down, whatever its own environment — is refused as the run
214
+ // is (src/cli/project.ts projectWriteRefusal reads the record and the environment; a value not 0 or 1 is refused above)
215
+ if (projectReadOnlySetting(process.env) === true) {
216
+ if (cfg.projectReadOnly !== true) {
217
+ cfg.projectReadOnly = true;
218
+ writeConfig(home, cfg);
219
+ }
220
+ }
221
+ else if (cfg.projectReadOnly !== undefined) {
222
+ delete cfg.projectReadOnly;
223
+ writeConfig(home, cfg);
224
+ }
204
225
  // the history's days (MASON_HISTORY_DAYS, a number of days; the old MASON_REQUEST_LOG_DAYS refused, named), read
205
226
  // before anything opens (src/duck/history.ts); every history below keeps them
206
227
  const keptDays = historyDaysSetting(process.env);
207
228
  if (typeof keptDays !== "number")
208
229
  return failed("start", { code: "refused", ...keptDays }, { json: false });
230
+ // MASON_DUCKDB_MEMORY not an amount (src/duck/store.ts memoryProblems), refused before anything opens (mason serve has
231
+ // said it already, with its other settings)
232
+ const memory = memoryProblems(process.env)[0];
233
+ if (memory !== undefined)
234
+ return failed("start", { code: "refused", ...memory }, { json: false });
209
235
  // THE SOURCE (src/cli/sources.ts): the database and the tables to copy, from the project's sources file; a server's
210
236
  // URL from its environment first (mason serve is configured by it alone). Read here, its failure said below where it
211
237
  // always was (after the listener's checks): a demo project's port is needed first
@@ -239,7 +265,7 @@ async function run(o, hosted) {
239
265
  return failed("start", pgTls, { json: false });
240
266
  const chosen = new Set([pg.port, ...demoPorts]);
241
267
  // a server's project folder is the team's, committed, and may be mounted read-only: mason serve writes nothing there
242
- const wrotePin = o.serve ? null : ensurePin(folder, o.version);
268
+ const wrotePin = o.serve ? null : ensurePin(folder, o.version, project.root);
243
269
  if (wrotePin)
244
270
  say(` wrote ${path.relative(project.root, wrotePin)}: version ${o.version}, the Mason this project is written for — commit it`);
245
271
  if ("message" in src)
@@ -248,7 +274,7 @@ async function run(o, hosted) {
248
274
  const demoFile = demoChangesRefusal(cfg, src, shownPath(project.root, src.file));
249
275
  if (demoFile)
250
276
  return failed("start", demoFile, { json: false });
251
- const logFile = path.join(home, "mason.log");
277
+ const logFile = logPath(home, "run");
252
278
  const log = fs.createWriteStream(logFile, { flags: "a" });
253
279
  // one entry a line: a multi-line message's further lines indented under it (src/lib/log.ts `textEntry`);
254
280
  // mason serve gets the same entry, for the container's stdout (src/cli/serve.ts)
@@ -283,7 +309,7 @@ async function run(o, hosted) {
283
309
  const db = maskedUrl(src.resolved);
284
310
  begin(`Connecting to ${db}`);
285
311
  const checkSaid = [];
286
- const checked = await checkSource(src.resolved, { remote: cfg.remote, cmd: "mason start", say: (m) => checkSaid.push(m),
312
+ const checked = await checkSource(src.resolved, { remote: cfg.remote, cmd: "mason start", say: (m) => checkSaid.push(m), name: src.from,
287
313
  // the checklist's line says it is connecting; everything else checkSource says is the answer
288
314
  progress: () => undefined });
289
315
  if ("needsRemote" in checked) {
@@ -400,7 +426,10 @@ async function run(o, hosted) {
400
426
  if (rolesRead !== null && "message" in rolesRead)
401
427
  return failed("start", rolesRead, { json: false });
402
428
  if (rolesRead !== null) {
403
- const resolved = resolveRoles(rolesRead, firstTables, project.root);
429
+ // a role may name a generated table, whose columns the catalog knows only once it is built (src/duck/generated.ts)
430
+ const generatedFiles = readGenerated(folder, project.root);
431
+ const generatedNames = [...generatedFiles.definitions, ...generatedFiles.refused].map((g) => ({ source: g.name, columns: [], pending: true }));
432
+ const resolved = resolveRoles(rolesRead, [...firstTables, ...generatedNames], project.root);
404
433
  if ("message" in resolved)
405
434
  return failed("start", resolved, { json: false });
406
435
  }
@@ -436,8 +465,10 @@ async function run(o, hosted) {
436
465
  let store;
437
466
  // its memory bounded (src/duck/store.ts DEFAULT_MEMORY_LIMIT): past it a statement spills, or fails alone
438
467
  const memoryLimit = process.env.MASON_DUCKDB_MEMORY || undefined;
468
+ // what statements may spill (src/duck/store.ts spillCeiling): past it a statement fails alone, and the disk is kept
469
+ const spillLimit = process.env.MASON_SPILL_LIMIT || undefined;
439
470
  try {
440
- store = await DuckStore.open(duckFile, (l) => logLine(`[engine] ${l}`), memoryLimit === undefined ? {} : { memoryLimit });
471
+ store = await DuckStore.open(duckFile, (l) => logLine(`[engine] ${l}`), { ...(memoryLimit === undefined ? {} : { memoryLimit }), ...(spillLimit === undefined ? {} : { spillLimit }) });
441
472
  }
442
473
  catch (e) {
443
474
  fail("Opening the synced data");
@@ -454,7 +485,16 @@ async function run(o, hosted) {
454
485
  else
455
486
  say(` ! the engine's thread stopped: ${reason}`);
456
487
  });
457
- done(`the synced data in ${path.relative(project.root, duckFile)} (mason destroy deletes it; engine ${DUCKDB_VERSION}), at most ${(memoryLimit ?? DEFAULT_MEMORY_LIMIT).trim()} of memory (MASON_DUCKDB_MEMORY), spilling to ${path.relative(project.root, duckFile)}.tmp`);
488
+ done(`the synced data in ${path.relative(project.root, duckFile)} (mason destroy deletes it; engine ${DUCKDB_VERSION}), at most ${(memoryLimit ?? DEFAULT_MEMORY_LIMIT).trim()} of memory (MASON_DUCKDB_MEMORY), spilling at most ${humanBytes(store.spillLimitBytes)} to ${path.relative(project.root, duckFile)}.tmp (MASON_SPILL_LIMIT)`);
489
+ // UNDER THE MEMORY FLOOR the run goes on and warns (src/duck/store.ts memoryWarning, the one text): here at its start,
490
+ // in its log, and in its warnings, which mason status, --json and the web page read for as long as it runs
491
+ const memoryWarned = memoryWarning(machineMemory(), process.env);
492
+ if (memoryWarned !== null) {
493
+ // the log (mason serve's stdout among its writers), and mason start's own terminal
494
+ logLine(`[engine] warning: ${memoryProblemText(memoryWarned)}`);
495
+ if (o.serve === undefined)
496
+ say(` ! ${memoryProblemText(memoryWarned)}`);
497
+ }
458
498
  const syncMode = syncModeOf(process.env);
459
499
  // THE SYNC'S EVENTS (src/duck/events.ts): its failures and notable events, kept as the request log is, before the sync
460
500
  // that records them starts
@@ -504,7 +544,8 @@ async function run(o, hosted) {
504
544
  logLine(`[sync] ${r.said}`);
505
545
  sources = { file: r.file, tables: r.tables, problem: r.problem };
506
546
  sync.setViews(facts.views ?? []);
507
- void sync.setTables(sources.tables);
547
+ sync.setCollations(facts.collations ?? []);
548
+ return sync.setTables(sources.tables);
508
549
  };
509
550
  if (pg.said)
510
551
  say(` ${pg.said}`);
@@ -513,6 +554,7 @@ async function run(o, hosted) {
513
554
  // A LIBC COLLATION'S ORDER IS READ FROM THE DATABASE (src/duck/libc-order.ts): once, a few read-only SELECTs, kept in
514
555
  // the copy and read again when the database's collation version moves (an OS upgrade's glibc)
515
556
  let libc;
557
+ let textOrder = null;
516
558
  const coll = facts.collation;
517
559
  if (coll !== undefined && isLibcCollation(coll.provider, coll.collate)) {
518
560
  const reading = `Reading how ${source.database} orders text (${coll.collate}${coll.version ? `, version ${coll.version}` : ""})`;
@@ -532,6 +574,10 @@ async function run(o, hosted) {
532
574
  libc = r.order;
533
575
  if (r.read)
534
576
  done(`Read how ${source.database} orders text (${coll.collate}): kept with the synced data, read again only when its collation version moves`);
577
+ // what the read leaves to say is in the log and in mason status's Text order; an order not followed at the start too
578
+ textOrder = libcOrderNote(r.order);
579
+ if (textOrder !== null)
580
+ (r.order.unread ? say : logLine)(` ! ${textOrder}`);
535
581
  }
536
582
  catch (e) {
537
583
  fail(reading);
@@ -542,6 +588,49 @@ async function run(o, hosted) {
542
588
  return 1;
543
589
  }
544
590
  }
591
+ // EVERY OTHER LIBC COLLATION'S ORDER (src/duck/collation.ts), read the same way under its own name: a synced column's
592
+ // at the start, one a statement names first on its first use (the plan waits for it, under a bound). A column's that
593
+ // cannot be read is said, and its statements refused by name (libc-locale-unread) until a read succeeds
594
+ const readNamed = async (sc) => {
595
+ const c = await connectSource(source);
596
+ if ("failure" in c)
597
+ throw new Error(`could not connect to read it: ${c.failure.message}`);
598
+ const read = readLibcOrder(async (sql) => (await c.pg.query(sql)).rows, sc.collate ?? "C", sc.version ?? null, `${pgIdent(sc.schema)}.${pgIdent(sc.name)}`);
599
+ // (a read given up at the bound ends when its connection closes: said, as the race has moved on)
600
+ read.catch((e) => logLine(`[engine] the read of the collation ${sc.name} ended: ${e.message}`));
601
+ let timer;
602
+ const bound = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`the read took longer than ${LIBC_READ_MS / 1000} s`)), LIBC_READ_MS); });
603
+ try {
604
+ return await Promise.race([read, bound]);
605
+ }
606
+ finally {
607
+ clearTimeout(timer);
608
+ c.pg.close();
609
+ }
610
+ };
611
+ const libcOrders = new Map();
612
+ const byName = new Map((facts.collations ?? []).map((c) => [`${c.schema}\u0000${c.name}`, c]));
613
+ const columnLibc = new Map();
614
+ for (const t of sources.tables)
615
+ for (const col of t.columns) {
616
+ const sc = col.collation === undefined ? undefined : byName.get(`${col.collation.schema}\u0000${col.collation.name}`);
617
+ if (sc !== undefined && sc.provider === "c" && sc.collate !== null && isLibcCollation("c", sc.collate) && !(coll !== undefined && isLibcCollation(coll.provider, coll.collate) && sc.collate === coll.collate))
618
+ columnLibc.set(sc.collate, sc);
619
+ }
620
+ for (const sc of columnLibc.values()) {
621
+ try {
622
+ const r = await store.libcOrder(sc.collate, sc.version ?? null, () => readNamed(sc));
623
+ libcOrders.set(sc.collate, r.order);
624
+ if (r.read)
625
+ logLine(`[engine] read how ${source.database} orders text under the collation ${sc.name} (${sc.collate}): kept with the synced data`);
626
+ const note = libcOrderNote(r.order);
627
+ if (note !== null)
628
+ logLine(`[engine] ! ${note}`);
629
+ }
630
+ catch (e) {
631
+ say(` ! the order of the collation ${sc.name} (${sc.collate}) a synced column has could not be read: ${e.message}; its sorts are refused until it is`);
632
+ }
633
+ }
545
634
  begin("Starting the Postgres listener");
546
635
  // ONE STATEMENT'S LIMIT (server.ts armTimeout): a runaway query must not hold DuckDB for ever; a session's
547
636
  // statement_timeout may only shorten it. Postgres's units (5min, 90s, 300000); 0 for none
@@ -586,7 +675,10 @@ async function run(o, hosted) {
586
675
  }
587
676
  let duckListener;
588
677
  try {
589
- duckListener = await store.listener(facts.collation, undefined, libc, () => sync.state(), sync.kept.enabled ? sync.kept : undefined);
678
+ duckListener = await store.listener(facts.collation, undefined, libc, () => [...sync.state(), ...sync.generated.copies()], sync.kept.enabled ? sync.kept : undefined, { ...(facts.collations !== undefined ? { collations: facts.collations } : {}), libcOrders, readLibc: async (sc) => {
679
+ logLine(`[engine] reading how ${source.database} orders text under the collation ${sc.name} (${sc.collate}), which a statement names first`);
680
+ return readNamed(sc);
681
+ } });
590
682
  }
591
683
  catch (e) {
592
684
  fail("Starting the Postgres listener");
@@ -604,11 +696,13 @@ async function run(o, hosted) {
604
696
  const users = new LiveUsers(path.join(home, USERS_FILE), cfg.apiKey);
605
697
  // THE ROLES (src/cli/roles.ts): the file read again when it or the copied tables change; a session with a role is
606
698
  // held to it at every statement, and the catalog is rebuilt with the roles (src/api/postgres/server.ts)
607
- const liveRoles = new LiveRoles(folder, project.root, () => sources.tables);
699
+ const liveRoles = new LiveRoles(folder, project.root, () => [...sources.tables, ...sync.generated.named()]);
608
700
  let api;
609
701
  try {
610
702
  api = await startPostgresApi({
611
703
  port: pgAddress.port, host: pgAddress.host, tls: pgTls.tls, database, masonVersion: o.version, product: "mason-data", statementTimeoutMs,
704
+ // behind a TCP proxy (MASON_PG_PROXY_FROM, src/api/postgres/proxy.ts): its PROXY header read from its addresses alone
705
+ ...(listen.proxyFrom !== undefined ? { proxyFrom: listen.proxyFrom } : {}),
612
706
  ...listenerLogins(users, cfg.apiKey),
613
707
  roles: () => liveRoles.now(),
614
708
  schema: () => sync.schema(),
@@ -629,7 +723,7 @@ async function run(o, hosted) {
629
723
  await store.close();
630
724
  return 1;
631
725
  }
632
- done(`Postgres listener on ${listenerText(pgTls.listener, api.port)}; ${statementTimeoutMs === 0 ? "statements without a time limit" : `each statement at most ${timeoutSetting.trim()}`} (MASON_STATEMENT_TIMEOUT); ` +
726
+ done(`Postgres listener on ${listenerText(pgTls.listener, api.port)}; ${listen.proxyFrom !== undefined ? `a PROXY protocol v2 header required from ${listen.proxyFrom.entries.join(", ")} (MASON_PG_PROXY_FROM); ` : ""}${statementTimeoutMs === 0 ? "statements without a time limit" : `each statement at most ${timeoutSetting.trim()}`} (MASON_STATEMENT_TIMEOUT); ` +
633
727
  `every request recorded for ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_HISTORY_DAYS; mason logs --requests)`);
634
728
  {
635
729
  // a user whose role the file no longer names reads nothing until it is added again (fail closed): said here, by name
@@ -656,6 +750,16 @@ async function run(o, hosted) {
656
750
  }
657
751
  }
658
752
  // WHAT mason share OPENS (src/cli/share-run.ts): a second socket of this listener, on a run on a laptop alone
753
+ // a tunnel's ssh config a run or a mason share killed with -9 left behind (src/cli/tunnel.ts): removed unless its
754
+ // process lives, so a run stopped with the page's share open leaves nothing at the next start
755
+ if (o.serve === undefined) {
756
+ try {
757
+ sweepTunnelFiles(home, alive);
758
+ }
759
+ catch (e) {
760
+ logLine(`share: the tunnels' files left behind could not be swept: ${e.message}`);
761
+ }
762
+ }
659
763
  const shares = o.serve !== undefined ? undefined : new RunShares({
660
764
  listener: api, database, log: logLine,
661
765
  // the project's own certificate, as --self-signed-tls serves it: made once, and recorded as that flag records it
@@ -676,22 +780,22 @@ async function run(o, hosted) {
676
780
  }
677
781
  },
678
782
  });
679
- const setupPort = await freePort(o.port, chosen);
783
+ const setupPort = o.exactPort === true ? o.port : await freePort(o.port, chosen);
680
784
  const token = newToken();
681
785
  const runCtx = {
682
- source, facts: () => facts, warnings,
786
+ source, facts: () => facts, warnings: [...warnings, ...(memoryWarned === null ? [] : [memoryProblemText(memoryWarned)])], textOrder,
683
787
  // the slot and publication while a table's sync is replication (the run streams), as the sync says it now
684
788
  replication: () => sync.streams() ? repl : null, port: setupPort, token, folder: () => folder, root: project.root, home,
685
789
  version: o.version, startedAt,
686
- // the web page (mason ui) is mason start's alone: mason serve's run serves none (src/cli/run-server.ts RunContext.page)
687
- page: o.serve === undefined,
790
+ // the web page (mason ui): on loopback alone, so mason serve's is reached through mason ui --ssh
791
+ page: true, served: o.serve !== undefined,
688
792
  demo: cfg.demo === true,
689
793
  users: { file: path.join(home, USERS_FILE), apiKey: cfg.apiKey, database, postgresUrl },
690
794
  // the roles as the listener holds them: their row filters checked by its catalog (a filter Postgres refuses is a problem)
691
795
  roles: () => api.roles(),
692
- ...(shares !== undefined ? { shares } : {}),
796
+ ...(shares !== undefined ? { shares, pageShares: new PageShares({ shares, home, log: logLine }) } : {}),
693
797
  // the sources file is the list of what is added: a change to it is read by the sync at once
694
- sources: () => sources, sourcesChanged: () => { added(); }, readSource: readSources, runUrl: src.resolved,
798
+ sources: () => sources, sourcesChanged: () => { void added(); }, readSource: readSources, runUrl: src.resolved,
695
799
  // a table set to sync: replication while the run is up, in a run that never checked the database for it: the
696
800
  // start's own checks, now, and the edit refused when they fail (src/cli/run-server.ts /api/pick)
697
801
  checkReplication: async (file) => {
@@ -717,32 +821,42 @@ async function run(o, hosted) {
717
821
  // the statements running now, and a cancel of one: the listener's own (a CancelRequest's path)
718
822
  listener: api,
719
823
  duck: { copies: () => sync.state(), file: duckFile, poll: (sources) => sync.pollNow(sources), stream: (o) => sync.streamStatus(o), measured: (table) => sync.measured(table), disk: () => sync.diskStatus(), requests, events, usage,
720
- restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status() },
824
+ restarts: () => readRestarts(home), jsonPaths: () => sync.kept.status(), generated: () => sync.generated.status() },
721
825
  };
722
826
  const server = createRunServer(runCtx);
723
827
  // a sample is /api/state's facts at its moment (src/cli/run-server.ts usageFacts): one computation of each
724
828
  usage.start(() => usageFacts(runCtx));
725
- await new Promise((resolve) => server.listen(setupPort, "127.0.0.1", resolve));
829
+ await listenOnLoopback(server, setupPort);
726
830
  const refreshFacts = setInterval(() => {
727
831
  void (async () => {
728
832
  const c = await connectSource(source);
729
833
  if ("failure" in c)
730
834
  return; // the commands keep the last catalog; the sync says loudly if the source is away
835
+ const before = facts.collations;
731
836
  try {
732
- facts = await readSourceFacts(c.pg);
837
+ facts = await readSourceFacts(c.pg, undefined, { collations: false });
733
838
  }
734
839
  finally {
735
840
  c.pg.close();
736
841
  }
842
+ if (before !== undefined)
843
+ facts.collations = before;
737
844
  })().catch((e) => logLine(`catalog refresh failed: ${e.message}`));
738
845
  }, 10_000);
739
- added();
846
+ const tablesSet = added();
740
847
  cfg.running = { pid: process.pid, controlUrl: `http://127.0.0.1:${setupPort}`, token, postgresUrl, postgresListener: pgTls.listener };
741
848
  cfg.lastRun = new Date().toISOString();
742
849
  writeConfig(home, cfg);
743
850
  done(`Mason is up: ${sync.state().length} table(s) added, kept current: ${syncMode === "poll" ? `as each table's sync: says${syncCounts(sources.tables)}` +
744
851
  `${sync.streams() ? `; replicated tables streamed as they change (logical replication: slot ${repl.slot}, publication ${repl.publication}${repl.owned ? "" : ", read as it is"})` : ""}`
745
852
  : `each table read again whole every ${PASS_RETRY_MS / 1000} s (MASON_DUCK_SYNC=recopy)`}`);
853
+ // THE GENERATED TABLES (src/duck/generated.ts): the folder read and each file held to the catalog, what is refused said
854
+ // here too; a refusal stops nothing, and their SQL is checked again as the tables they read are copied
855
+ await tablesSet;
856
+ await sync.generated.start(() => readGenerated(folder, project.root), api.planner());
857
+ for (const g of sync.generated.status())
858
+ for (const r of g.refusals)
859
+ say(textEntry(" ! ", r.text).trimEnd());
746
860
  // THE FILE'S DEFAULT, SAID ONCE (issue 33: nothing Mason does to your database is a hidden default)
747
861
  const defaultLine = syncDefaultLine(sources.file, shownPath(project.root, sources.file.file), syncMode === "poll" ? copiedBefore : 0);
748
862
  say(` ${defaultLine}`);
@@ -792,6 +906,13 @@ async function run(o, hosted) {
792
906
  catch (e) {
793
907
  say(` closing the share: ${e.message}`);
794
908
  }
909
+ // before the listener: a generated table's review plans on its catalog, so it ends first
910
+ try {
911
+ await sync.generated.stop();
912
+ }
913
+ catch (e) {
914
+ say(` stopping the generated tables: ${e.message}`);
915
+ }
795
916
  try {
796
917
  await api.close();
797
918
  }
@@ -6,15 +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
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";
18
21
  const out = (m) => { process.stdout.write(`${m}\n`); };
19
22
  /**
20
23
  * THE NODE THE PACKAGE NEEDS, major.minor: cli/package.json's `engines`, this check, `mason --help` and the README read
@@ -26,19 +29,31 @@ const out = (m) => { process.stdout.write(`${m}\n`); };
26
29
  export const NODE_MIN = "22.13";
27
30
  export const DOCTOR_USAGE = `usage: mason doctor [--json]
28
31
 
29
- Says whether this machine is ready for Mason — Node ${NODE_MIN} or later, memory (the engine uses up to half of it by
30
- default; MASON_DUCKDB_MEMORY=4GiB sets it), Mason's postgres and inet extensions (installed with Mason for this
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
31
34
  platform, so nothing is downloaded; else the first sync downloads them), the ports Mason listens on — with the way
32
- out for anything missing. In a
33
- project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
34
- database, whether every added table is synced, the statements of the last day it refused or answered slowly.
35
+ out for anything missing.
35
36
 
36
37
  mason doctor
37
38
 
38
- --json as data: { ready, verdict, checks: [{ name, ok, detail }], next: [{ command, what }] }; ok is true, false,
39
- or null when it could not tell
39
+ --json as data: its shape is at the end
40
40
 
41
- Exit status: 0 everything checked is fine; 1 something is not (said, with the way out).
41
+ In a project's folder it checks the project's database too; with Mason running for it, the run in a few lines instead: its
42
+ database, whether every added table is synced, the statements of the last day it refused or answered slowly. Both
43
+ say whether the user Mason connects as could change a synced table's rows (a superuser, BYPASSRLS, an owner of a
44
+ synced table, a write privilege on one or CREATE on its schema; or a write that reaches its rows through a
45
+ partition, an inheritance child or parent, a foreign key's action, a view or a rule; SECURITY DEFINER code it
46
+ reaches; a foreign server; ADMIN OPTION or a server role; directly or through a role it is a member of), with the
47
+ statements that make a role that only reads.
48
+
49
+ Exit status: 0 nothing failed — a warning (!) and a check that could not tell (?) are said, and leave it 0; 1
50
+ something failed (said, with the way out).
51
+
52
+ The --json answer: { ready, verdict, checks: [{ name, ok, detail, warn, code, fix }], next: [{ command, what }] }; ok
53
+ is true, false, or null when it could not tell; warn (only when true): fine to run, but said loudly; code
54
+ (the role's lines): ${ROLE_CODES.slice(0, 5).join(", ")},
55
+ ${ROLE_CODES.slice(5).join(", ")};
56
+ fix (with code): the statements and the edit that make a role that only reads
42
57
  `;
43
58
  export const DOCTOR = {
44
59
  name: "doctor",
@@ -48,7 +63,7 @@ export const DOCTOR = {
48
63
  json: ["ready", "verdict", "checks", "next"],
49
64
  };
50
65
  /** The ports a mason start takes by default, and what listens on each. */
51
- export const PORTS = [[4039, "the mason commands"], [DEFAULT_PG_PORT, "the Postgres API"]];
66
+ export const PORTS = [[DEFAULT_UI_PORT, "the mason commands"], [DEFAULT_PG_PORT, "the Postgres API"]];
52
67
  /** Node's version against the package's floor, major then minor. */
53
68
  export function nodeCheck(version = process.versions.node) {
54
69
  const [major, minor] = version.split(".").map(Number);
@@ -57,19 +72,30 @@ export function nodeCheck(version = process.versions.node) {
57
72
  ? { name: "node", ok: true, detail: `Node ${version}` }
58
73
  : { name: "node", ok: false, detail: `Node ${version} is too old: mason needs ${NODE_MIN} or later (nodejs.org, or nvm install ${minMajor})` };
59
74
  }
60
- /** The machine's memory and what the copy's engine may use of it (src/duck/store.ts DEFAULT_MEMORY_LIMIT, MASON_DUCKDB_MEMORY). */
61
- export function memoryCheck(totalBytes = machineMemoryBytes(), env = process.env) {
62
- const gib = (totalBytes / 1024 ** 3).toFixed(1);
63
- const set = env.MASON_DUCKDB_MEMORY;
64
- if (set !== undefined && !MEMORY_LIMIT_RE.test(set)) {
65
- 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)` };
75
+ /**
76
+ * The machine's memory and what the copy's engine may use of it (src/duck/store.ts DEFAULT_MEMORY_LIMIT,
77
+ * MASON_DUCKDB_MEMORY): what a start refuses is said in the start's own words (`memoryProblems`), and what it warns of
78
+ * under the floor in the run's own words (`memoryWarning`), a warning, never a failure, since the start goes on; so is
79
+ * a share the operator set that leaves less than OUTSIDE_RESERVE_BYTES beside it.
80
+ */
81
+ export function memoryCheck(memory = machineMemory(), env = process.env) {
82
+ const problems = memoryProblems(env);
83
+ if (problems.length)
84
+ return { name: "memory", ok: false, detail: problems.map(memoryProblemText).join("; ") };
85
+ const warned = memoryWarning(memory, env);
86
+ const set = env.MASON_DUCKDB_MEMORY || undefined;
87
+ const share = set ?? defaultMemoryLimit(memory.bytes);
88
+ const outside = memory.bytes - memoryLimitBytes(share);
89
+ if (warned !== null) {
90
+ return { name: "memory", ok: true, warn: true,
91
+ detail: `${memoryProblemText(warned)} (the engine uses up to ${share.trim()}, the default, leaving ${memorySize(Math.max(0, outside))} beside it)` };
66
92
  }
67
- const share = set ?? defaultMemoryLimit(totalBytes);
68
- const low = totalBytes < 2 * 1024 ** 3;
69
93
  return {
70
- name: "memory", ok: !low,
71
- detail: `${gib} GiB; the engine uses up to ${share.trim()}${set !== undefined ? " (MASON_DUCKDB_MEMORY)" : " (half, the default; MASON_DUCKDB_MEMORY sets it)"}` +
72
- (low ? ". Under 2 GiB, a large table's first sync and a query starve each other: expect statements refused for memory" : ""),
94
+ name: "memory", ok: true, ...(outside < OUTSIDE_RESERVE_BYTES ? { warn: true } : {}),
95
+ detail: `${memorySize(memory.bytes)}; the engine uses up to ${share.trim()}${set !== undefined ? " (MASON_DUCKDB_MEMORY)" : " (the default; MASON_DUCKDB_MEMORY sets it)"}, ` +
96
+ `leaving ${memorySize(Math.max(0, outside))} for Mason beside it` +
97
+ (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, ` +
98
+ "a generated table's build or a burst of writes (a smaller MASON_DUCKDB_MEMORY, or more memory)" : ""),
73
99
  };
74
100
  }
75
101
  /** The copy's extensions (src/duck/extensions.ts): shipped with Mason and loaded by an engine of their own as the run's
@@ -119,25 +145,72 @@ function portOf(url) {
119
145
  return null;
120
146
  } // not a URL: no port to compare with
121
147
  }
122
- /** The database a URL names: reachable, its version, what this user can read. */
123
- export async function databaseCheck(url, o = {}) {
124
- const parsed = parseSource(url);
148
+ /**
149
+ * The database a URL names: reachable, its version, what this user can read — and, given `role` (the project's synced
150
+ * tables), whether that user could write to it (src/cli/source-role.ts), on the same connection. `name` says where the
151
+ * URL is (`DATABASE_URL in .env`): a refusal names it, never the value (src/cli/preflight.ts shownUrl).
152
+ */
153
+ export async function databaseChecks(url, o = {}) {
154
+ const parsed = parseSource(url, o.name);
125
155
  if ("problem" in parsed)
126
- return { name: "database", ok: false, detail: parsed.problem };
156
+ return [{ name: "database", ok: false, detail: parsed.problem }];
127
157
  const connected = await connectSource(parsed.source, o);
128
158
  if ("failure" in connected)
129
- return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` };
159
+ return [{ name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${connected.failure.message}${connected.failure.hints.map((h) => `\n - ${h}`).join("")}` }];
160
+ try {
161
+ const facts = await readSourceFacts(connected.pg);
162
+ const tooOld = versionProblem(facts);
163
+ if (tooOld)
164
+ return [{ name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` }];
165
+ 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` };
166
+ return o.role ? [database, ...await askRole(connected.pg, o.role)] : [database];
167
+ }
168
+ finally {
169
+ connected.pg.close();
170
+ }
171
+ }
172
+ /** The role's lines, from its one catalog read; a read that failed is "could not tell", never "only reads". */
173
+ async function askRole(pg, ask) {
130
174
  let facts;
131
175
  try {
132
- facts = await readSourceFacts(connected.pg);
176
+ facts = await readRoleFacts(pg, ask.tables);
177
+ }
178
+ catch (e) {
179
+ return [roleUnknown(e.message)];
180
+ }
181
+ return roleChecks(facts, ask);
182
+ }
183
+ /** What the role check asks of a project's sources file: its tables, whether one streams, its publication, its URL. */
184
+ export function roleAsk(src, root, env = process.env) {
185
+ return {
186
+ tables: src.tables.map((e) => { const at = e.table.indexOf("."); return { schema: e.table.slice(0, at), table: e.table.slice(at + 1) }; }),
187
+ replicated: replicatedEntries(src).map((e) => e.table),
188
+ publication: namedPublication(src, env)?.name ?? null,
189
+ urlVariable: "env" in src.url ? src.url.env : null, urlFrom: src.from, file: shownPath(root, src.file), url: src.resolved,
190
+ };
191
+ }
192
+ /**
193
+ * The role's lines with Mason running (its own checks never connect to the database): the project's sources file read
194
+ * and the database asked, on a connection of its own. What cannot be read is "could not tell".
195
+ */
196
+ export async function runRoleChecks(project) {
197
+ if (project.config.demo === true)
198
+ return []; // the demo database is Mason's own, inside the run
199
+ const src = projectSource(project);
200
+ if ("message" in src)
201
+ return [roleUnknown(`${src.message} (mason status)`)];
202
+ const parsed = parseSource(src.resolved);
203
+ if ("problem" in parsed)
204
+ return [roleUnknown(parsed.problem)];
205
+ const connected = await connectSource(parsed.source);
206
+ if ("failure" in connected)
207
+ return [roleUnknown(`${maskedUrl(parsed.source.url)}: ${connected.failure.message}`)];
208
+ try {
209
+ return await askRole(connected.pg, roleAsk(src, project.root));
133
210
  }
134
211
  finally {
135
212
  connected.pg.close();
136
213
  }
137
- const tooOld = versionProblem(facts);
138
- if (tooOld)
139
- return { name: "database", ok: false, detail: `${maskedUrl(parsed.source.url)}: ${tooOld}` };
140
- 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` };
141
214
  }
142
215
  /** The run's health, from its own state: every table copied, failing, or still at work; and what it refused or answered slowly. */
143
216
  export async function runChecks(run) {
@@ -185,7 +258,7 @@ export function nextSteps(checks, run, project) {
185
258
  if (project === null)
186
259
  return [
187
260
  ["mason init", "in your app's folder: the database from MASON_DATABASE_URL or DATABASE_URL in .env.local, .env or the shell"],
188
- ["mason init postgres://user:password@localhost:5432/your_app", "or name it"],
261
+ ["mason init - < db-url.txt", "or its URL on stdin"],
189
262
  ];
190
263
  if (run === null)
191
264
  return [["mason start", "Mason in the background, for this folder"]];
@@ -214,7 +287,7 @@ export function verdictOf(checks, run, project) {
214
287
  }
215
288
  /** A check's mark: ok, !! a failure, ? could not tell. */
216
289
  function markOf(c, s) {
217
- return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : s.green("ok ");
290
+ return c.ok === false ? s.red("!! ") : c.ok === null ? s.yellow("? ") : c.warn === true ? s.yellow("! ") : s.green("ok ");
218
291
  }
219
292
  export async function doctor(argv, deps = {}) {
220
293
  const f = parseCommand(DOCTOR, argv);
@@ -236,8 +309,13 @@ export async function doctor(argv, deps = {}) {
236
309
  if (project) {
237
310
  // the database the project's sources file names (src/cli/sources.ts): a file that cannot be read is the failing line
238
311
  const src = projectSource(project);
239
- 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() }
240
- : await databaseCheck(src.resolved, { demo: project.config.demo === true }));
312
+ if ("message" in src)
313
+ checks.push({ name: "database", ok: false, detail: `${src.message}${src.why ? `: ${src.why.replace(/\n/g, "; ")}` : ""}. ${src.fix?.split("\n")[0] ?? ""}`.trim() });
314
+ // the demo database is Mason's own, inside the run: whom it connects as is not the person's to change
315
+ else if (project.config.demo === true)
316
+ checks.push(...await databaseChecks(src.resolved, { demo: true, name: src.from }));
317
+ else
318
+ checks.push(...await databaseChecks(src.resolved, { role: roleAsk(src, project.root), name: src.from }));
241
319
  }
242
320
  // a project here whose config cannot be read: init would refuse, so "Ready: mason init" would send a person nowhere
243
321
  if ("code" in which && which.code === "bad_config")
@@ -245,6 +323,8 @@ export async function doctor(argv, deps = {}) {
245
323
  }
246
324
  else {
247
325
  checks.push(...await runChecks(run));
326
+ if (project)
327
+ checks.push(...await runRoleChecks(project));
248
328
  }
249
329
  const next = nextSteps(checks, run, project);
250
330
  const { ready, verdict } = verdictOf(checks, run, project);
@@ -262,6 +342,12 @@ export async function doctor(argv, deps = {}) {
262
342
  const mark = markOf(c, s);
263
343
  out(` ${mark} ${c.name.padEnd(w)} ${wrap(c.detail, s.width, 2 + 3 + 1 + w + 2)}`);
264
344
  }
345
+ // a way out several lines share, once, under them: SQL as what you type, the rest as its words
346
+ for (const fix of new Set(checks.map((c) => c.fix).filter((x) => x !== undefined))) {
347
+ out("");
348
+ for (const line of fix.split("\n"))
349
+ out(` ${/;(\s+--.*)?$/.test(line) ? s.cyan(line) : line.startsWith("--") ? s.dim(line) : wrap(line, s.width, 4)}`);
350
+ }
265
351
  if (run !== null)
266
352
  out(s.dim(` its synced data and its state in ${run.home}. mason status for the details`));
267
353
  if (run === null || !ready)