@mason-data/cli 0.7.0 → 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 (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  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 +1173 -207
  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/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -0,0 +1,79 @@
1
+ /** The content type of the text exposition format, version 0.0.4. */
2
+ export const METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
3
+ /**
4
+ * HOW MANY SAMPLES OLD A NUMBER READ FROM THE DATABASE (the stream's position, the slot) or THE SAMPLER (p50 / p95) may
5
+ * be and still be said: the sampler refreshes each once a minute (src/duck/usage.ts SAMPLE_SECONDS), and a scrape never
6
+ * reads them itself (src/cli/run-server.ts `metricsFacts`), so one older than two samples means the sampler could not
7
+ * take them — said as unknown (absent), never as a stale number that looks current.
8
+ */
9
+ export const FRESH_SAMPLES = 2;
10
+ /** a label value escaped as the format says: backslash, double quote and line feed */
11
+ const labelValue = (v) => v.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
12
+ /** a sample's value: an integer as written, a fraction as JavaScript prints it (Go's ParseFloat reads both) */
13
+ const valueText = (n) => (Number.isInteger(n) ? n.toFixed(0) : String(n));
14
+ /** a sample that must be a count: a whole number, at least 0 */
15
+ const isCount = (s, kind) => kind === "counter" || s.suffix === "_count";
16
+ /** One metric's lines, its HELP and TYPE first — or none, the WHOLE family absent, when a sample it needs is unknown
17
+ * or any value is not finite (or a count not a whole number). */
18
+ function metric(name, kind, help, samples) {
19
+ const whole = samples.every((s) => s.value === null ? s.optional === true
20
+ : Number.isFinite(s.value) && (!isCount(s, kind) || (Number.isInteger(s.value) && s.value >= 0)));
21
+ const kept = samples.filter((s) => s.value !== null);
22
+ if (!whole || kept.length === 0)
23
+ return [];
24
+ const lines = [`# HELP ${name} ${help.replaceAll("\\", "\\\\").replaceAll("\n", "\\n")}`, `# TYPE ${name} ${kind}`];
25
+ for (const s of kept) {
26
+ const labels = s.labels ? Object.entries(s.labels).map(([k, v]) => `${k}="${labelValue(v ?? "")}"`).join(",") : "";
27
+ lines.push(`${name}${s.suffix ?? ""}${labels ? `{${labels}}` : ""} ${valueText(s.value)}`);
28
+ }
29
+ return lines;
30
+ }
31
+ /** The PHASES `mason_tables` says, in order: the sync's (src/duck/sync.ts `CopyState.phase`), as `/healthz` counts them. */
32
+ export const TABLE_PHASES = ["copied", "copying", "waiting", "failed"];
33
+ /** The OUTCOMES `mason_statements_total` says: the request log's (`outcomeOf`), `ok` said as `completed`. */
34
+ export const STATEMENT_OUTCOMES = ["completed", "failed", "refused", "cancelled"];
35
+ /**
36
+ * THE BODY OF `GET /metrics`. Starting or stopping: `mason_ready 0` and `mason_info` alone (a scrape must not fail
37
+ * while Mason starts; whether it is up is `/healthz`'s to say). Serving: `mason_ready 1` and the run's numbers.
38
+ */
39
+ export function metricsText(f) {
40
+ const lines = [
41
+ ...metric("mason_ready", "gauge", "1 once Mason serves the synced data; 0 while it starts or stops (/healthz answers 503 then).", [{ value: f.run === null ? 0 : 1 }]),
42
+ ...metric("mason_info", "gauge", "Mason's version, as a label; the value is always 1.", [{ labels: { version: f.version }, value: 1 }]),
43
+ ];
44
+ const r = f.run;
45
+ if (r !== null) {
46
+ const u = r.usage, st = r.statements;
47
+ // a number read from the database (the stream's position, the slot) as of `readAt`: said only while fresh
48
+ const fresh = (value, readAt) => readAt !== null && isFresh(r, readAt) ? value : null;
49
+ lines.push(...metric("mason_uptime_seconds", "gauge", "Seconds since mason serve started.", [{ value: r.uptimeSeconds }]), ...metric("mason_tables", "gauge", "The synced tables the run holds, by phase (a removed table's data is held until dropped).", TABLE_PHASES.map((phase) => ({ labels: { phase }, value: u.tables[phase] }))), ...metric("mason_copy_bytes", "gauge", "The size of the synced data's file on disk, bytes.", [{ value: u.copyBytes }]), ...metric("mason_copy_rows", "gauge", "The rows of the synced tables served.", [{ value: u.totals.rows }]),
50
+ // absent, not NaN, before any added table was synced: nothing has an age yet (see the module's comment)
51
+ ...metric("mason_sync_oldest_age_seconds", "gauge", "Seconds since the stalest added table was last brought current; absent before any sync.", [{ value: u.oldestSyncSeconds }]),
52
+ // absent where no table is replicated (the run polls): a 0 would claim a stream that is not there. The database's
53
+ // position under the bytes, and the slot, are as the sampler last read them (a scrape never asks the database):
54
+ // absent when that is more than FRESH_SAMPLES samples old. The seconds are the run's own clock against the last
55
+ // batch's commit time, read from nothing: current at every scrape
56
+ ...metric("mason_stream_lag_bytes", "gauge", "Replication: WAL bytes between the database's position (as last sampled, at most two samples old) and what Mason has applied; absent when nothing is replicated or not known.", [{ value: fresh(u.streamLag?.bytes ?? null, u.streamLagReadAt) }]), ...metric("mason_stream_lag_seconds", "gauge", "Replication: seconds since the newest change applied was committed on the database; absent when nothing is replicated.", [{ value: u.streamLag?.seconds ?? null }]), ...metric("mason_slot_wal_held_bytes", "gauge", "Replication: WAL bytes the replication slot holds on the database (as last sampled, at most two samples old); absent when nothing is replicated or not known.", [{ value: fresh(u.slotWalHeldBytes, u.slotWalReadAt) }]), ...metric("mason_disk_free_bytes", "gauge", "Bytes free on the disk the synced data is on.", [{ value: u.diskFreeBytes }]), ...metric("mason_sessions_open", "gauge", "Sessions logged in to Mason's Postgres API now.", [{ value: u.sessionsOpen }]), ...metric("mason_statements_total", "counter", "Statements the listener answered since the process started, whether or not the request log kept each, by outcome (refused: the statement's fault; failed: Mason's or the machine's).", STATEMENT_OUTCOMES.map((outcome) => ({ labels: { outcome }, value: outcome === "completed" ? st.completed : st[outcome] }))), ...durationSummary(r));
57
+ }
58
+ return `${lines.join("\n")}\n`;
59
+ }
60
+ /** a time `readAt` (ISO) at most `freshSeconds` before the facts were read */
61
+ const isFresh = (r, readAt) => (r.at - Date.parse(readAt)) / 1000 <= r.freshSeconds;
62
+ /**
63
+ * `mason_statement_duration_seconds`, a summary: the quantiles are the last usage sample's (its minute's completed
64
+ * statements, as `mason usage` says them: src/duck/usage.ts `lastDurations`; past DURATIONS_KEPT in a minute, of a
65
+ * uniform sample of them), absent before the first sample, for a minute none completed, and when the last sample is
66
+ * more than FRESH_SAMPLES samples old (a sample that could not be taken keeps the one before); `_sum` and `_count` are
67
+ * the completed statements since the process started (the counter), as a Prometheus client's summary is: quantiles
68
+ * over a recent window, sum and count cumulative.
69
+ */
70
+ function durationSummary(r) {
71
+ const d = r.durations !== null && isFresh(r, r.durations.at) ? r.durations : null;
72
+ const seconds = (ms) => (ms === null || ms === undefined ? null : ms / 1000);
73
+ return metric("mason_statement_duration_seconds", "summary", "Completed statements' duration, seconds: p50 and p95 of the last minute's usage sample (sampled: a busy minute's of a uniform sample); sum and count since the process started.", [
74
+ { labels: { quantile: "0.5" }, value: seconds(d?.p50Ms), optional: true },
75
+ { labels: { quantile: "0.95" }, value: seconds(d?.p95Ms), optional: true },
76
+ { suffix: "_sum", value: r.statements.completedMs / 1000 },
77
+ { suffix: "_count", value: r.statements.completed },
78
+ ]);
79
+ }
@@ -42,22 +42,30 @@ export function runListenerSettings(shell, o) {
42
42
  ...(s.fix ? { fix: s.kind === "plain" ? `${s.fix}. Or mason start --self-signed-tls: a certificate made for this project in .mason/` : s.fix } : {}),
43
43
  ...(s.kind === "plain" ? { next: [["mason start --self-signed-tls", "TLS from a certificate made in .mason/ (clients connect with sslmode=require)"]] } : {}) };
44
44
  }
45
+ /** The name in every self-signed certificate: a certificate is handed to whoever connects, before any login, so it
46
+ * names no project. */
47
+ const SELF_SIGNED_NAME = "mason";
45
48
  /**
46
49
  * The project's self-signed certificate: the one in `.mason/` when it is still good (it parses, its key is its own, it
47
- * has not expired), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
50
+ * has not expired, it names no project), else a new one, both files 0600. `said` is why a new one was made, for the run to print.
48
51
  */
49
- export function ensureSelfSigned(home, project, now = new Date()) {
52
+ export function ensureSelfSigned(home, now = new Date()) {
50
53
  const certFile = path.join(home, SELF_SIGNED_FILES.cert), keyFile = path.join(home, SELF_SIGNED_FILES.key);
51
54
  const kept = keptCertificate(certFile, keyFile, now);
52
55
  if ("fingerprint" in kept)
53
56
  return { fingerprint: kept.fingerprint, said: null };
54
- const made = selfSignedCertificate(`mason-${project}`, { now });
57
+ const made = selfSignedCertificate(SELF_SIGNED_NAME, { now });
55
58
  for (const [file, text] of [[keyFile, made.key], [certFile, made.cert]]) {
56
59
  fs.writeFileSync(file, text, { mode: 0o600 });
57
60
  fs.chmodSync(file, 0o600); // a file that was there keeps its mode through writeFileSync
58
61
  }
59
62
  return { fingerprint: new crypto.X509Certificate(made.cert).fingerprint256, said: kept.why };
60
63
  }
64
+ /** The project's self-signed certificate as a listener reads it: the two files of `ensureSelfSigned`, and what it said. */
65
+ export function selfSignedPair(home) {
66
+ const { fingerprint, said } = ensureSelfSigned(home);
67
+ return { cert: fs.readFileSync(path.join(home, SELF_SIGNED_FILES.cert)), key: fs.readFileSync(path.join(home, SELF_SIGNED_FILES.key)), fingerprint, said };
68
+ }
61
69
  function keptCertificate(certFile, keyFile, now) {
62
70
  if (!fs.existsSync(certFile) || !fs.existsSync(keyFile))
63
71
  return { why: `made a self-signed certificate: .mason/${SELF_SIGNED_FILES.cert}` };
@@ -65,6 +73,8 @@ function keptCertificate(certFile, keyFile, now) {
65
73
  const x = new crypto.X509Certificate(fs.readFileSync(certFile));
66
74
  if (!x.checkPrivateKey(crypto.createPrivateKey(fs.readFileSync(keyFile))))
67
75
  return { why: `made a new self-signed certificate: .mason/${SELF_SIGNED_FILES.key} was not the key of .mason/${SELF_SIGNED_FILES.cert}` };
76
+ if (x.subject !== `CN=${SELF_SIGNED_NAME}` || x.issuer !== `CN=${SELF_SIGNED_NAME}`)
77
+ return { why: "made a new self-signed certificate: the one in .mason/ named this project to whoever connected" };
68
78
  if (new Date(x.validTo).getTime() <= now.getTime())
69
79
  return { why: `made a new self-signed certificate: the one in .mason/ expired ${x.validTo}` };
70
80
  return { fingerprint: x.fingerprint256 };
@@ -40,6 +40,10 @@ export const withPlaceholders = (recorded) => recorded.replace(/^postgresql:\/\/
40
40
  export const withoutPlaceholders = (shown) => shown.replace(/^postgres:\/\/<user>:<password>@/, "postgresql://");
41
41
  /** The psql line for a URL, quoted for a shell. */
42
42
  export const psqlLine = (url) => `psql ${shellWord(url)}`;
43
+ /** WHERE A TOOL CONNECTS, as `mason status --json` and `/api/state` say it (`postgres`): the recorded URL's parts, the
44
+ * URL with the credentials' place shown (`shown`, what `mason start` and `mason status` print) and its psql line — one
45
+ * builder, so the web page's Connect panel and the commands print the same line. */
46
+ export const postgresFacts = (recorded) => ({ ...postgresLink(recorded), shown: withPlaceholders(recorded), psql: psqlLine(withPlaceholders(recorded)) });
43
47
  /** What a tool connecting to the listener should know: it listens on every address; its certificate is self-signed. */
44
48
  export function listenerTips(l) {
45
49
  if (l === null)
@@ -1,24 +1,25 @@
1
1
  /**
2
2
  * THE PROJECT'S VERSION PIN, as the mason command holds it (src/lib/project-version.ts has the rule and the image's
3
3
  * side): `mason init` writes `mason.yml` with this command's version, `mason start` writes it into a project made
4
- * before there was one, and `mason init` and `mason start` refuse a command of another release line — what the
5
- * developer runs is then what production's image is held to.
4
+ * before there was one, and `mason init`, `mason start` and a `mason serve` of the package refuse a command of another
5
+ * release line — what the developer runs is then what production is held to.
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { parseVersion, pinFileText, pinProblem, readPin, versionText, PIN_FILE } from "../lib/project-version.js";
9
+ import { parseVersion, pinFileText, projectPinProblem, PIN_FILE } from "../lib/project-version.js";
10
10
  /** Why this mason command may not run the project in `folder`, or null (a checkout's `dev` checks nothing). */
11
+ export function commandPinProblem(folder, cliVersion) {
12
+ return projectPinProblem(folder, cliVersion, { what: "this mason command",
13
+ run: (p) => `run the mason command it names: npm i -g @mason-data/cli@${p} (or npx @mason-data/cli@${p} …)` });
14
+ }
15
+ const sentence = (clause) => `${clause[0].toUpperCase()}${clause.slice(1)}.`;
16
+ /** The refusal as `mason init` and `mason start` say it: one line of sentences. */
11
17
  export function cliPinProblem(folder, cliVersion) {
12
- const running = parseVersion(cliVersion);
13
- if (running === null)
14
- return null;
15
- const read = readPin(folder);
16
- if ("problem" in read)
17
- return read.problem;
18
- if (read.pin === null)
18
+ const r = commandPinProblem(folder, cliVersion);
19
+ if (r === null)
19
20
  return null;
20
- const p = versionText(read.pin);
21
- return pinProblem(read.pin, running, read.file, "this mason command", `Run the mason command it names: npm i -g @mason-data/cli@${p} (or npx @mason-data/cli@${p} …).`);
21
+ const [run, ...others] = r.ways;
22
+ return run === undefined ? r.problem : [`${r.problem}.`, sentence(run), ...others.map((w) => sentence(`or ${w}`))].join(" ");
22
23
  }
23
24
  /**
24
25
  * Write `folder`'s mason.yml with this command's version when it has none; the file written, or null when there was one
@@ -149,7 +149,7 @@ export const VIEWS_SQL = `SET LOCAL search_path = pg_catalog; SELECT coalesce(js
149
149
  ) v`;
150
150
  /** One catalog read: every ordinary or partitioned table outside the system schemas, its key, size, comments and FKs. */
151
151
  export const TABLES_SQL = `SELECT json_agg(t ORDER BY t.schema, t.table)::text FROM (
152
- SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid,
152
+ SELECT n.nspname AS schema, c.relname AS table, c.oid, c.reltype AS row_type_oid, c.relreplident AS replica_identity,
153
153
  -- a PARTITIONED table's rows and bytes are its leaves' (its own are -1 / 0 and ~0 bytes); "never analysed" is
154
154
  -- relpages = 0 AND reltuples <= 0 on any leaf, which Postgres 13 (reltuples 0) and 14+ (-1) both read
155
155
  CASE WHEN c.relkind = 'p'
@@ -221,6 +221,7 @@ export async function readSourceFacts(pg, counted = COUNTED) {
221
221
  foreignKeys: t.foreign_keys.map((f) => ({ column: f.column, target: f.target, targetColumn: f.target_column })),
222
222
  uniqueKeys: t.unique_keys.map((u) => ({ name: u.name, columns: u.columns, constraint: u.constraint, nullsNotDistinct: u.nulls_not_distinct })),
223
223
  ...(t.row_type_oid > 0 ? { rowTypeOid: t.row_type_oid } : {}),
224
+ ...(t.replica_identity === "d" || t.replica_identity === "n" || t.replica_identity === "f" || t.replica_identity === "i" ? { replicaIdentity: t.replica_identity } : {}),
224
225
  });
225
226
  }
226
227
  // the columns differ by version (datlocprovider from 15, daticulocale 15–16, datlocale from 17): read the row whole
@@ -261,7 +262,7 @@ export function serverlessHost(host) {
261
262
  * on a database elsewhere (the tables worth copying are the big ones). */
262
263
  export function unpickableReason(t) {
263
264
  if (t.key.length === 0)
264
- return "no primary key: Mason keeps one row per key, so it can only copy a table that has one";
265
+ return "no primary key: Mason keeps one row per key, so it can only sync a table that has one";
265
266
  return null;
266
267
  }
267
268
  /** The warnings `mason start` prints, and `mason status` shows, before anything is copied. */
@@ -280,8 +281,8 @@ export function sourceWarnings(s, f) {
280
281
  /** The warning a database on another machine gets at every start: what the copy is and how it is kept current (true of
281
282
  * both modes — the mode is the start's, not known here: a poll scans each table, replication reads the change log). */
282
283
  export function remoteWarning(s) {
283
- return `${s.host} is not this machine. mason start will copy the tables you pick to this machine (into its copy's file, ` +
284
- "with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log with --replication";
284
+ return `${s.host} is not this machine. mason start will sync the tables you pick to this machine (into its synced data's file, ` +
285
+ "with no access control beyond this machine's), and keep them current while it runs: a scan of each table at every poll, or its change log for a table whose sync: is replication";
285
286
  }
286
287
  /**
287
288
  * THE CONFIRMATION A DATABASE ON ANOTHER MACHINE NEEDS (the refusal read as a failed connection and then a fix: only a
@@ -291,9 +292,9 @@ export function remoteWarning(s) {
291
292
  */
292
293
  export function remoteConfirmation(host, o) {
293
294
  return { code: "failed", exit: 1,
294
- message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and copying from it needs your say-so`,
295
- why: "mason start would copy the tables you pick to this machine: a file under .mason/, with no access control beyond this machine's\n" +
296
- "and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log with --replication",
295
+ message: `stopped before ${o.cmd === "mason init" ? "writing anything" : "starting"}: ${host} is on another machine, and syncing from it needs your say-so`,
296
+ why: "mason start would sync the tables you pick to this machine: a file under .mason/, with no access control beyond this machine's\n" +
297
+ "and keep them current while it runs by reading that database: a scan of each table at every poll, or its change log for a table whose sync: is replication",
297
298
  fix: o.wayOut, next: o.next };
298
299
  }
299
300
  /** `pg_current_snapshot()` and xid8, which polling reads, arrived in Postgres 13. */
@@ -18,11 +18,13 @@
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { randomBytes } from "node:crypto";
21
- import { readSource } from "./sources.js";
21
+ import { connectDatabase, databaseSettingRefusal } from "../lib/database-name.js";
22
+ import { changesRetired, pollMsRetired } from "../lib/table-sync.js";
23
+ import { readSource, sourcesTop, writtenDatabase } from "./sources.js";
22
24
  export const STATE_DIR = ".mason";
23
25
  const CONFIG = "config.json";
24
- /** A project's name: the database name a tool connects to on the Postgres API, and (with the source's) what its
25
- * replication slot is named for. */
26
+ /** A project's name: what its commands call it and (with the source's) what its replication slot is named for. Never
27
+ * the database name a tool connects with: src/lib/database-name.ts. */
26
28
  export const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,47}$/;
27
29
  /** The copy of the data, a DuckDB file in the project's `.mason/`: kept by mason stop, deleted by mason destroy. */
28
30
  export const DUCK_FILE = "duck.db";
@@ -113,7 +115,7 @@ export function theProject(cwd) {
113
115
  if (p)
114
116
  return { project: p };
115
117
  return { problem: "no Mason project here or above", code: "no_project", fix: "cd into a project's folder, or make this folder one (mason init demo: a demo database, nothing to install)",
116
- next: [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"]] };
118
+ next: [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"]] };
117
119
  }
118
120
  /** Whether `root`'s .gitignore already ignores `.mason/`. */
119
121
  export function ignoresStateDir(gitignore) {
@@ -127,3 +129,40 @@ export function ignoresStateDir(gitignore) {
127
129
  export function projectSource(p, env = process.env, o = {}) {
128
130
  return readSource(p.folder, p.root, env, o);
129
131
  }
132
+ /**
133
+ * THE DATABASE NAME THE PROJECT'S NEXT RUN WILL ANSWER AS, with Mason stopped (a run's is in its URL: `postgresLink`): by
134
+ * the one rule, from the sources file's one key, so it needs neither the database's URL nor the rest of the file right.
135
+ */
136
+ export function projectDatabase(p, env = process.env) {
137
+ const written = writtenDatabase(p.folder, p.root);
138
+ return "message" in written ? written : connectDatabase(written, p.config, env);
139
+ }
140
+ /**
141
+ * THE NAME THE SOURCES FILE HAS MOVED TO SINCE A RUN STARTED answering as `answersAs` (the name is read at the start), or
142
+ * null. Only what the file says: a server's run may be named by its setting, in an environment the shell asking need
143
+ * not have, so a served project whose file writes no name has nothing to compare; a file that cannot be read is said
144
+ * as such by the commands that read it.
145
+ */
146
+ export function databaseMoved(p, answersAs) {
147
+ const written = writtenDatabase(p.folder, p.root);
148
+ if ("message" in written || (written.database === null && p.config.served === true))
149
+ return null;
150
+ const says = connectDatabase(written, {}, {});
151
+ return says === answersAs ? null : says;
152
+ }
153
+ /**
154
+ * A SETTING A START REFUSES, for the start that is typed and the run it spawns alike: read by the parent where the person
155
+ * (or --json) reads the answer, and by the run, which `mason start --attach` and `mason serve` reach without the parent.
156
+ * One list, because a check in the run alone reaches a background start as "the run stopped", its way out in a log.
157
+ */
158
+ export function startSettingRefusal(p, env, o) {
159
+ const top = () => sourcesTop(p.root, p.folder);
160
+ const pollMs = pollMsRetired(env, top);
161
+ if (pollMs !== null)
162
+ return { code: "refused", ...pollMs };
163
+ const changes = changesRetired(env, top);
164
+ if (changes !== null)
165
+ return { code: "refused", ...changes };
166
+ const database = o.serve ? null : databaseSettingRefusal(env, top().shown);
167
+ return database === null ? null : { code: "refused", ...database };
168
+ }
@@ -8,11 +8,13 @@ export function duration(seconds) {
8
8
  const m = Math.round(s / 60);
9
9
  return m < 90 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
10
10
  }
11
+ /** A duration in milliseconds as a person reads it (src/lib/table-sync.ts, where the sync reads it too). */
12
+ export { msText } from "../lib/table-sync.js";
11
13
  /**
12
14
  * WHAT A COPYING TABLE'S LINE SAYS, AND HOW FULL ITS BAR IS (a 20 M-row copy over the internet showed no progress: its
13
15
  * first line waited on 0 for minutes, then jumped to 100%). A copy's rows
14
16
  * land a chunk at a time, so the count is the committed chunks' rows PLUS what the chunks being read have read by now
15
- * at the rate the copy measured (`progress.rowsReading`, held under each chunk's expected rows, so a commit moves it
17
+ * at the rate the copy measured (`progress.rowsReading`, held under each chunk's expected rows, so a commit that holds them moves it
16
18
  * forward), and the text says what is happening: the chunks reading and for how long, the rate, and the time left at
17
19
  * it — never from the row estimate alone. Before anything is measured it says the first chunk is being read, never
18
20
  * `0 of`. The share is of the heap's PAGES read (`progress.share`: the ranges are exact), and only without the chunks'
@@ -35,7 +37,7 @@ export function copyingText(copied, rowsEstimate, p) {
35
37
  if (p.rowsPerSecond === null) {
36
38
  // nothing measured this attempt: its first chunks are being read (a copy resumed has rows from before)
37
39
  const first = p.reading === 0 ? "about to read the first chunk" : `reading the first ${p.reading === 1 ? "chunk" : chunks(p.reading)}${since}`;
38
- return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to copy`}` : `${rowsText(copied)} · resumed, ${first}` };
40
+ return { fraction, text: copied === 0 ? `${first}${estimate === null ? "" : ` · ~${n(estimate)} rows to sync`}` : `${rowsText(copied)} · resumed, ${first}` };
39
41
  }
40
42
  const parts = [rowsText(shown)];
41
43
  const allIn = p.reading === 0 && p.chunksDone >= p.chunksTotal;
@@ -51,7 +53,39 @@ export function copyingText(copied, rowsEstimate, p) {
51
53
  /** A copy's progress on one line, as `mason wait` lists what it waits for: `public.orders: copying 12% · …` (`copyingText`). */
52
54
  export function copyProgress(source, copied, rowsEstimate, p) {
53
55
  const c = copyingText(copied, rowsEstimate, p);
54
- return `${source}: ${c.fraction !== null ? `copying ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "copying " : ""}${c.text}`;
56
+ return `${source}: ${c.fraction !== null ? `syncing ${Math.floor(c.fraction * 100)}% · ` : /^\d/.test(c.text) ? "syncing " : ""}${c.text}`;
57
+ }
58
+ export function syncStateOf(c) {
59
+ if (c === undefined || c.phase === "waiting")
60
+ return "waiting";
61
+ if (c.phase === "failed" || c.error !== null)
62
+ return "failed";
63
+ if (c.phase === "copying" || c.rebuilding !== undefined || c.behind !== undefined)
64
+ return "syncing";
65
+ return "synced";
66
+ }
67
+ export function tableSync(c, now) {
68
+ const s = c?.synced;
69
+ return { state: syncStateOf(c), syncedAt: s?.at ?? null, syncSeconds: s?.seconds ?? null, syncedBy: s?.by ?? null,
70
+ ageSeconds: s === undefined ? null : Math.max(0, Math.round((now - Date.parse(s.at)) / 100) / 10) };
71
+ }
72
+ export function syncTotals(added, copies) {
73
+ const copied = copies.filter((c) => c.phase === "copied");
74
+ const latest = copies.flatMap((c) => c.synced === undefined ? [] : [c.synced.at]).sort().pop() ?? null;
75
+ return { added, copied: copied.length, failed: copies.filter((c) => syncStateOf(c) === "failed").length, rows: copied.reduce((n, c) => n + (c.rows ?? 0), 0), syncedAt: latest };
76
+ }
77
+ export function phaseCounts(copies) {
78
+ const counts = { total: 0, copied: 0, copying: 0, waiting: 0, failed: 0 };
79
+ for (const c of copies) {
80
+ counts.total++;
81
+ counts[c.phase]++;
82
+ }
83
+ return counts;
84
+ }
85
+ /** WHERE A RUN IS IN ONE WORD, as `mason status` heads its last line and the web page's Status says it: every added
86
+ * table copied and served, a failure, or still under way. */
87
+ export function readinessWord(r) {
88
+ return r.ready ? "Ready" : r.failed.length ? "Failed" : "Under way";
55
89
  }
56
90
  /**
57
91
  * WHERE A RUN IS. Ready when every added table's copy has ended well: `mason add` followed by `mason wait` returns
@@ -70,7 +104,7 @@ export function readiness(s) {
70
104
  continue;
71
105
  }
72
106
  if (!t.copy)
73
- waiting.push(`${t.table}: waiting for its copy to start`);
107
+ waiting.push(`${t.table}: waiting for its first sync to start`);
74
108
  }
75
109
  return { ready: failed.length === 0 && waiting.length === 0, failed, waiting };
76
110
  }
@@ -3,18 +3,23 @@
3
3
  * command's summary, in this order — so a command cannot exist without being listed, and tests/cli-help.test.ts holds
4
4
  * each one to src/cli/command.ts's rules (its own --help, an example, every option documented).
5
5
  */
6
- import { failed, parseCommand, reportText } from "./command.js";
6
+ import { failed, parseCommand, reportText, wantsJson } from "./command.js";
7
+ import { removedStartFlags } from "../lib/table-sync.js";
8
+ import { sourcesTop } from "./sources.js";
7
9
  import { dev } from "./dev.js";
8
10
  import { devInBackground } from "./background.js";
9
11
  import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, status, stop, tables, wait } from "./commands.js";
10
12
  import { USER, user } from "./user.js";
11
13
  import { MCP, mcp } from "./mcp.js";
14
+ import { SHARE, share } from "./share.js";
12
15
  import { UI, ui } from "./ui.js";
13
16
  import { GUIDES, guideText } from "./guides.js";
14
17
  import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
15
18
  import { DESTROY, destroy } from "./machine.js";
16
19
  import { INIT, init } from "./init.js";
17
20
  import { SYNC, sync } from "./sync.js";
21
+ import { USAGE, usage } from "./usage.js";
22
+ import { CANCEL, cancel } from "./cancel.js";
18
23
  import { SERVE, serve } from "./serve.js";
19
24
  import { PROJECT_LINE } from "./dev.js";
20
25
  import { version } from "./version.js";
@@ -27,21 +32,31 @@ export const DEV = {
27
32
  needs: "project",
28
33
  name: "start",
29
34
  summary: "start Mason for this folder's project, in the background (mason init makes the project)",
30
- usage: `usage: mason start [--attach] [--replication [--publication <name>] | --poll] [--self-signed-tls] [--port <n>] [--json]
35
+ usage: `usage: mason start [--attach] [--self-signed-tls] [--port <n>] [--json]
31
36
 
32
- Starts Mason for the project of this folder (mason init made it): one process, which copies the tables you add
33
- (mason add) from the project's database into its own file, keeps them current, and answers the Postgres protocol
34
- for BI tools and your app. It only reads the database (SELECT): no wal_level, replication slot or publication is
35
- needed or made. It returns once Mason is up; run it again any time (it says so when Mason is already running). The
36
- copy and Mason's state stay between runs: the next start resumes.
37
+ Starts Mason for the project of this folder (mason init made it): one process, which syncs the tables you add
38
+ (mason add) from the project's database into its own file, keeps them current as the sources file says, and answers
39
+ the Postgres protocol for BI tools and your app. It only reads the database (SELECT, and a table's change log when
40
+ its sync: says replication). It returns once Mason is up; run it again any time (it says so when Mason is
41
+ already running). The synced data and Mason's state stay between runs: the next start resumes.
37
42
 
38
- With --replication it reads the database's change log instead: it needs wal_level = logical and a user that may
39
- replicate, and makes its own publication and replication slot on the database (mason destroy, or mason start --poll,
40
- drops them). The project keeps the mode until --poll; a switch keeps the copies and catches each table up by one
41
- poll. --publication <name> (else PG_PUBLICATION) names a publication that is the database owner's: it is read as it
42
- is, verified to carry every added table and column, and never created, altered or dropped — Mason makes only its
43
- slot. A read replica needs one (it takes no CREATE PUBLICATION). The name is kept with the project, so the next
44
- mason start --replication reads it again.
43
+ After its first sync, each table is kept current as the sources file's sync: says — at the top for every table, or
44
+ on a table's entry: off (the default, which mason init writes) reads it again only when you run mason sync <table>;
45
+ poll <interval> (poll 5s, poll 5m, poll 1h) polls it with SELECTs at that interval; replication reads its changes
46
+ from the database's change log, as they are made. Each poll scans its table whole
47
+ on your database, and the next waits at least four times as long as that poll took, so the polls never take more
48
+ than a fifth of your database's time. The start says the file's default once; mason status says how each table is
49
+ kept current and when it was last synced. A table whose sync: changed keeps its synced data and is caught up by one poll.
50
+
51
+ A table under sync: replication needs wal_level = logical, a user that may replicate, and a primary key (a key:
52
+ table is polled): Mason makes one replication slot for the project and its own publication, carrying the replicated
53
+ tables alone, and drops them, verified gone, when no table's sync is replication any more (and at mason destroy).
54
+ publication: <name> at the top of the sources file (\${PG_PUBLICATION} reads it from .env) names a publication that
55
+ is the database owner's: it is read as it is, verified to carry every replicated table and column, and never
56
+ created, altered or dropped — Mason makes only its slot. A read replica needs one (it takes no CREATE PUBLICATION).
57
+
58
+ --replication, --poll and --publication are gone: each answers with the line to write at the top of the sources
59
+ file (sync: replication, sync: poll 5m, publication: <name>) and exit 2, and starts nothing.
45
60
 
46
61
  The Postgres API's port is the project's: the first start takes 5433, or the next port free on every address when a
47
62
  second Postgres holds it, and records it; every later start listens on the same port, so a BI tool's saved connection
@@ -58,15 +73,9 @@ export const DEV = {
58
73
 
59
74
  mason start
60
75
  mason start --attach in the foreground: Ctrl-C stops it
61
- mason start --replication read the change log, as production does
62
- mason start --replication --publication app_pub through the database owner's publication
63
76
  MASON_PG_HOST=0.0.0.0 mason start --self-signed-tls the Postgres API for other machines, over TLS
64
77
 
65
78
  --attach stay in the foreground (Ctrl-C stops Mason)
66
- --replication read the database's changes from its change log (logical replication), as production does
67
- --publication <name> with --replication: the publication to read, the owner's, never altered (PG_PUBLICATION;
68
- default: Mason's own, mason_dev_<project>_<source>)
69
- --poll read them with SELECTs (the default; takes back --replication)
70
79
  --self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
71
80
  served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
72
81
  --port <n> the port the mason commands reach it on (default 4039, or the next free one)
@@ -74,22 +83,22 @@ export const DEV = {
74
83
  `,
75
84
  options: {
76
85
  port: { type: "string" }, attach: { type: "boolean", default: false }, json: { type: "boolean", default: false },
77
- replication: { type: "boolean", default: false }, poll: { type: "boolean", default: false }, publication: { type: "string" },
78
86
  "self-signed-tls": { type: "boolean", default: false },
79
87
  },
80
88
  json: ["running", "pid", "postgres", "next"],
81
89
  };
82
90
  async function runDev(argv) {
91
+ // --replication, --poll and --publication are gone (issue 33): each answered with the edit to make in the sources
92
+ // file, exit 2, before the options are parsed (it would be an unknown option there) and before anything starts. The
93
+ // file is read only when one is given. A decision: `mason start --replication --help` answers the refusal, not the
94
+ // usage — the flag is wrong whatever else the line asks
95
+ const removed = removedStartFlags(argv, () => startSourcesTop(process.cwd()));
96
+ if (removed !== null)
97
+ return failed("start", { code: "usage", ...removed, next: [["mason start", "each table kept current as its sync: says"], ["mason help production", "sync: and publication:, and what each needs"]] }, { json: wantsJson(argv, DEV) });
83
98
  const f = parseCommand(DEV, argv);
84
99
  if (typeof f === "number")
85
100
  return f;
86
101
  const v = f.values;
87
- if (v.replication && v.poll)
88
- return failed("start", { message: "--replication or --poll, not both", code: "usage", usage: DEV.usage }, { json: v.json });
89
- if (v.publication !== undefined && v.poll)
90
- return failed("start", { message: "--publication goes with --replication, not --poll", code: "usage", usage: DEV.usage }, { json: v.json });
91
- if (v.publication !== undefined && !/^\S+$/.test(v.publication))
92
- return failed("start", { message: `--publication must name a publication, not ${JSON.stringify(v.publication)}`, code: "usage", usage: DEV.usage }, { json: v.json });
93
102
  if (f.positionals.length) {
94
103
  // the database is the project's now: a URL here is the old habit, answered with the new way
95
104
  return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", usage: DEV.usage }, { json: v.json });
@@ -98,8 +107,14 @@ async function runDev(argv) {
98
107
  if (!Number.isInteger(port) || port < 1 || port > 65_535)
99
108
  return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
100
109
  if (!v.attach)
101
- return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"], changes: v.replication ? "replication" : v.poll ? "poll" : null });
102
- return dev({ port, version: version(), changes: v.replication ? "replication" : v.poll ? "poll" : null, publication: v.publication ?? null, selfSignedTls: v["self-signed-tls"] });
110
+ return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"] });
111
+ return dev({ port, version: version(), selfSignedTls: v["self-signed-tls"] });
112
+ }
113
+ /** The project's sources file as a person names it (`mason/sources/app.yml`), for a removed flag's answer: the one file
114
+ * of the project of this folder, else the name every project's has. */
115
+ function startSourcesTop(cwd) {
116
+ const t = theProject(cwd);
117
+ return "problem" in t ? { shown: "mason/sources/<name>.yml", sync: null, publication: null } : sourcesTop(t.project.root, t.project.folder);
103
118
  }
104
119
  export const HELP = {
105
120
  name: "help",
@@ -152,8 +167,8 @@ export const STEPS = [
152
167
  ["Learn", [{ ...HELP, run: runHelp }]],
153
168
  ["Set up", [{ ...DOCTOR, run: (argv) => doctor(argv) }, { ...INIT, run: (argv) => init(argv) }, { ...DEV, run: runDev }]],
154
169
  ["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
155
- ["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }]],
156
- ["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
170
+ ["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }, { ...SHARE, run: share }]],
171
+ ["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...CANCEL, run: cancel }, { ...USAGE, run: (argv) => usage(argv) }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
157
172
  ["Go to production", [{ ...SERVE, run: serve }]],
158
173
  ];
159
174
  /** Every command, in the order `mason --help` lists them. */
@@ -164,6 +179,9 @@ export const COMMANDS = STEPS.flatMap(([, commands]) => commands);
164
179
  * it, then the next command for that moment — and mason help, every command. `mason help` and `mason --help` are the
165
180
  * whole list (topUsage).
166
181
  */
182
+ /** WHAT MASON IS, in one line: the first `mason` alone and `mason --help` print, in the words its reader was told it
183
+ * in ("a drop-in read replica for analytics"). */
184
+ export const TAGLINE = "Mason: a drop-in read replica of your Postgres, for analytics — the tables you add, synced, kept current and served to psql, BI tools and your app.";
167
185
  export async function orientation(cwd, s = styleOut()) {
168
186
  const t = theProject(cwd);
169
187
  const project = "project" in t ? t.project : null;
@@ -172,12 +190,12 @@ export async function orientation(cwd, s = styleOut()) {
172
190
  ? [["Project", `${project.config.name}, files in ${project.folder}`], ["Mason", run ? `running (pid ${run.pid})` : "not running"]]
173
191
  : [["Project", "none here: mason init makes this folder one"]];
174
192
  const next = !project
175
- ? [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"],
193
+ ? [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"],
176
194
  ["mason init demo", "or try it on a demo database Mason makes itself: nothing to install"],
177
195
  ["mason doctor", "what this machine needs for Mason, and whether it has it"]]
178
196
  : !run ? [["mason start", "start Mason for this project, in the background"], ["mason status", "the project and its tables"]]
179
197
  : [["mason status", "what Mason is doing: its tables, and the last day's slow and refused statements"], ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
180
- return `${reportText({ headline: "Mason: your Postgres tables, copied and served to BI tools and your app.", details: rows,
198
+ return `${reportText({ headline: TAGLINE, details: rows,
181
199
  next: [...next, ["mason help", "every command, and the guides"]] }, s)}\n`;
182
200
  }
183
201
  /** `mason --help`: what Mason is, every command with its summary, and where each one's help is. On a terminal the steps are
@@ -204,8 +222,9 @@ export function topUsage(s = styleOut()) {
204
222
  return [
205
223
  `${s.bold("usage:")} mason <command> [options]`,
206
224
  "",
207
- para("Mason: your Postgres tables, copied and served to BI tools and your app. Make your app's folder a project (mason init), " +
225
+ para(`${TAGLINE} Make your app's folder a project (mason init), ` +
208
226
  `add tables (mason add, before or after the start), start Mason (mason start), connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
227
+ para("Try it first with nothing to install: mason init demo, then mason start and mason ui (a demo shop database, hosted by Mason itself)."),
209
228
  para(PROJECT_LINE),
210
229
  "",
211
230
  listed.map(([step, es]) => ` ${s.bold(step)}\n${block(es)}`).join("\n\n"),
@@ -64,6 +64,8 @@ export function suggestedWalCapGb(bytesPerSecond) {
64
64
  return null;
65
65
  return Math.max(4, Math.ceil((bytesPerSecond * 86_400) / 1024 ** 3));
66
66
  }
67
+ /** What Mason does about a lost slot (src/duck/sync.ts `prepareReplication`), one wording for every check that says it. */
68
+ const SLOT_REMADE = "Mason makes a new slot and catches each table up with one poll (a scan of each table on your database), never a full sync";
67
69
  /** What wal_level = logical takes on this server: its provider's way, or postgresql.conf's. */
68
70
  function walLevelFix(f) {
69
71
  if (f.rds)
@@ -77,12 +79,12 @@ export function replicationChecks(f, slot, slotState) {
77
79
  const out = [];
78
80
  out.push(f.walLevel === "logical"
79
81
  ? { name: "wal_level", ok: true, detail: "logical" }
80
- : { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or read changes by polling instead (--poll: SELECT alone, for a small database)` });
82
+ : { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or poll the table instead: give its entry sync: poll <interval> in the sources file (SELECTs alone, no change log)` });
81
83
  if (f.standby) {
82
84
  out.push(f.version >= 160000
83
85
  ? { name: "standby", ok: true, warn: !f.hotStandbyFeedback,
84
- detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : ". hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (Mason then rebuilds from scratch) — turn it on"}` }
85
- : { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll it (--poll)` });
86
+ detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : `. hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (${SLOT_REMADE}) — turn it on`}` }
87
+ : { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll the table: give its entry sync: poll <interval> in the sources file` });
86
88
  }
87
89
  const canReplicate = f.superuser || f.replication || f.rds?.member === true;
88
90
  out.push(canReplicate
@@ -92,7 +94,7 @@ export function replicationChecks(f, slot, slotState) {
92
94
  const free = f.maxSlots - f.slotsUsed;
93
95
  if (slotState) {
94
96
  out.push(slotState.walStatus === "lost"
95
- ? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): Mason drops it and rebuilds from scratch at its start` }
97
+ ? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): ${SLOT_REMADE}` }
96
98
  : slotState.active
97
99
  ? { name: "slot", ok: false, detail: `${slot} is in use by another reader${slotState.activePid !== null ? ` (pid ${slotState.activePid} on the database)` : ""}: one slot has one reader. ` +
98
100
  `Stop the other Mason for this project (mason stop). A run that crashed holds it until its connection ends${slotState.activePid !== null ? `: SELECT pg_terminate_backend(${slotState.activePid}) ends it now` : ""}` }
@@ -110,7 +112,7 @@ export function replicationChecks(f, slot, slotState) {
110
112
  const size = gb === null ? "a day of this database's WAL" : `${gb}GB`;
111
113
  const basis = gb === null ? "" : ` (a day of the WAL this server writes: ${bytesText(f.walBytesPerSecond)}/s since its statistics were reset)`;
112
114
  out.push(f.slotWalKeepMb >= 0
113
- ? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres drops the slot rather than fill its disk, and Mason rebuilds from scratch${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means a rebuild` : ""}` }
115
+ ? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres invalidates the slot rather than fill its disk, and ${SLOT_REMADE}${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means that catch-up` : ""}` }
114
116
  : { name: "WAL cap", ok: false,
115
117
  detail: `max_slot_wal_keep_size is unlimited: while Mason is stopped its slot holds every byte of WAL, until the disk fills and Postgres stops taking writes. Cap it at the outage you want Mason to survive, e.g. ${size}${basis}` +
116
118
  (f.rds || f.cloudSql ? ` — in the ${f.rds ? "DB parameter group" : "database flags"} (max_slot_wal_keep_size, in MB)` : ""),