@mason-data/cli 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,34 +1,38 @@
1
1
  /**
2
2
  * THE COMMANDS BESIDE `mason start` (an interface as good for people as for coding agents).
3
- * Each one finds the run it means (src/cli/runs.ts), asks its command server (src/cli/control.ts), and prints
4
- * it for a person — or, with `--json`, as data a program reads. Exit codes are part of each command's contract:
3
+ * Each one finds the run it means (src/cli/runs.ts) and asks its command server (src/cli/control.ts) — or, for what
4
+ * needs only the project's files and its database (tables; add and remove while Mason is down), reads them itself — and
5
+ * prints it for a person or, with `--json`, as data a program reads. Exit codes are part of each command's contract:
5
6
  *
6
7
  * mason status [--json] what the run is doing: its database, its tables and their copies, the Postgres API
7
8
  * mason tables [<table>…] [--json] the database's tables, their columns, keys and foreign keys
8
9
  * mason add <table>… [--columns …] copy tables into Mason: every column, or the ones --columns names
9
10
  * mason remove <table>… take tables out of the sources file; their copies are dropped
10
- * mason wait [--timeout <s>] [--json] until every added table is copied: 0 ready, 1 a copy failed (said), 124 timed out
11
- * mason logs [-n <lines>] [-f] [--start] [--requests] the run's log; --start, the start's terminal; --requests, its request log
11
+ * mason wait [--timeout <s>] [--json] until every added table is synced: 0 ready, 1 a copy failed (said), 124 timed out
12
+ * mason logs [-n <lines>] [-f] [--start] [--requests] [--events] the run's log; --start, the start's terminal; --requests, its request log; --events, the sync's events
12
13
  * mason stop stop Mason for this folder's project (the copy stays)
13
14
  */
14
15
  import * as fs from "node:fs";
15
16
  import * as path from "node:path";
16
- import { endText, errorJson, failed, jsonText, nextBlock, parseCommand, reportText, withEnd } from "./command.js";
17
+ import { asFailure, endText, errorJson, failed, jsonText, nextBlock, parseCommand, reportText, withEnd } from "./command.js";
17
18
  import { err as styleErr, out as styleOut, stepRenderer, wrap } from "./style.js";
18
- import { alive, endRun, END_GRACE_MS, theRun } from "./runs.js";
19
+ import { alive, endRun, END_GRACE_MS, notAnswering, runState, theRun } from "./runs.js";
19
20
  import { version } from "./version.js";
20
- import { readRestarts, signalSupervisor, stopSupervisor, supervisorGone } from "./supervise.js";
21
- import { DUCK_FILE, isProject, projectSource, readProject, theProject, writeConfig } from "./project.js";
21
+ import { restartsFact, signalSupervisor, stopSupervisor, supervisorGone } from "./supervise.js";
22
+ import { DEMO_DIR, DUCK_FILE, STATE_DIR, databaseMoved, isProject, projectDatabase, projectSource, readProject, theProject, writeConfig } from "./project.js";
22
23
  import { callRun, RunUnreachable } from "./control.js";
23
- import { withPlaceholders, postgresLink } from "./pg-url.js";
24
+ import { withPlaceholders, postgresFacts, postgresLink } from "./pg-url.js";
24
25
  import { listenerText } from "./pg-tls.js";
25
- import { SLOW_MS } from "./run-server.js";
26
+ import { logEntries, logLineText } from "../lib/log-buffer.js";
26
27
  import { catalogTable, entryName, looksSecret, tableNameProblem } from "./sources.js";
28
+ import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
27
29
  import { connectSource, maskedUrl, parseSource, readSourceFacts } from "./preflight.js";
28
30
  import { reach } from "./state.js";
29
- import { copyingText, copyProgress, duration, readiness } from "./readiness.js";
31
+ import { copyingText, copyProgress, duration, msText, readiness, readinessWord } from "./readiness.js";
30
32
  import { pathText } from "../duck/kept-route.js";
33
+ import { DEFAULT_REQUEST_LIMIT, eventLimit, MAX_SEARCH, SLOW_MS, requestFilter, requestFilterParams } from "./request-filter.js";
31
34
  import { countsText } from "../duck/stream.js";
35
+ import { agoText, BIG_TABLE_ROWS, parseSync, syncText } from "../lib/table-sync.js";
32
36
  const out = (m) => { process.stdout.write(`${m}\n`); };
33
37
  const err = (m) => { process.stderr.write(`${m}\n`); };
34
38
  // ------------------------------------------------------------------ shared
@@ -37,8 +41,9 @@ async function runOr(spec, json) {
37
41
  const w = await reach(spec, json);
38
42
  return w !== null && "run" in w ? w.run : null;
39
43
  }
44
+ /** The run's state with each table's columns (`?columns=1`: `mason status --json` carries them; the page polls it lean). */
40
45
  export async function readState(run) {
41
- const r = await callRun(run, "GET", "/api/state");
46
+ const r = await callRun(run, "GET", "/api/state?columns=1");
42
47
  if (r.status !== 200)
43
48
  throw new RunUnreachable(String(r.body.error ?? `HTTP ${r.status}`));
44
49
  return r.body;
@@ -78,7 +83,7 @@ export const tableArg = entryName;
78
83
  /** What to run once every added table is copied: a user for the tool that will query it (src/cli/user.ts). */
79
84
  export const READY_STEPS = [
80
85
  ["mason user add <name>", "a user for your BI tool or psql: it prints the password and the URL to paste"],
81
- ["mason status", "each table's copy, and the statements the Postgres API answered slowly or refused"],
86
+ ["mason status", "each synced table, and the statements the Postgres API answered slowly or refused"],
82
87
  ];
83
88
  /** What to run next over a database's tables: a table's columns, then adding it — named after a real table not yet added.
84
89
  * `from: "tables"` is mason tables itself asking: it never points at itself. */
@@ -91,14 +96,15 @@ export function addSteps(tables, from = null) {
91
96
  return [
92
97
  ...(from === "tables" ? [] : [["mason tables", "the database's tables: rows, key, whether added"]]),
93
98
  [`mason tables ${name}`, "one table's columns"],
94
- [`mason add ${name}`, "copy it into Mason: it is added to the sources file (--columns a,b,c: those only)"],
99
+ [`mason add ${name}`, "sync it into Mason: it is added to the sources file (--columns a,b,c: those only)"],
95
100
  ];
96
101
  }
97
102
  // ------------------------------------------------------------------ readiness (mason wait, mason status): src/cli/readiness.ts
98
- export { copyingText, copyProgress, duration, readiness };
99
- /** A table line's bar in its state's colour (on a terminal): green copied, red failed, yellow served but not kept current, cyan at work. */
103
+ export { copyingText, copyProgress, duration, msText, readiness, readinessWord };
104
+ /** A table line's bar in its state's colour (on a terminal): green copied, red failed, yellow served but not kept current, cyan at work
105
+ * (a table whose sync is off is copied as it asked: green). */
100
106
  export function paintBar(line, s) {
101
- const colour = / its copy failed: /.test(line) ? s.red : / NOT kept current /.test(line) ? s.yellow : / 100% [\d,]+ rows · copied( · |$)/.test(line) ? s.green : s.cyan;
107
+ const colour = / its first sync failed: /.test(line) ? s.red : / NOT kept current /.test(line) ? s.yellow : / 100% [\d,]+ rows · synced( · |$)/.test(line) ? s.green : s.cyan;
102
108
  return line.replace(/(█+)(░*)|(░+)/, (_m, full, empty, none) => full !== undefined ? `${colour(full)}${s.dim(empty ?? "")}` : s.dim(none ?? ""));
103
109
  }
104
110
  /** The width of a table's progress bar, in characters. */
@@ -109,37 +115,34 @@ const bar = (fraction) => { const full = Math.round(Math.max(0, Math.min(1, frac
109
115
  * b; it shows a line for each table, 100% for those up to date and a progress bar for those still at work, in order of
110
116
  * last processed, so the ones at work are at the bottom): its bar, its percent and what it is doing. The finished
111
117
  * first, in the order they finished (`copy.updatedAt`: a finished copy's state is written once, when it finishes), then
112
- * the failed, the copying and the waiting.
118
+ * the failed, the copying and the waiting. A copied table says how it is kept current and how old its copy is
119
+ * (`keptAge`): a copy's age is never hidden.
113
120
  */
114
- export function tableLines(s) {
121
+ export function tableLines(s, now = Date.now()) {
115
122
  const picked = s.tables.filter((t) => t.added);
116
123
  // 0 done, 1 failed, 2 copying, 3 waiting for its copy
117
124
  const rank = (t) => t.error ? 1 : t.copy?.phase === "copying" ? 2 : !t.copy ? 3 : 0;
118
125
  const rows = picked.map((t) => {
119
126
  const r = rank(t);
120
127
  let fraction, what;
121
- // a table over the poll's row limit is served as copied, and said so on its line (src/duck/sync.ts POLL_ROW_LIMIT)
122
- if (r === 0 && t.unsynced) {
128
+ if (r === 0) {
123
129
  fraction = 1;
124
- what = `${n(t.copy?.rowsCopied ?? 0)} rows · copied, NOT kept current (${t.unsynced.reason}${t.unsynced.asOf ? `; as of ${t.unsynced.asOf.slice(0, 16).replace("T", " ")} UTC` : ""}) — mason sync ${tableArg(t.table)}, or mason start --replication`;
125
- }
126
- else if (r === 0) {
127
- fraction = 1;
128
- what = `${n(t.copy?.rowsCopied ?? 0)} rows · copied${t.copy?.retired ? ` · ${t.copy.retired}` : ""}`;
130
+ const age = keptAge(t, now);
131
+ what = `${n(t.copy?.rowsCopied ?? 0)} rows · synced${t.copy?.retired ? ` · ${t.copy.retired}` : ""}${age ? ` · ${age}` : ""}`;
129
132
  }
130
133
  else if (r === 1) {
131
134
  fraction = 1;
132
- what = `its copy failed: ${firstLine(t.error)}`;
135
+ what = `its first sync failed: ${firstLine(t.error)}`;
133
136
  }
134
137
  else if (r === 2) {
135
138
  const c = copyingText(t.copy.rowsCopied, t.rows, t.copy.progress);
136
139
  fraction = c.fraction ?? 0;
137
140
  // a count says it is copying; "reading the first chunk" says so itself
138
- what = /^\d/.test(c.text) ? `copying ${c.text}` : c.text;
141
+ what = /^\d/.test(c.text) ? `syncing ${c.text}` : c.text;
139
142
  }
140
143
  else {
141
144
  fraction = 0;
142
- what = "waiting for its copy to start";
145
+ what = "waiting for its first sync to start";
143
146
  }
144
147
  return { t, r, at: t.copy?.updatedAt ?? "", line: `${bar(fraction)} ${`${Math.floor(fraction * 100)}%`.padStart(4)} ${what}` };
145
148
  });
@@ -147,8 +150,33 @@ export function tableLines(s) {
147
150
  const w = Math.max(0, ...rows.map((x) => x.t.table.length));
148
151
  return rows.map((x) => `${x.t.table.padEnd(w)} ${x.line}`);
149
152
  }
153
+ /**
154
+ * HOW A COPIED TABLE IS KEPT CURRENT, AND HOW OLD ITS COPY IS, on its line (issue 33: a copy days old answers today's
155
+ * count(*) wrongly, and nothing else tells): `sync: off, last synced 3 days ago — mason sync orders`, `polled every
156
+ * 30s, last 4 s ago`, `replicated, at 0/1A2B3C4D, 2 s ago` (the stream's position, and the last moment it had
157
+ * everything the database wrote); a table being caught up, copied again or not streamed says why it is NOT kept current.
158
+ * The Stream section says the rest of the stream.
159
+ */
160
+ export function keptAge(t, now = Date.now()) {
161
+ const k = t.kept;
162
+ if (k?.by === "replication") {
163
+ if (k.state !== "streamed")
164
+ return `NOT kept current (${k.syncedAt === null ? "never synced" : `last synced ${agoText(k.syncedAt, now)}`}): ${k.why ?? "it is not streamed yet"}`;
165
+ const at = `replicated, at ${k.position}, ${k.syncedAt === null ? "not synced yet" : agoText(k.syncedAt, now)}`;
166
+ return k.notRunning === null ? at : `${at} — NOT kept current now, the stream is not running: ${k.notRunning}`;
167
+ }
168
+ if (k?.by !== "poll")
169
+ return null;
170
+ const synced = k.syncedAt === null ? "never synced" : `last synced ${agoText(k.syncedAt, now)}`;
171
+ if (k.pending !== undefined)
172
+ return `NOT kept current (${synced}): ${k.pending}`;
173
+ // the line to write as well as the command: a project an older Mason polled is off after the upgrade
174
+ if (k.sync.mode === "off")
175
+ return `sync: off, ${synced} — mason sync ${tableArg(t.table)} syncs it now; sync: poll 5m on its entry polls it`;
176
+ return `polled every ${k.sync.every}, ${k.syncedAt === null ? "not polled yet" : `last ${agoText(k.syncedAt, now)}`}`;
177
+ }
150
178
  /** A span of seconds as the stream's lines say it: `0.3 s` under ten seconds, then as a wait reads (`duration`). */
151
- const spanText = (seconds) => seconds < 10 ? `${Math.max(0, seconds).toFixed(1)} s` : duration(seconds);
179
+ export const spanText = (seconds) => seconds < 10 ? `${Math.max(0, seconds).toFixed(1)} s` : duration(seconds);
152
180
  /**
153
181
  * WHERE THE STREAM IS, a thing a line (the first replication run on a server showed nothing of it in 35
154
182
  * minutes): its position against the database's and the bytes between, the newest change applied and how long after
@@ -167,6 +195,7 @@ export function streamLines(st, now = Date.now()) {
167
195
  ? `the database is at ${st.database.lsn}: ${n(st.lag.bytes ?? 0)} bytes behind (read ${spanText(since(st.database.at))} ago)`
168
196
  : `the database's position could not be read: ${st.database.error.split("\n")[0]}`;
169
197
  rows.push(["position", `${st.position} · ${db}`]);
198
+ rows.push(["slot", slotText(st.slotWal, since)]);
170
199
  rows.push(["last change", st.lastBatch === null ? "none applied this run"
171
200
  : `committed ${spanText(since(st.lastBatch.committedAt))} ago, applied ${spanText((Date.parse(st.lastBatch.appliedAt) - Date.parse(st.lastBatch.committedAt)) / 1000)} after its commit`]);
172
201
  const total = (t) => t.applied.inserts + t.applied.updates + t.applied.deletes + t.applied.truncates;
@@ -184,12 +213,32 @@ export function streamLines(st, now = Date.now()) {
184
213
  const w = Math.max(...rows.map(([k]) => k.length));
185
214
  return rows.map(([k, v]) => `${k.padEnd(w)} ${v}`);
186
215
  }
216
+ /**
217
+ * THE WAL THE SLOT HOLDS ON THE DATABASE, in a line (`StreamStatus.slotWal`): how much, Postgres's status of it, and the cap —
218
+ * or, with `max_slot_wal_keep_size` unset, that nothing caps it: while nothing reads the slot it holds WAL for ever, until
219
+ * the database's disk fills (CLAUDE.md "Hard-won gotchas"). A read that failed is said, never a 0.
220
+ */
221
+ export function slotText(w, since) {
222
+ if ("error" in w)
223
+ return `the WAL it holds could not be read: ${w.error.split("\n")[0]}`;
224
+ const read = `(read ${spanText(since(w.at))} ago)`;
225
+ if (w.walStatus === "lost")
226
+ return `lost: Postgres invalidated it (it held more WAL than max_slot_wal_keep_size), so Mason makes it again and catches the synced tables up by one poll ${read}`;
227
+ const cap = w.safeWalSizeBytes === null
228
+ ? "max_slot_wal_keep_size is unset, so nothing caps it: while nothing reads the slot it holds WAL for ever, until the database's disk fills (set max_slot_wal_keep_size on the database)"
229
+ : w.safeWalSizeBytes <= 0 ? "past max_slot_wal_keep_size: Postgres invalidates it at its next checkpoint, and Mason makes it again"
230
+ : `${fileSize(w.safeWalSizeBytes)} more before Postgres invalidates it (max_slot_wal_keep_size)`;
231
+ return `holds ${fileSize(w.walHeldBytes)} of the database's WAL${w.walStatus === null ? "" : ` (${w.walStatus})`} · ${cap} ${read}`;
232
+ }
187
233
  // ------------------------------------------------------------------ mason status
188
234
  const STATUS_USAGE = `usage: mason status [--json]
189
235
 
190
236
  Status: running, not running, or hung — then this project's facts, one a line: its folder, its database and how
191
- its changes are read, the run and its copy's file, the Postgres API and how it listens. Then each added table as
192
- mason wait shows it; the statements the Postgres API answered slowly in the last day; and the ones it refused or
237
+ its changes are read, the run and the synced data's file and the synced tables in all (synced, rows, the last sync), the Postgres API
238
+ and how it listens. Then each added table as mason wait shows it, with how it is kept current and when it was last synced
239
+ (sync: off, last synced 3 days ago; polled every 30s, last 4 s ago); the sync's latest failures (mason logs --events
240
+ lists every event) and the latest statements refused, failed or cancelled (mason logs --requests --failed); the Postgres API's statements of the last day and the last week (each outcome, the completed
241
+ ones' average, p50 and p95 time, the rows and bytes sent); the ones it answered slowly in the last day; and the ones it refused or
193
242
  failed, each with the reason (mason logs --requests lists every one). With Mason stopped, the same rows say "not
194
243
  running", how the last run ended, and the tables are the ones the sources file names. Hung is a run that is alive and does
195
244
  not answer its commands (a statement may be holding its main thread): the same rows, and mason stop ends it (exit 1).
@@ -197,16 +246,46 @@ const STATUS_USAGE = `usage: mason status [--json]
197
246
  mason status
198
247
  mason status --json
199
248
 
200
- --json as data: { running, pid, postgres: { url, host, port, database, sslmode (require when it is TLS), listener:
249
+ --json as data: { running, pid, postgres: { url, host, port, database, sslmode (require when it is TLS), shown (the URL with
250
+ <user>:<password> in the credentials' place), psql (its psql line), listener:
201
251
  { host (what it is bound to), tls (certificate, self-signed or null), fingerprint } }, source: { name (the
202
- source's: its sources file's), file (the sources file), url, database, remote, version, standby, changes,
203
- replication }, warnings, folder, project, tables: [{ table, rows, bytes, key, added, addedColumns, copy, error,
204
- … }], engine: { file, bytes, pollSeconds, restarts (each time its supervisor started the
205
- run again: { at, reason, upSeconds }) }, slowQueries, refusedQueries, refusedTotal (how many in all),
206
- readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), next }; with Mason stopped: { running: false, project, source: { name,
252
+ source's: its sources file's), file (the sources file), shownFile (it from the project's root), url, database, remote, version, standby, changes,
253
+ replication, demo (the demo database this run hosts), sync: { default (every table's sync unless its entry
254
+ says its own: off, poll 30s), written (the file's top sync: says it; false: the default) } }, warnings, folder, project, tables: [{ table, rows,
255
+ bytes, key, added, addedColumns, copy, error, kept, sync: { state (synced, syncing, failed, waiting), syncedAt (when
256
+ it was last brought current), syncSeconds (how long that took), syncedBy (copy, poll, catch-up, stream),
257
+ ageSeconds (how long ago that was) }, … }], totals: { added, copied, failed, rows (the synced tables' rows),
258
+ syncedAt (the latest sync of any) }, engine: { file, bytes, keptDays (MASON_HISTORY_DAYS), version (the run's), startedAt,
259
+ uptimeSeconds, restarts (the times its supervisor started the run again: { count, last: { at, why, upSeconds }
260
+ or null }, or { error }) }, disk: { path (where the synced data's file is), freeBytes, copyBytes (the file),
261
+ projectedBytes (what the first syncs running are projected to add; null: none, or too early to say), error },
262
+ sessions: { open (the Postgres API's sessions logged in), byClient: [{ user ('' a token's), client (its
263
+ application_name), open, running }] }, share: (null, or what mason share has open) { port, fingerprint, sessions,
264
+ refusedLogins, droppedConnections, shares: [{ user, role, reads, openedAt, via, host, port, endsAt, state }] }, queries: { keptDays, day, week } (the last day's and the last 7
265
+ days' statements, each { days, covered (the days of it the request log keeps), statements, completed, refused,
266
+ failed, cancelled, rows, bytesSent, durationMs: { avg, p50, p95 } (the completed ones') or null }; { error }
267
+ when it could not be read), slowQueries, refusedQueries, refusedTotal (how many in all), unsupported: [{ gap
268
+ (its entry of docs/SQL-SUPPORT.md), title (what it is; null for an entry this Mason no longer has), runs
269
+ (the statements it refused in the last day), users, clients (application_name), last, example (the latest,
270
+ cut at 1,000 characters) }] (the most refused first: GET /api/unsupported) or { error },
271
+ recentErrors: { sync: [{ at, kind, failed, table, message }] (the latest failures of the sync and restarts,
272
+ newest last: mason logs --events), error (why they could not be read, or null), queries: [{ requestId,
273
+ startedAt, outcome, sqlstate, message, query, … }] (the latest statements refused, failed or cancelled, newest
274
+ last, as mason logs --requests --json shows each: mason logs --requests --failed), queriesError (or null) },
275
+ readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), next }.
276
+ Each table's kept is how it is kept current now (absent for a table the sync does not hold):
277
+ { by: poll, sync: { mode: off } or { mode: poll, every (30s), seconds } (its entry's sync:, else the file's,
278
+ else off), syncedAt (its last whole read or poll), nextAt (when its next poll is due; null: off, or not polled
279
+ now), pending (only when it applies: why it is not kept current now, its phase included: it waits for
280
+ its first sync, it is being read whole, its first sync failed, it is read again whole, caught up first) }
281
+ or { by: recopy, copiedAt, pending (only when it applies, as the poll's) }
282
+ or { by: replication, slot, state (streamed, catching-up, waiting, left-out), why, position, lastCommit,
283
+ notRunning (why the stream is not running, null when it is) }; with Mason stopped: { running: false, project, source: { name,
207
284
  file, url, error (the sources file's problem, or null) }, engine, tables: [{ table }], lastRun, lastStop:
208
285
  { at, how } or null, next }; hung: the same with hung: { pid, problem, why },
209
- exit 1. In replication mode stream: { slot, publication, running, failure, position,
286
+ exit 1. In replication mode stream: { slot, slotWal: { walHeldBytes (the WAL the slot holds on the
287
+ database), walStatus (reserved, extended, unreserved, lost), safeWalSizeBytes (the WAL left before Postgres
288
+ invalidates it; null: max_slot_wal_keep_size unset, nothing caps it), at } or { error }, publication, running, failure, position,
210
289
  applied, database: { lsn, at } or { error }, lag: { bytes, seconds }, lastBatch: { appliedAt, committedAt },
211
290
  tables: [{ table, state (streamed, catching-up, waiting, left-out), why, catchUp: { phase (queued, polling,
212
291
  applying), from, startedAt, held, retryAt }, applied: { inserts, updates, deletes, truncates } }] }.
@@ -215,14 +294,17 @@ const STATUS_USAGE = `usage: mason status [--json]
215
294
  fill: { rows, bytesPerRow, seconds, basis (measured: from the last fill of its column; disk or text: modelled from
216
295
  the column's bytes a row on disk, or its text's before it is on disk), rowsPerSecond } (the fill's estimate it
217
296
  was decided on), filled: { rows, seconds } (what its own fill measured), queued (a backfill waiting for the fill
218
- under way), keptAt, readyAt, why }]
297
+ under way), keptAt, readyAt, why }]. roles (a run with a roles file, mason/roles.yml: what a user added
298
+ with --role may read): { file, roles: [name], problem (what is wrong with the file now, every statement of a
299
+ user with a role refused while there is; null), default (the role of a user added without --role; null: the
300
+ file names none, and such a user reads nothing), all (the roles of tables: all) }
219
301
 
220
302
  In replication mode a Stream section says where the stream is: its position against the database's (bytes behind),
221
303
  when the newest change applied was committed and how long after it was applied, and each added table — streamed,
222
304
  with the changes applied this run; being caught up by one poll; not streamed yet; or left out — and why.
223
305
 
224
306
  Stored JSON paths: a JSON value statements keep reading (payload ->> 'customer_id' read by several statements, or by
225
- one slow one) is stored as a column of the copy and read from there, computed once when its row is written instead
307
+ one slow one) is stored as a column of the synced table and read from there, computed once when its row is written instead
226
308
  of for every row of every statement; on a table whose fill would take a minute or more, only once the statements
227
309
  reading it took a tenth of the fill's estimate in all. The estimate is the rate the last fill of the same JSON
228
310
  column measured, once one has run, else modelled from the column's bytes on disk; the line says which. Each path is
@@ -233,11 +315,12 @@ const STATUS_USAGE = `usage: mason status [--json]
233
315
  export const STATUS = {
234
316
  needs: "project",
235
317
  name: "status",
236
- summary: "what Mason is doing: the database, each table's copy, the Postgres API, the slow and refused statements",
318
+ summary: "what Mason is doing: the database, each synced table, the Postgres API, its statements and errors",
237
319
  usage: STATUS_USAGE,
238
320
  options: { json: { type: "boolean" } },
239
- json: ["running", "pid", "postgres", "source", "warnings", "folder", "project", "tables", "engine", "stream", "jsonPaths", "slowQueries", "refusedQueries", "refusedTotal", "readiness", "ui",
240
- "lastRun", "lastStop", "hung", "next"],
321
+ json: ["running", "pid", "postgres", "source", "warnings", "folder", "project", "tables", "totals", "engine", "disk", "sessions", "stream", "jsonPaths", "queries", "slowQueries", "refusedQueries",
322
+ "refusedTotal", "unsupported", "recentErrors", "readiness", "ui",
323
+ "roles", "share", "lastRun", "lastStop", "hung", "next"],
241
324
  };
242
325
  export async function status(argv) {
243
326
  const f = parseCommand(STATUS, argv);
@@ -255,7 +338,12 @@ export async function status(argv) {
255
338
  const run = r0.run;
256
339
  return guarded("status", json, async () => {
257
340
  const st = styleOut();
258
- const s = await readState(run);
341
+ const read = await readState(run);
342
+ // the name is read at the start, so an edit of the sources file's database: is said here until the run takes it
343
+ const answersAs = postgresLink(run.postgresUrl).database;
344
+ const fileSays = databaseMoved(r0.project, answersAs);
345
+ const s = fileSays !== null
346
+ ? { ...read, warnings: [...read.warnings, `the sources file now says tools connect with ${fileSays}, and this run answers as ${answersAs}: mason stop && mason start takes it`] } : read;
259
347
  const r = readiness(s);
260
348
  // the statements the Postgres API answered slowly, and the ones it refused, with the reason (src/duck/requests.ts)
261
349
  const slowReply = await callRun(run, "GET", "/api/slow");
@@ -263,30 +351,56 @@ export async function status(argv) {
263
351
  const refusedReply = await callRun(run, "GET", "/api/refused");
264
352
  const refused = refusedReply.status === 200 ? refusedReply.body.refused : [];
265
353
  const refusedTotal = typeof refusedReply.body.total === "number" ? refusedReply.body.total : null;
354
+ // the same refusals grouped by the unsupported SQL they used (src/duck/gaps.ts): what to rewrite
355
+ const unsupportedReply = await callRun(run, "GET", `/api/unsupported?limit=${STATUS_UNSUPPORTED}`);
356
+ const unsupported = unsupportedReply.status === 200 ? unsupportedReply.body.unsupported
357
+ : { error: String(unsupportedReply.body.error ?? `the run answered ${unsupportedReply.status}`) };
358
+ // the sync's latest failures (src/duck/events.ts), and the run's restarts among them
359
+ const eventsReply = await callRun(run, "GET", `/api/events?failed=1&limit=${RECENT_ERRORS}`);
360
+ // and the latest statements refused, failed or cancelled: the request log's own page, the shape mason logs --requests --json prints
361
+ const failedReply = await callRun(run, "GET", `/api/requests?failed=1&limit=${RECENT_ERRORS}`);
362
+ const recentErrors = {
363
+ sync: eventsReply.status === 200 ? eventsReply.body.events : [],
364
+ error: eventsReply.status === 200 ? null : String(eventsReply.body.error ?? `the run answered ${eventsReply.status}`),
365
+ queries: failedReply.status === 200 ? failedReply.body.requests : [],
366
+ queriesError: failedReply.status === 200 ? null : String(failedReply.body.error ?? `the run answered ${failedReply.status}`),
367
+ };
368
+ // the last day's and week's statements (src/duck/requests.ts summary): asked here, never with the state
369
+ const queriesReply = await callRun(run, "GET", "/api/queries");
370
+ const queries = queriesReply.status === 200 ? queriesReply.body.queries
371
+ : { error: String(queriesReply.body.error ?? `the run answered ${queriesReply.status}`) };
266
372
  const picked = s.tables.filter((t) => t.added);
267
373
  // what to run next, the same for a person and under --json
268
374
  const next = !picked.length ? addSteps(s.tables)
269
375
  : r.ready ? [...READY_STEPS]
270
376
  : r.failed.length ? [["mason logs", "the detail of each failure"], ["mason wait", "each failure, with the way out"]]
271
377
  : [["mason wait", "until it is done"]];
272
- // the times its supervisor started it again (src/cli/supervise.ts): a run that crashed says so, never only in a log
273
- const restarts = restartsOf(run.home);
274
378
  if (json) {
275
- out(jsonText(STATUS, { running: true, pid: run.pid, postgres: { ...postgresLink(run.postgresUrl), listener: run.postgresListener },
276
- ...s, engine: { ...s.engine, restarts }, slowQueries: slow, refusedQueries: refused, refusedTotal, readiness: r }, next));
379
+ out(jsonText(STATUS, { running: true, pid: run.pid,
380
+ ...s, postgres: { ...postgresFacts(run.postgresUrl), listener: run.postgresListener }, queries, slowQueries: slow, refusedQueries: refused, refusedTotal, unsupported, recentErrors, readiness: r }, next));
277
381
  return 0;
278
382
  }
279
383
  const rows = [
280
384
  ["Project", `${run.name}, files in ${s.folder ?? "(none chosen)"}`],
281
385
  ...sourceRows(s),
282
- ["Mason", `${version()}, pid ${run.pid} · one process`],
283
- ["Copy", `${s.engine.file}${s.engine.bytes === null ? "" : ` · ${fileSize(s.engine.bytes)}`}`],
284
- ...restartsRows(restarts),
386
+ masonRow(s.engine, run.pid),
387
+ ["Synced data", `${s.engine.file}${s.engine.bytes === null ? "" : ` · ${fileSize(s.engine.bytes)}`}`],
388
+ ...diskRows(s.disk),
389
+ // the copies in all (src/cli/readiness.ts syncTotals): a run from before them has none
390
+ ...(s.totals ? [["Synced tables", totalsText(s.totals)]] : []),
391
+ // the times its supervisor started it again (src/cli/supervise.ts): a run that crashed says so, never only in a log
392
+ ...restartsRows(s.engine.restarts),
285
393
  ["Postgres API", `${withPlaceholders(run.postgresUrl)} (mason user add <name> makes a user)`],
394
+ ...sessionsRows(s.sessions),
286
395
  // what it serves: its address, TLS or plain (src/cli/pg-tls.ts)
287
396
  ["Listener", listenerText(run.postgresListener, postgresLink(run.postgresUrl).port)],
397
+ ...shareRows(s.share),
288
398
  // the web page (mason ui), without its one-time token: mason ui prints the URL that carries it, and opens it
289
399
  ...(s.ui === true ? [["Web UI", `${run.control.url}/ (mason ui opens it)`]] : []),
400
+ // the roles file as the listener reads it now (mason user add --role): its roles, or what is wrong with it
401
+ ...(s.roles && s.roles.file !== null
402
+ ? [["Roles", s.roles.problem !== null ? st.red(`${s.roles.problem} (every statement of a user with a role is refused until it is fixed)`)
403
+ : `${s.roles.roles.join(", ")} (${s.roles.file}; ${s.roles.default != null ? `default ${s.roles.default} for a user added without --role` : "no default: a user with no role reads nothing"})`]] : []),
290
404
  ...s.warnings.map((w) => ["Warning", w]),
291
405
  ];
292
406
  out(reportText({ headline: `Status: ${st.green("running")}`, details: rows }, st));
@@ -304,19 +418,29 @@ export async function status(argv) {
304
418
  out(st.bold(`Tables (${picked.length} of ${s.tables.length} added):`));
305
419
  for (const l of tableLines(s))
306
420
  out(` ${paintBar(l, st)}`);
421
+ for (const l of syncErrorLines(recentErrors, st))
422
+ out(l);
423
+ for (const l of queryErrorLines(recentErrors, st))
424
+ out(l);
307
425
  for (const l of jsonPathLines(s.jsonPaths ?? [], st))
308
426
  out(l);
427
+ for (const l of queryLines(queries, st))
428
+ out(l);
309
429
  for (const l of slowLines(slowReply.status === 200 ? slow : { error: String(slowReply.body.error ?? slowReply.status) }, st))
310
430
  out(l);
311
431
  for (const l of refusedLines(refusedReply.status === 200 ? { refused, total: refusedTotal } : { error: String(refusedReply.body.error ?? refusedReply.status) }, st))
312
432
  out(l);
433
+ for (const l of unsupportedLines(unsupported, st))
434
+ out(l);
313
435
  out("");
436
+ // the word the web page's Status says too (readinessWord)
437
+ const word = readinessWord(r);
314
438
  if (r.ready)
315
- out(withEnd(st.green("Ready: every added table is copied and served."), { next }));
439
+ out(withEnd(st.green(`${word}: every added table is synced and served.`), { next }));
316
440
  else if (r.failed.length)
317
- out(withEnd(`${st.red("Failed:")}\n${bullets(r.failed)}`, { next }));
441
+ out(withEnd(`${st.red(`${word}:`)}\n${bullets(r.failed)}`, { next }));
318
442
  else
319
- out(withEnd(`Under way:\n${r.waiting.map((x) => ` - ${x}`).join("\n")}`, { next }));
443
+ out(withEnd(`${word}:\n${r.waiting.map((x) => ` - ${x}`).join("\n")}`, { next }));
320
444
  return 0;
321
445
  });
322
446
  }
@@ -326,11 +450,15 @@ export async function status(argv) {
326
450
  */
327
451
  export function sourceRows(s) {
328
452
  return [
329
- ...(s.source.file ? [["Source", `${s.source.file} (the tables it names are added)`]] : []),
330
- ["Database", `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
331
- ["Changes", s.source.replication ? `logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication} ` +
332
- `(${s.source.replication.owned ? "Mason's own" : "named: read as it is, never altered"}) · mason start --poll goes back to polling`
333
- : `polling with SELECTs (xmin), every ${s.engine.pollSeconds} s · mason start --replication reads the database's change log instead`],
453
+ ...(s.source.file ? [["Source", `${s.source.shownFile ?? s.source.file} (the tables it names are added)`]] : []),
454
+ ["Database", s.source.demo ? `the demo database Mason hosts (${s.source.url} · Postgres ${s.source.version})`
455
+ : `${s.source.url} · Postgres ${s.source.version}${s.source.standby ? " · a read replica" : ""}`],
456
+ // in a web developer's words first (each table as its sync: says, streamed as they happen); the mechanism an operator needs in parentheses
457
+ ["Changes", `each table as its sync: says${s.source.replication ? "" : " (a SELECT of its changed rows)"}${s.source.sync ? ` · sync: ${s.source.sync.default} ${s.source.sync.written ? "for every table without its own" : "(the default: the file writes none)"}` : ""} · ` +
458
+ (s.source.replication ? `sync: replication tables streamed as they happen (logical replication: slot ${s.source.replication.slot}, publication ${s.source.replication.publication}, ` +
459
+ `${s.source.replication.owned ? "Mason's own, carrying them alone" : "named: read as it is, never altered"}); the others by a SELECT of their changed rows`
460
+ : s.source.demo ? "the demo database is checked this way only"
461
+ : "sync: replication on an entry streams that table as it happens instead")],
334
462
  ];
335
463
  }
336
464
  /** An SQL statement on one line, cut at `max` characters (the whole of it is mason logs --requests --json's). */
@@ -347,7 +475,7 @@ export function jsonPathLines(paths, st) {
347
475
  const dropped = paths.length - live.length;
348
476
  if (!live.length && !dropped)
349
477
  return [];
350
- const lines = [`\n${st.bold("Stored JSON paths")} ${st.dim("(read from a column of the copy, computed once per row written; MASON_STORE_JSON_PATHS=off turns it off)")}:`];
478
+ const lines = [`\n${st.bold("Stored JSON paths")} ${st.dim("(read from a column of the synced table, computed once per row written; MASON_STORE_JSON_PATHS=off turns it off)")}:`];
351
479
  for (const p of live) {
352
480
  const pct = p.rowsTotal > 0 ? Math.min(99, Math.floor((p.rowsDone / p.rowsTotal) * 100)) : 0;
353
481
  // a path its fill's cost holds back says so: its why is the decision, with the estimate
@@ -359,6 +487,51 @@ export function jsonPathLines(paths, st) {
359
487
  lines.push(` ${st.dim(`${dropped} dropped (mason status --json says why)`)}`);
360
488
  return lines;
361
489
  }
490
+ /** The copies in all, as mason status's `Copies` row says them: `3 of 3 added synced, 1,234 rows, the last synced 5 s ago`. */
491
+ export function totalsText(t, now = Date.now()) {
492
+ const last = t.syncedAt === null ? "" : `, the last synced ${duration((now - Date.parse(t.syncedAt)) / 1000)} ago`;
493
+ return `${n(t.copied)} of ${n(t.added)} added synced, ${n(t.rows)} row${t.rows === 1 ? "" : "s"}${t.failed ? `, ${n(t.failed)} failed` : ""}${last}`;
494
+ }
495
+ /** How many of the latest sync failures and failed statements mason status shows (`recentErrors`); mason logs --events and --requests --failed list each. */
496
+ export const RECENT_ERRORS = 5;
497
+ /** One event of the sync on one line (src/duck/events.ts): when, what (a failure in red), its table, its reason's first line. */
498
+ export function eventLine(e, st) {
499
+ return `${e.at.slice(0, 19).replace("T", " ")} ${(e.failed ? st.red : st.green)(e.kind.padEnd(18))} ${e.table ?? "(the sync)"} ${firstLine(e.message)}`;
500
+ }
501
+ /** mason status's sync errors: the latest few, a line each, newest last; none, no section. */
502
+ export function syncErrorLines(r, st) {
503
+ if (r.error !== null)
504
+ return [`\n${st.bold("Sync errors")}: could not read them (${firstLine(r.error)}; mason logs has the detail)`];
505
+ if (!r.sync.length)
506
+ return [];
507
+ return [`\n${st.bold("Sync errors")} ${st.dim("(the latest, newest last; mason logs --events lists every event)")}:`, ...r.sync.map((e) => ` ${eventLine(e, st)}`)];
508
+ }
509
+ /** mason status's statement errors: the latest few refused, failed or cancelled, a line each (`requestLine`), newest last; none, no section. */
510
+ export function queryErrorLines(r, st) {
511
+ if (r.queriesError !== null)
512
+ return [`\n${st.bold("Query errors")}: could not read them (${firstLine(r.queriesError)}; mason logs has the detail)`];
513
+ if (!r.queries.length)
514
+ return [];
515
+ return [`\n${st.bold("Query errors")} ${st.dim("(the latest refused, failed or cancelled, newest last; mason logs --requests --failed lists each)")}:`, ...r.queries.map((q) => ` ${requestLine(q, st)}`)];
516
+ }
517
+ /** Bytes as a person reads them: B, KiB, MiB, GiB (the web page's statements say the same, src/web/shared.js). */
518
+ export function bytesText(b) { return b < 1024 ? `${n(b)} B` : fileSize(b); }
519
+ /**
520
+ * mason status's queries (src/duck/requests.ts summary): the last day's and the last week's statements, a line each —
521
+ * how many, each outcome, the completed ones' times, the rows and bytes sent — and, when the request log keeps fewer
522
+ * days than the week, that the week is only what it keeps.
523
+ */
524
+ export function queryLines(q, st) {
525
+ if ("error" in q)
526
+ return [`\n${st.bold("Queries")}: could not read them (${firstLine(q.error)}; mason logs has the detail)`];
527
+ const line = (label, w) => {
528
+ const counts = `${n(w.statements)} statement${w.statements === 1 ? "" : "s"}: ${n(w.completed)} completed, ${n(w.refused)} refused, ${n(w.failed)} failed, ${n(w.cancelled)} cancelled`;
529
+ const times = w.durationMs === null ? "" : ` · ${msText(w.durationMs.avg)} / ${msText(w.durationMs.p50)} / ${msText(w.durationMs.p95)}`;
530
+ const short = w.covered < w.days ? st.dim(` (the request log keeps ${w.covered} day${w.covered === 1 ? "" : "s"}: MASON_HISTORY_DAYS)`) : "";
531
+ return ` ${label.padEnd(10)} ${counts}${times} · ${n(w.rows)} rows, ${bytesText(w.bytesSent)} sent${short}`;
532
+ };
533
+ return [`\n${st.bold("Queries")} ${st.dim("(the completed ones' time: average / p50 / p95; mason logs --requests lists each)")}:`, line("last day", q.day), line("last week", q.week)];
534
+ }
362
535
  /** mason status's slow statements (src/duck/requests.ts slow), each on one line with its times. */
363
536
  export function slowLines(slow, st) {
364
537
  if (!Array.isArray(slow))
@@ -367,7 +540,7 @@ export function slowLines(slow, st) {
367
540
  return [];
368
541
  const lines = [`\n${st.bold("Slow queries")} ${st.dim(`(the last day, still ${(SLOW_MS / 1000).toLocaleString("en-US")} s or more; slowest first)`)}:`];
369
542
  for (const q of slow) {
370
- const times = `${n(q.latestMs)} ms last time (${q.runs} run${q.runs === 1 ? "" : "s"}, median ${n(q.medianMs)} ms)`;
543
+ const times = `${msText(q.latestMs)} last time (${q.runs} run${q.runs === 1 ? "" : "s"}, median ${msText(q.medianMs)})`;
371
544
  lines.push(` ${times}, last ${q.last.slice(0, 16).replace("T", " ")} UTC`, ` ${oneLine(q.payload)}`);
372
545
  }
373
546
  return lines;
@@ -388,27 +561,80 @@ export function refusedLines(r, st) {
388
561
  }
389
562
  return lines;
390
563
  }
391
- /** A file's size, as a person reads one */
392
- function fileSize(b) {
564
+ /** how many entries of the unsupported SQL mason status shows (and its --json carries) */
565
+ const STATUS_UNSUPPORTED = 5;
566
+ /**
567
+ * `Unsupported SQL` in mason status: the last day's refused statements grouped by the registry entry that refused them
568
+ * (src/duck/requests.ts unsupported), the most refused first — how many, what they used, who sent them, and the latest
569
+ * — so a person knows what to rewrite; docs/SQL-SUPPORT.md has each entry's way out. None: no section.
570
+ */
571
+ export function unsupportedLines(u, st) {
572
+ if ("error" in u)
573
+ return [`\n${st.bold("Unsupported SQL")}: could not read it (${firstLine(u.error)}; mason logs has the detail)`];
574
+ if (!u.length)
575
+ return [];
576
+ const lines = [`\n${st.bold("Unsupported SQL")} ${st.dim("(the last day's refused statements by what they used, the most first)")}:`];
577
+ for (const g of u) {
578
+ const title = g.title ?? "SQL this Mason no longer lists";
579
+ const who = [...(g.users.length ? [`as ${g.users.join(", ")}`] : []), ...(g.clients.length ? [`from ${g.clients.join(", ")}`] : [])];
580
+ lines.push(` ${title}: ${n(g.runs)} statement${g.runs === 1 ? "" : "s"}${who.length ? ` ${who.join(" ")}` : ""}`);
581
+ lines.push(` last ${g.last.slice(0, 16).replace("T", " ")} — ${oneLine(g.example)}`);
582
+ lines.push(` How to rewrite it: see docs/SQL-SUPPORT.md › ${title}`);
583
+ }
584
+ return lines;
585
+ }
586
+ /** A file's size, as a person reads one (mason status's copy, mason usage's) */
587
+ export function fileSize(b) {
393
588
  return b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)} GiB` : b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(1)} MiB` : `${(b / 1024).toFixed(1)} KiB`;
394
589
  }
395
- /** The restarts recorded for a run, or why they could not be read (a file nobody can read is said, never taken as none). */
396
- function restartsOf(home) {
397
- try {
398
- return readRestarts(home);
399
- }
400
- catch (e) {
401
- return { error: `${path.join(home, "restarts.json")} could not be read: ${e.message}` };
402
- }
590
+ /** `Mason` in mason status: the run's own version (a CLI of another version says the run's), its pid, and how long it
591
+ * has been up (a run from before it: the CLI's version, no uptime) */
592
+ export function masonRow(e, pid) {
593
+ return ["Mason", `${e.version ?? version()}, pid ${pid} · one process${e.startedAt ? ` · up ${spanText(e.uptimeSeconds ?? 0)}, since ${e.startedAt.slice(0, 19).replace("T", " ")} UTC` : ""}`];
594
+ }
595
+ /** `Sessions` in mason status: how many are logged in to the Postgres API, how many run a statement, and by whom (the
596
+ * three with the most, then how many others) */
597
+ export function sessionsRows(s) {
598
+ if (s === undefined)
599
+ return [];
600
+ if (s.open === 0)
601
+ return [["Sessions", "none open"]];
602
+ const running = s.byClient.reduce((n, c) => n + c.running, 0);
603
+ const who = s.byClient.slice(0, 3).map((c) => `${c.open} ${c.user === "" ? "by a token" : `as ${c.user}`}${c.client === "" ? "" : ` (${c.client})`}`);
604
+ const others = s.byClient.length - who.length;
605
+ return [["Sessions", `${s.open} open, ${running} running a statement · ${who.join(", ")}${others > 0 ? `, and ${others} other client${others === 1 ? "" : "s"}` : ""} (mason logs --running lists the statements)`]];
606
+ }
607
+ /** `Share` in mason status: each share open now (mason share) and what its socket met; no row when none is. */
608
+ export function shareRows(s, now = Date.now()) {
609
+ if (s === undefined || s === null)
610
+ return [];
611
+ const each = s.shares.map((h) => {
612
+ const where = h.host === null ? "its tunnel is opening" : `${h.host}:${h.port} via ${h.via ?? "a tunnel"}${h.state === "reconnecting" ? " (reconnecting)" : ""}`;
613
+ const ends = h.endsAt === null ? "" : `, ends in about ${Math.max(0, Math.round((Date.parse(h.endsAt) - now) / 60_000))} min`;
614
+ return ["Share", `${where}, as ${h.user}, reads ${h.role === null ? h.reads : `as the role ${h.role}`}${ends}`];
615
+ });
616
+ return [...each, ["Share socket", `127.0.0.1:${s.port}, TLS required, SHA-256 ${s.fingerprint} · ${s.sessions} session${s.sessions === 1 ? "" : "s"}, ${s.refusedLogins} login${s.refusedLogins === 1 ? "" : "s"} refused, ${s.droppedConnections} dropped before a login`]];
403
617
  }
404
- /** `Restarts` in mason status: how many times the run was started again, and the last one's reason (none: no row) */
405
- function restartsRows(r) {
406
- if (!Array.isArray(r))
618
+ /** `Disk` in mason status: the bytes free where the synced data is, and what the first syncs running are projected to add (none: no
619
+ * row, for a run from before it) */
620
+ export function diskRows(d) {
621
+ if (d === undefined)
622
+ return [];
623
+ const adds = d.projectedBytes === null ? "" : ` · the first syncs running are projected to add about ${fileSize(d.projectedBytes)} more`;
624
+ if (d.freeBytes === null)
625
+ return [["Disk", `${d.error ?? "the free space could not be read"}${adds}`]];
626
+ return [["Disk", `${fileSize(d.freeBytes)} free in ${d.path}${adds}${d.projectedBytes !== null && d.projectedBytes > d.freeBytes ? " — more than is free: the sync stops before the disk fills" : ""}`]];
627
+ }
628
+ /** `Restarts` in mason status: how many times the run was started again, and the last one's reason (none: no row) —
629
+ * the run's own `engine.restarts` while it is up, the same fact read from the file while it is not (`restartsFact`) */
630
+ export function restartsRows(r) {
631
+ if (r === undefined)
632
+ return [];
633
+ if ("error" in r)
407
634
  return [["Restarts", r.error]];
408
- const last = r[r.length - 1];
409
- if (last === undefined)
635
+ if (r.last === null)
410
636
  return [];
411
- return [["Restarts", `${r.length}, the last ${ago(last.at)}: ${last.reason}, after ${last.upSeconds} s up (mason logs --start has its last lines)`]];
637
+ return [["Restarts", `${r.count}, the last ${ago(r.last.at)}: ${r.last.why}, after ${r.last.upSeconds} s up (mason logs --start has its last lines)`]];
412
638
  }
413
639
  /**
414
640
  * mason status with Mason stopped: the same rows and tables, "not running" where something is not. The
@@ -422,19 +648,23 @@ function stoppedStatus(project, json, hung = null) {
422
648
  const src = projectSource(project);
423
649
  const tables = "message" in src ? [] : src.tables.map((e) => e.table);
424
650
  const database = "message" in src ? null : maskedUrl(src.resolved);
651
+ // the name a tool connects with once it starts: null when the file cannot say it (`source.error` says why)
652
+ const connect = projectDatabase(project);
653
+ const connectName = typeof connect === "string" ? connect : null;
425
654
  const duckFile = path.join(project.dir, DUCK_FILE);
426
655
  // the copy's file, when a run has made one: its size says what the next mason start resumes with
427
656
  const bytes = fs.existsSync(duckFile) ? fs.statSync(duckFile).size : null;
428
- const restarts = restartsOf(project.dir);
657
+ const restarts = restartsFact(project.dir);
429
658
  const lastStop = project.config.lastStop ?? null;
430
- const next = hung === null ? ("message" in src && src.next?.length ? src.next : [["mason start", "start Mason for this project: what it copied is kept, and polled again"]])
659
+ const next = hung === null ? ("message" in src && src.next?.length ? src.next : [["mason start", "start Mason for this project: what it synced is kept, and polled again"]])
431
660
  : hung.problem.next ?? [["mason stop", "end it"], ["mason logs", "what it was doing"]];
432
661
  const state = hung === null ? "not running" : "hung";
433
662
  if (json) {
434
663
  out(jsonText(STATUS, { running: false, ...(hung === null ? {} : { hung: { pid: hung.run.pid, problem: hung.problem.problem, why: hung.problem.why } }),
664
+ postgres: { database: connectName },
435
665
  project: { name: project.config.name, folder: project.folder },
436
666
  source: { name: "message" in src ? null : src.name, file: "message" in src ? null : src.file, url: database, error: "message" in src ? errorJson(src) : null },
437
- engine: { name: "duckdb", file: duckFile, bytes, restarts }, tables: tables.map((table) => ({ table })),
667
+ engine: { file: duckFile, bytes, restarts }, tables: tables.map((table) => ({ table })),
438
668
  lastRun: project.config.lastRun ?? null, lastStop }, next));
439
669
  return hung === null ? 0 : 1;
440
670
  }
@@ -445,9 +675,9 @@ function stoppedStatus(project, json, hung = null) {
445
675
  ...(database === null ? [] : [["Database", `${database} (not checked: Mason ${hung === null ? "is not running" : "does not answer"})`]]),
446
676
  ["Mason", hung === null ? `${version()} · not running${project.config.lastRun ? ` (last started ${ago(project.config.lastRun)}${ended})` : ""}`
447
677
  : `${version()}, pid ${hung.run.pid} · running but does not answer its commands: ${hung.problem.why}`],
448
- ["Copy", bytes === null ? "none yet (mason start makes it)" : `${duckFile} · ${fileSize(bytes)}`],
678
+ ["Synced data", bytes === null ? "none yet (mason start makes it)" : `${duckFile} · ${fileSize(bytes)}`],
449
679
  ...restartsRows(restarts),
450
- ["Postgres API", hung === null ? "not running" : `${withPlaceholders(hung.run.postgresUrl)} (held by the hung run; it may not answer)`],
680
+ ["Postgres API", hung === null ? `not running${connectName === null ? "" : ` (a tool connects with the database ${connectName})`}` : `${withPlaceholders(hung.run.postgresUrl)} (held by the hung run; it may not answer)`],
451
681
  ] }, st));
452
682
  out("");
453
683
  if (!tables.length)
@@ -461,8 +691,9 @@ function stoppedStatus(project, json, hung = null) {
461
691
  out(endText({ tips: [], next }, st));
462
692
  return hung === null ? 0 : 1;
463
693
  }
464
- function ago(iso) {
465
- const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
694
+ /** How long ago a time was, as mason status says it (`12s ago`, `5 min ago`, `3 h ago`; the web page's `agoText`, held equal) */
695
+ export function ago(iso, now = Date.now()) {
696
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
466
697
  return s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)} min ago` : `${Math.round(s / 3600)} h ago`;
467
698
  }
468
699
  // ------------------------------------------------------------------ mason tables
@@ -480,8 +711,8 @@ const TABLES_USAGE = `usage: mason tables [<table>…] [--all] [--json]
480
711
  --all every table with its columns, not only the named ones
481
712
  --json as data: { database, source: { name (the source's: its sources file's), file (the sources file) },
482
713
  tables: [{ table, rows, bytes, primaryKey, comment, addable,
483
- reason, added, addedColumns (the columns its copy keeps: null when not added), copyKey (the key: the sources
484
- file copies it by, null: its primary key), columns: [{ name, type, nullable, comment, references }] }], next }
714
+ reason, added, addedColumns (the columns it syncs: null when not added), copyKey (the key: the sources
715
+ file keys it by, null: its primary key), columns: [{ name, type, nullable, comment, references }] }], next }
485
716
  `;
486
717
  export const TABLES = {
487
718
  // the project, not the run (the hands-on review of #451, item 15): what it lists is the database's catalog and the
@@ -518,7 +749,7 @@ export async function tables(argv) {
518
749
  // what to run next: the first table not yet added, as a whole list or as the named tables
519
750
  const one = shown.find((t) => !t.added && t.reason === null);
520
751
  const next = f.positionals.length
521
- ? (one ? [[`mason add ${tableArg(one.table)}`, `copy it into Mason (--columns ${one.columns.slice(0, 2).map((c) => c.name).join(",")}: those only)`]] : [])
752
+ ? (one ? [[`mason add ${tableArg(one.table)}`, `sync it into Mason (--columns ${one.columns.slice(0, 2).map((c) => c.name).join(",")}: those only)`]] : [])
522
753
  : addSteps(shown, "tables");
523
754
  if (json) {
524
755
  out(jsonText(TABLES, { database: s.database, source: { name: s.name, file: s.file }, tables: shown.map((t) => ({
@@ -537,16 +768,15 @@ export async function tables(argv) {
537
768
  if (!f.positionals.length && !f.values.all) {
538
769
  const w = Math.max(...shown.map((t) => t.table.length));
539
770
  for (const t of shown) {
540
- const size = t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`;
541
- out(`${t.table.padEnd(w)} ${size.padStart(18)} ${t.key.length ? `key ${t.key.join(", ")}` : "no key"}` +
542
- `${t.added ? " · added" : t.reason ? ` · cannot be added as it is: ${t.reason}` : ""}`);
771
+ const l = tableFacts(t);
772
+ out(`${t.table.padEnd(w)} ${l.size.padStart(18)} ${l.key}${l.state ? ` · ${l.state}` : ""}`);
543
773
  }
544
774
  out(nextBlock(next));
545
775
  return 0;
546
776
  }
547
777
  for (const t of shown) {
548
778
  const size = t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`;
549
- const added = t.added ? ` · added (${t.addedColumns.length === t.columns.length ? "every column" : t.addedColumns.join(", ")}${t.copyKey ? `; copied by ${t.copyKey.join(", ")}` : ""})` : "";
779
+ const added = t.added ? ` · added (${t.addedColumns.length === t.columns.length ? "every column" : t.addedColumns.join(", ")}${t.copyKey ? `; keyed by ${t.copyKey.join(", ")}` : ""})` : "";
550
780
  out(`${t.table} · ${size}${t.key.length ? ` · key ${t.key.join(", ")}` : ""}${added}`);
551
781
  if (t.comment)
552
782
  out(` ${t.comment}`);
@@ -566,22 +796,35 @@ export async function tables(argv) {
566
796
  out(nextBlock(next).slice(1));
567
797
  return 0;
568
798
  }
799
+ /** A table's line in `mason tables`: its size, its key, and added or why it cannot be added as it is (the web page's
800
+ * tables say the same, src/web/app.js; tests/web-page.test.ts holds the two equal). */
801
+ export function tableFacts(t) {
802
+ return { size: t.rows === null ? "rows unknown" : `~${n(t.rows)} rows`, key: t.key.length ? `key ${t.key.join(", ")}` : "no key",
803
+ state: t.added ? "added" : t.reason ? `cannot be added as it is: ${t.reason}` : null };
804
+ }
569
805
  /**
570
- * THE PROJECT'S DATABASE'S TABLES, READ BY THE COMMAND ITSELF (the hands-on review of #451, item 15: `mason tables`
571
- * answered "Mason is not running", though it reads only Postgres): the database's catalog (preflight.ts
572
- * readSourceFacts, what mason start reads), each table as the run's state says it against the sources file
573
- * (sources.ts catalogTable). A file that cannot be read, or a database that does not answer, is said with why and the way out.
806
+ * THE PROJECT'S DATABASE'S CATALOG, READ BY THE COMMAND ITSELF (the hands-on review of #451, item 15: `mason tables`
807
+ * answered "Mason is not running", though it reads only Postgres): the sources file as the run reads it, and the
808
+ * database's catalog (preflight.ts readSourceFacts, what mason start reads) — what `mason tables` lists, and what `mason
809
+ * add` and `mason remove` check an edit against while Mason is down. A file that cannot be read, or a database that does
810
+ * not answer, is said with why and the way out.
574
811
  */
575
- async function databaseTables(p) {
812
+ async function readCatalog(p) {
576
813
  const src = projectSource(p);
577
814
  if ("message" in src)
578
815
  return src;
816
+ const demo = p.config.demo === true;
579
817
  const fix = `change the URL where it is (${src.from}) when the database's password or host changed`;
580
818
  const parsed = parseSource(src.resolved);
581
819
  if ("problem" in parsed)
582
820
  return { message: `the database's URL (${src.from}): ${parsed.problem}`, code: "bad_source", fix };
583
- const connected = await connectSource(parsed.source);
821
+ const connected = await connectSource(parsed.source, { demo });
584
822
  if ("failure" in connected) {
823
+ // the demo database's way out is its run (src/cli/demo.ts): its hint is the fix, mason start the next command
824
+ if (demo) {
825
+ return { message: `could not connect to ${maskedUrl(parsed.source.url)}: ${connected.failure.message}`, code: "unreachable",
826
+ fix: connected.failure.hints.join("; ") || "mason start hosts the demo database", next: [["mason start", "the demo database and Mason, in the background"]] };
827
+ }
585
828
  return { message: `could not connect to ${maskedUrl(parsed.source.url)}: ${connected.failure.message}`, code: "unreachable",
586
829
  ...(connected.failure.hints.length ? { why: connected.failure.hints.join("; ") } : {}), fix,
587
830
  next: [["mason doctor", "checks the machine and the project's database"]] };
@@ -598,44 +841,122 @@ async function databaseTables(p) {
598
841
  finally {
599
842
  connected.pg.close();
600
843
  }
601
- return { database: parsed.source.database, name: src.name, file: src.file, tables: facts.tables.map((t) => catalogTable(t, src.tables.find((e) => e.table === t.source))) };
844
+ return { src, database: parsed.source.database, facts: facts.tables };
845
+ }
846
+ /** `mason tables` without the run: the database's tables, each as the run's state says it against the sources file
847
+ * (sources.ts catalogTable). */
848
+ async function databaseTables(p) {
849
+ const c = await readCatalog(p);
850
+ if ("message" in c)
851
+ return c;
852
+ return { database: c.database, name: c.src.name, file: c.src.file, tables: c.facts.map((t) => catalogTable(t, c.src.tables.find((e) => e.table === t.source))) };
853
+ }
854
+ /**
855
+ * MASON DOWN, `mason add` AND `mason remove` EDIT THE FILE THEMSELVES (the file is the list: `mason init && mason add
856
+ * orders && mason start` is a natural order, and `mason init demo` already writes entries before any run) — through
857
+ * the same check-and-write the run's command server calls (src/cli/source-edit.ts), against the catalog the command
858
+ * reads itself (`readCatalog`), so a refusal is worded alike from both. A run alive and silent (hung) still holds the
859
+ * file: refused, with mason stop as the way out, as is a run that came up since the command looked. Null once the
860
+ * failure is said.
861
+ */
862
+ async function withoutRun(cmd, p, json) {
863
+ const st = await runState(p);
864
+ // a hung run is Mason running and not answering: FAILURE_CODES' unreachable
865
+ if (st.kind === "hung") {
866
+ failed(cmd, { ...asFailure(notAnswering(p, st.run)), code: "unreachable" }, { json });
867
+ return null;
868
+ }
869
+ if (st.kind === "up") {
870
+ failed(cmd, { message: `Mason started for ${p.config.name} while mason ${cmd} ran`, code: "refused", fix: `run mason ${cmd} again: it asks the run, which syncs at once` }, { json });
871
+ return null;
872
+ }
873
+ const c = await readCatalog(p);
874
+ if ("message" in c) {
875
+ failed(cmd, c, { json });
876
+ return null;
877
+ }
878
+ return { db: { tables: c.facts, database: c.database },
879
+ tables: c.facts.map((t) => catalogTable(t, c.src.tables.find((e) => e.table === t.source))), file: c.src.file,
880
+ edit: () => sourceToEdit(p.folder, p.root, () => projectSource(p)) };
881
+ }
882
+ function edited(f) {
883
+ try {
884
+ return { ok: f() };
885
+ }
886
+ catch (e) {
887
+ // a refusal is the edit's answer, said as the run's is; anything else is a bug, and goes on up
888
+ if (e instanceof Refusal)
889
+ return { status: e.status, error: e.message };
890
+ throw e;
891
+ }
892
+ }
893
+ /** The run's command server's answer, as an edit's (`Edited`): what `/api/pick` or `/api/remove` answered. */
894
+ async function viaRun(run, route, body) {
895
+ const r = await callRun(run, "POST", route, body);
896
+ return r.status === 200 ? { ok: r.body } : { status: r.status, error: String(r.body.error) };
602
897
  }
603
898
  // ------------------------------------------------------------------ mason add
604
- const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--include <column>,…] [--key <column>,…] [--json]
899
+ const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--include <column>,…] [--key <column>,…] [--sync <sync>] [--json]
605
900
 
606
- Copies tables into Mason: each one is added to the project's sources file (mason/sources/<name>.yml), under tables:
901
+ Syncs tables into Mason: each one is added to the project's sources file (mason/sources/<name>.yml), under tables:
607
902
  mason add orders every column: orders: {}
608
903
  mason add orders --columns id,status,total those columns: orders: { columns: [id, status, total] }
609
904
  mason add orders customers several tables, every column of each
610
905
  mason add users --include password_hash every column, and one that looks like a secret
611
- mason add event_log --key tenant_id,id a table with no primary key, copied by those columns
612
- A table is its name (orders) or schema.table (billing.invoices). Its primary key is always copied. The rest of the
906
+ mason add event_log --key tenant_id,id a table with no primary key, keyed by those columns
907
+ mason add orders --sync "poll 30s" polled every 30 seconds: orders: { sync: poll 30s }
908
+ A table is its name (orders) or schema.table (billing.invoices). Its primary key is always synced. The rest of the
613
909
  file — your comments, other tables — stays as it was.
614
910
 
911
+ After its first sync, a table is kept current as the file's sync: says — at the top for every table (mason init
912
+ writes sync: off: read again only by mason sync <table>), or on its own entry. --sync writes the table's own:
913
+ off, poll <interval> (poll 5s, poll 5m, poll 1h; 1s to 24h), or replication (the database's change log, through
914
+ the project's slot). On a table already added, --sync alone changes its sync: line and nothing else; its synced data is
915
+ kept and caught up by one poll. Without --sync a new entry follows the file's — but a table of more than
916
+ ${BIG_TABLE_ROWS.toLocaleString("en-US")} rows under a file that polls is written sync: off and said, since each poll scans a table whole on your
917
+ database, and a table replication cannot keep (no primary key: --key; REPLICA IDENTITY NOTHING) under a file whose
918
+ sync: is replication is written sync: poll 5m and said. A table of tens of millions of rows set to poll more often
919
+ than every minute is warned of, with an estimate of what each poll costs your database.
920
+
615
921
  A column that looks like a secret (password, token, api_key, private_key, …) is left out unless named, and said:
616
- it is written as exclude: [password_hash], since anyone with a user's password can read every copied column. Name
922
+ it is written as exclude: [password_hash], since anyone with a user's password can read every synced column. Name
617
923
  one with --columns or --include to have it.
618
924
 
619
- The copy starts at once; mason wait returns when it is done and the table is served. Adding a table again with
620
- other columns changes its entry, and the table is copied again with them.
925
+ With Mason running the first sync starts at once; mason wait returns when it is done and the table is served. Before
926
+ mason start (mason init && mason add orders && mason start) the entry is written, checked against the database as
927
+ the run checks it, and the first sync begins at mason start. Adding a table again with other columns changes its entry,
928
+ and the table is synced again whole with them.
621
929
 
622
930
  --columns <a,b,…> only these columns (one table at a time: the list is that table's)
623
931
  --include <a,b,…> these columns as well as every other one — a column that looks like a secret, named on purpose
624
- --key <a,b,…> for a table with no primary key: the columns that tell its rows apart, which it is copied by (one
625
- table at a time; polling only: logical replication cannot apply a change by them)
932
+ --key <a,b,…> for a table with no primary key: the columns that tell its rows apart, which it is keyed by (one
933
+ table at a time; polled or off: logical replication cannot apply a change by them)
934
+ --sync <sync> how it is kept current after its first sync, written on its entry: off, "poll 30s" or replication
626
935
  --json as data: { added: [{ table, entry, columns (null: every one), exclude, key, addedKey (key columns
627
- added to --columns), skippedSecrets, already (the file said so already) } or { table, error }],
936
+ added to --columns), skippedSecrets, already (the file said so already), sync: { value (off, poll
937
+ 30s), from (entry: its own sync:, file: the file's, default: off, as the file writes none) },
938
+ bigOff (it is over ${BIG_TABLE_ROWS.toLocaleString("en-US")} rows and its entry was written sync: off), notReplicated (why
939
+ its entry was written sync: poll 5m under a file whose sync: is replication, or null), pollCost (an estimate of
940
+ what a frequent poll of a big table costs your database, or null) } or { table, error }],
628
941
  file (the sources file), error (when a table was not added: { code, message, why }), next };
629
942
  exit 1 when a table could not be added
630
943
  `;
631
944
  export const ADD = {
632
- needs: "run",
945
+ // the project, not the run: the sources file is the list, so a table is added before mason start as well as after
946
+ needs: "project",
633
947
  name: "add",
634
- summary: "copy tables into Mason, adding them to the sources file: mason add orders (--columns id,status: those only)",
948
+ summary: "sync tables into Mason, adding them to the sources file: mason add orders (--columns id,status: those only)",
635
949
  usage: ADD_USAGE,
636
- options: { json: { type: "boolean" }, columns: { type: "string" }, include: { type: "string" }, key: { type: "string" } },
950
+ options: { json: { type: "boolean" }, columns: { type: "string" }, include: { type: "string" }, key: { type: "string" }, sync: { type: "string" } },
637
951
  json: ["added", "file", "error", "next"],
638
952
  };
953
+ /** `--sync`, read by the sources file's own grammar (src/lib/table-sync.ts parseSync): a sync, or the usage's problem */
954
+ export function addSync(v) {
955
+ if (v === undefined)
956
+ return undefined;
957
+ const s = parseSync(v);
958
+ return "problem" in s ? `--sync ${JSON.stringify(v)}: ${s.problem}` : s;
959
+ }
639
960
  /**
640
961
  * The tables and the columns each one's entry copies (add takes --columns, not a DSL):
641
962
  * `["*"]` for every column, the --columns list for one table, and --include's columns on top of either; `key`, --key's
@@ -672,94 +993,146 @@ export async function add(argv) {
672
993
  const specs = addSpecs(f.positionals, f.values.columns, f.values.include, f.values.key);
673
994
  if (typeof specs === "string")
674
995
  return failed("add", { message: specs, code: "usage", usage: ADD_USAGE }, { json });
675
- const run = await runOr(ADD, json);
676
- if (!run)
996
+ const sync = addSync(f.values.sync);
997
+ if (typeof sync === "string")
998
+ return failed("add", { message: sync, code: "usage", usage: ADD_USAGE }, { json });
999
+ // --sync alone: a table already added keeps its columns and key, and only its sync: line is written
1000
+ const keep = sync !== undefined && f.values.columns === undefined && f.values.include === undefined && f.values.key === undefined;
1001
+ const how = { ...(sync !== undefined ? { sync } : {}), keep };
1002
+ const where = await reach(ADD, json);
1003
+ // (a state with no project never meets the need: reach said it)
1004
+ if (where === null || where.project === null)
677
1005
  return 1;
678
- return guarded("add", json, async () => {
679
- const s = await readState(run);
680
- const results = [];
681
- const added = [];
682
- let file = s.source.file ?? null;
683
- // the tables that could not be added, each with its reason and code: the command's failure, in the failure shape
684
- const refused = [];
685
- // A CHECKLIST, a line per table (it reads best as a list of completed actions): a spinner
686
- // while it is added, ✔ with what was written, its notes under it; ✘ with the reason when it could not be
687
- const st = styleOut();
688
- const steps = stepRenderer({ line: out, live: !json && process.stdout.isTTY ? { write: (t) => { process.stdout.write(t); } } : null, style: st });
689
- const note = (text) => { if (!json)
690
- steps.print(st.dim(` ${wrap(text, st.width, 4)}`)); };
691
- const shown = (p) => { const rel = path.relative(process.cwd(), p); return rel.startsWith("..") ? p : rel; };
692
- for (const spec of specs) {
693
- const t = findTable(s.tables, spec.table);
694
- // a table the database lacks is a failed line of the checklist, its reason under it, like a table Mason refused
695
- if (!("table" in t)) {
696
- refused.push({ table: spec.table, message: t.message, code: t.code });
697
- results.push({ table: spec.table, error: t.message });
698
- if (!json) {
699
- steps.begin(`Adding ${spec.table}`);
700
- steps.fail(`Adding ${spec.table}`);
701
- note(`${t.message}${t.fix ? `: ${t.fix}` : ""}${t.next?.length ? ` (${t.next[0][0]})` : ""}`);
702
- }
703
- continue;
704
- }
705
- if (!json)
706
- steps.begin(`Adding ${t.table}`);
707
- const r = await callRun(run, "POST", "/api/pick", { table: t.table, picked: true, columns: spec.columns, ...(spec.key ? { key: spec.key } : {}) });
708
- if (r.status !== 200) {
709
- refused.push({ table: t.table, message: String(r.body.error), code: r.status === 404 ? "not_found" : "refused" });
710
- results.push({ table: t.table, error: String(r.body.error) });
711
- if (!json) {
712
- steps.fail(`Adding ${t.table}`);
713
- note(String(r.body.error));
714
- }
715
- continue;
1006
+ if (where.state === "up") {
1007
+ // Mason up: the run adds it (the copy starts now, and the run's own re-read never races the file's write)
1008
+ const run = where.run;
1009
+ return guarded("add", json, async () => {
1010
+ const s = await readState(run);
1011
+ return addTables({ tables: s.tables, file: s.source.file ?? null,
1012
+ pick: (table, columns, key) => viaRun(run, "/api/pick", { table, picked: true, columns, ...(key ? { key } : {}), ...(sync ? { sync: syncText(sync) } : {}), keep }),
1013
+ // --sync alone on tables already added: no copy starts (each is caught up by one poll), so wait has nothing to wait for
1014
+ next: (n, wereAdded) => keep && wereAdded === n
1015
+ ? [["mason status", `${n === 1 ? "its sync and when it was last synced: it is" : "their sync and when each was last synced: each is"} caught up by one poll, never synced again whole`], READY_STEPS[0]]
1016
+ : [["mason wait", `until ${n === 1 ? "its first sync finishes" : "their first syncs finish"} (the sync has started)`], READY_STEPS[0]] }, specs, json);
1017
+ });
1018
+ }
1019
+ // Mason down: the command writes the entry itself, checked as the run checks it; the copy begins at mason start
1020
+ const down = await withoutRun("add", where.project, json);
1021
+ if (down === null)
1022
+ return 1;
1023
+ return addTables({ tables: down.tables, file: down.file,
1024
+ pick: async (table, columns, key) => edited(() => addEntry(down.edit(), down.db, table, columns, key, how)),
1025
+ next: () => [["mason start", "Mason in the background, for this project: the first sync of every added table begins"],
1026
+ ["mason wait", "then, until every added table is synced and served"]] }, specs, json);
1027
+ }
1028
+ /** `mason add`'s checklist and answer, the same whichever way the tables are added (`AddWay`). */
1029
+ async function addTables(way, specs, json) {
1030
+ const results = [];
1031
+ const added = [];
1032
+ let wereAdded = 0;
1033
+ let file = way.file;
1034
+ // the tables that could not be added, each with its reason and code: the command's failure, in the failure shape
1035
+ const refused = [];
1036
+ // A CHECKLIST, a line per table (it reads best as a list of completed actions): a spinner
1037
+ // while it is added, ✔ with what was written, its notes under it; ✘ with the reason when it could not be
1038
+ const st = styleOut();
1039
+ const steps = stepRenderer({ line: out, live: !json && process.stdout.isTTY ? { write: (t) => { process.stdout.write(t); } } : null, style: st });
1040
+ const note = (text) => { if (!json)
1041
+ steps.print(st.dim(` ${wrap(text, st.width, 4)}`)); };
1042
+ const shown = (p) => { const rel = path.relative(process.cwd(), p); return rel.startsWith("..") ? p : rel; };
1043
+ for (const spec of specs) {
1044
+ const t = findTable(way.tables, spec.table);
1045
+ // a table the database lacks is a failed line of the checklist, its reason under it, like a table Mason refused
1046
+ if (!("table" in t)) {
1047
+ refused.push({ table: spec.table, message: t.message, code: t.code });
1048
+ results.push({ table: spec.table, error: t.message });
1049
+ if (!json) {
1050
+ steps.begin(`Adding ${spec.table}`);
1051
+ steps.fail(`Adding ${spec.table}`);
1052
+ note(`${t.message}${t.fix ? `: ${t.fix}` : ""}${t.next?.length ? ` (${t.next[0][0]})` : ""}`);
716
1053
  }
717
- const b = r.body;
718
- file = b.file;
719
- results.push({ table: b.table, entry: b.entry, columns: b.columns, exclude: b.exclude, key: b.key, addedKey: b.addedKey, skippedSecrets: b.skippedSecrets, already: b.already });
720
- added.push(b.table);
721
- if (json)
722
- continue;
723
- const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
724
- steps.done(`Added ${t.table} to ${shown(b.file)}: ${copies}${b.key ? `, copied by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`);
725
- if (b.addedKey.length)
726
- note(`key added: ${b.addedKey.join(", ")} (a copy always has its key)`);
727
- // a secret is readable by anyone with a user's password: left out, and said, with the way to include one
728
- if (b.skippedSecrets.length)
729
- note(`left out ${b.skippedSecrets.join(", ")}: ${b.skippedSecrets.length === 1 ? "it looks" : "they look"} like ${b.skippedSecrets.length === 1 ? "a secret" : "secrets"}, ` +
730
- `which anyone with a user's password could read (--include ${b.skippedSecrets[0]} adds it)`);
1054
+ continue;
731
1055
  }
732
- const next = added.length ? [
733
- ["mason wait", `until ${added.length === 1 ? "its copy finishes" : "their copies finish"} (the copy has started)`],
734
- READY_STEPS[0],
735
- ] : [];
736
- if (refused.length) {
737
- // A TABLE NOT ADDED IS THE COMMAND'S FAILURE, in the shape every failure has (found by the CLI session test of the private repo:
738
- // exit 1 with no error, and nothing on stderr): what is wrong, each table's reason, and next — the added ones' too
739
- const f = {
740
- message: specs.length === 1 ? `${refused[0].table} was not added` : `${refused.length} of ${specs.length} tables were not added: ${refused.map((x) => x.table).join(", ")}`,
741
- code: refused.every((x) => x.code === "not_found") ? "not_found" : refused.every((x) => x.code === "usage") ? "usage" : "refused",
742
- why: refused.map((x) => specs.length === 1 ? x.message : `${x.table}: ${x.message}`).join("; "),
743
- next: [...next, ["mason tables", "the database's tables, as this user reads them"]],
744
- };
745
- if (json) {
746
- out(jsonText(ADD, { added: results, file, error: errorJson(f) }, f.next));
747
- return 1;
1056
+ if (!json)
1057
+ steps.begin(`Adding ${t.table}`);
1058
+ const r = await way.pick(t.table, spec.columns, spec.key);
1059
+ if (!("ok" in r)) {
1060
+ refused.push({ table: t.table, message: r.error, code: r.status === 404 ? "not_found" : "refused" });
1061
+ results.push({ table: t.table, error: r.error });
1062
+ if (!json) {
1063
+ steps.fail(`Adding ${t.table}`);
1064
+ note(r.error);
748
1065
  }
749
- return failed("add", f, { json: false });
1066
+ continue;
750
1067
  }
1068
+ const b = r.ok;
1069
+ file = b.file;
1070
+ results.push({ table: b.table, entry: b.entry, columns: b.columns, exclude: b.exclude, key: b.key, addedKey: b.addedKey, skippedSecrets: b.skippedSecrets, already: b.already,
1071
+ sync: b.sync, bigOff: b.bigOff, notReplicated: b.notReplicated, pollCost: b.pollCost });
1072
+ added.push(b.table);
1073
+ if (t.added)
1074
+ wereAdded++;
751
1075
  if (json)
752
- out(jsonText(ADD, { added: results, file }, next));
753
- else
754
- out(endText({ next }, st));
755
- return 0;
756
- });
1076
+ continue;
1077
+ const l = addedLines(b, shown(b.file));
1078
+ steps.done(l.line);
1079
+ for (const x of l.notes)
1080
+ note(x);
1081
+ }
1082
+ const next = added.length ? way.next(added.length, wereAdded) : [];
1083
+ if (refused.length) {
1084
+ // A TABLE NOT ADDED IS THE COMMAND'S FAILURE, in the shape every failure has (found by the CLI session test of the private repo:
1085
+ // exit 1 with no error, and nothing on stderr): what is wrong, each table's reason, and next — the added ones' too
1086
+ const f = {
1087
+ message: specs.length === 1 ? `${refused[0].table} was not added` : `${refused.length} of ${specs.length} tables were not added: ${refused.map((x) => x.table).join(", ")}`,
1088
+ code: refused.every((x) => x.code === "not_found") ? "not_found" : refused.every((x) => x.code === "usage") ? "usage" : "refused",
1089
+ why: refused.map((x) => specs.length === 1 ? x.message : `${x.table}: ${x.message}`).join("; "),
1090
+ next: [...next, ["mason tables", "the database's tables, as this user reads them"]],
1091
+ };
1092
+ if (json) {
1093
+ out(jsonText(ADD, { added: results, file, error: errorJson(f) }, f.next));
1094
+ return 1;
1095
+ }
1096
+ return failed("add", f, { json: false });
1097
+ }
1098
+ if (json)
1099
+ out(jsonText(ADD, { added: results, file }, next));
1100
+ else
1101
+ out(endText({ next }, st));
1102
+ return 0;
1103
+ }
1104
+ /** What `mason add` prints for a table added: its line (✔ …), then its notes under it (the web page says the same,
1105
+ * src/web/pages/tables.js; tests/web-page.test.ts holds the two equal). `file` is the sources file as the reader is shown it. */
1106
+ export function addedLines(b, file) {
1107
+ const copies = b.columns === null ? (b.exclude.length ? `every column but ${b.exclude.join(", ")}` : "every column") : b.columns.join(", ");
1108
+ const s = b.skippedSecrets;
1109
+ const name = tableArg(b.table);
1110
+ // HOW IT IS KEPT CURRENT, always said (issue 33: the default is never met by surprise): the sync: it follows, where it
1111
+ // is said, and the one line that changes it — a big table written off, with the alternatives
1112
+ const sync = b.bigOff ? `sync: off written on its entry: it has more than ${BIG_TABLE_ROWS.toLocaleString("en-US")} rows, and each poll scans it whole on your database — ` +
1113
+ `mason add ${name} --sync "poll 1h" polls it hourly; mason sync ${name} syncs it once, now; mason add ${name} --sync replication keeps it current from the change log`
1114
+ : b.notReplicated !== null ? `sync: poll 5m written on its entry, not the file's replication: ${b.notReplicated} — ` +
1115
+ `mason add ${name} --sync "poll 1h" polls it hourly; mason add ${name} --sync off reads it only by mason sync ${name}`
1116
+ : b.sync.value === "off" ? `sync: off (${b.sync.from === "entry" ? "its entry's" : b.sync.from === "file" ? "the file's" : "the default"}): after its first sync it is read again only by mason sync ${name} — ` +
1117
+ `mason add ${name} --sync "poll 5m" polls it every 5 minutes`
1118
+ : b.sync.value === "replication" ? `sync: replication (${b.sync.from === "entry" ? "its entry's" : "the file's"}): kept current from the database's change log after its first sync — ` +
1119
+ `mason add ${name} --sync "poll 5m" polls it instead`
1120
+ : `sync: ${b.sync.value} (${b.sync.from === "entry" ? "its entry's" : "the file's"}): polled every ${b.sync.value.slice("poll ".length)} after its first sync — mason add ${name} --sync off stops that`;
1121
+ return { line: `Added ${b.table} to ${file}: ${copies}${b.key ? `, keyed by ${b.key.join(", ")}` : ""}${b.already ? " (already so)" : ""}`, notes: [
1122
+ sync,
1123
+ ...(b.pollCost !== null ? [b.pollCost] : []),
1124
+ ...(b.addedKey.length ? [`key added: ${b.addedKey.join(", ")} (a synced table always has its key)`] : []),
1125
+ // a secret is readable by anyone with a user's password: left out, and said, with the way to include one
1126
+ ...(s.length ? [`left out ${s.join(", ")}: ${s.length === 1 ? "it looks" : "they look"} like ${s.length === 1 ? "a secret" : "secrets"}, ` +
1127
+ `which anyone with a user's password could read (--include ${s[0]} adds it)`] : []),
1128
+ ] };
757
1129
  }
758
1130
  // ------------------------------------------------------------------ mason remove
759
1131
  const REMOVE_USAGE = `usage: mason remove <table>… [--json]
760
1132
 
761
1133
  Takes tables out of the project: each one's entry in the sources file (mason/sources/<name>.yml) is removed, the
762
- rest of the file as it was. Mason then stops copying it and drops its copy.
1134
+ rest of the file as it was. With Mason running it stops syncing the table and drops its synced data at once; with Mason
1135
+ stopped, what an earlier run synced is dropped at the next mason start.
763
1136
 
764
1137
  mason remove customers
765
1138
  mason remove customers billing.invoices several at once
@@ -767,9 +1140,10 @@ const REMOVE_USAGE = `usage: mason remove <table>… [--json]
767
1140
  --json as data: { removed (the tables, schema.table), file (the sources file), next }
768
1141
  `;
769
1142
  export const REMOVE = {
770
- needs: "run",
1143
+ // the project, not the run, as mason add
1144
+ needs: "project",
771
1145
  name: "remove",
772
- summary: "take tables out of the sources file: mason remove customers (its copy is dropped)",
1146
+ summary: "take tables out of the sources file: mason remove customers (its synced data is dropped)",
773
1147
  usage: REMOVE_USAGE,
774
1148
  options: { json: { type: "boolean" } },
775
1149
  json: ["removed", "file", "next"],
@@ -782,51 +1156,77 @@ export async function remove(argv) {
782
1156
  const names = [...new Set(f.positionals)];
783
1157
  if (!names.length)
784
1158
  return failed("remove", { message: "name a table: mason remove customers (mason status lists the added ones)", code: "usage", usage: REMOVE_USAGE }, { json });
785
- const run = await runOr(REMOVE, json);
786
- if (!run)
1159
+ const where = await reach(REMOVE, json);
1160
+ // (a state with no project never meets the need: reach said it)
1161
+ if (where === null || where.project === null)
787
1162
  return 1;
788
- return guarded("remove", json, async () => {
789
- // the run's own removal (src/cli/run-server.ts removeTables): one path, so nothing can disagree with it
790
- const r = await callRun(run, "POST", "/api/remove", { tables: names });
791
- if (r.status !== 200) {
792
- const message = String(r.body.error);
793
- return failed("remove", { message: `${names.join(", ")} ${names.length === 1 ? "was" : "were"} not removed`, code: r.status === 404 ? "not_found" : r.status === 400 ? "usage" : "refused", why: message,
794
- next: [["mason status", "the tables added"]] }, { json });
795
- }
796
- const b = r.body;
797
- const next = [["mason status", "the tables left"]];
798
- if (json) {
799
- out(jsonText(REMOVE, { removed: b.removed, file: b.file }, next));
800
- return 0;
801
- }
802
- const rel = path.relative(process.cwd(), b.file);
803
- out(reportText({ headline: `Removed ${b.removed.join(", ")}`, details: [["from", rel.startsWith("..") ? b.file : rel]],
804
- tips: [`Mason stops copying ${b.removed.length === 1 ? "it" : "them"} and drops ${b.removed.length === 1 ? "its copy" : "their copies"}`], next }, styleOut()));
1163
+ if (where.state === "up") {
1164
+ // Mason up: the run removes it and drops the copy at once (src/cli/run-server.ts removeTables)
1165
+ const run = where.run;
1166
+ return guarded("remove", json, async () => removedAnswer(await viaRun(run, "/api/remove", { tables: names }), names, true, json));
1167
+ }
1168
+ // Mason down: the command cuts the entries itself, checked as the run checks them (src/cli/source-edit.ts removeEntries)
1169
+ const down = await withoutRun("remove", where.project, json);
1170
+ if (down === null)
1171
+ return 1;
1172
+ return removedAnswer(edited(() => removeEntries(down.edit(), down.db, names)), names, false, json);
1173
+ }
1174
+ /** `mason remove`'s answer, the same whichever way the entries were cut; `running`: Mason is up, and dropped the copies. */
1175
+ function removedAnswer(r, names, running, json) {
1176
+ if (!("ok" in r)) {
1177
+ return failed("remove", { message: `${names.join(", ")} ${names.length === 1 ? "was" : "were"} not removed`, code: r.status === 404 ? "not_found" : r.status === 400 ? "usage" : "refused", why: r.error,
1178
+ next: [["mason status", "the tables added"]] }, { json });
1179
+ }
1180
+ const b = r.ok;
1181
+ const next = running ? [["mason status", "the tables left"]]
1182
+ : [["mason start", "Mason in the background, for this project: it syncs the tables left"], ["mason status", "the tables left"]];
1183
+ if (json) {
1184
+ out(jsonText(REMOVE, { removed: b.removed, file: b.file }, next));
805
1185
  return 0;
806
- });
1186
+ }
1187
+ const rel = path.relative(process.cwd(), b.file);
1188
+ out(reportText({ ...removedReport(b.removed, rel.startsWith("..") ? b.file : rel, { running }), next }, styleOut()));
1189
+ return 0;
1190
+ }
1191
+ /** What `mason remove` prints for the tables removed, `file` the sources file as the reader is shown it (the web page
1192
+ * says the same, src/web/app.js; tests/web-page.test.ts holds the two equal). */
1193
+ export function removedReport(removed, file, o = {}) {
1194
+ const one = removed.length === 1;
1195
+ return { headline: `Removed ${removed.join(", ")}`, details: [["from", file]],
1196
+ // Mason down (the web page always has a run): a copy an earlier run made is dropped by the next start's sync
1197
+ tips: [o.running === false ? `what an earlier run synced of ${one ? "it" : "them"} is dropped at the next mason start`
1198
+ : `Mason stops syncing ${one ? "it" : "them"} and drops ${one ? "its synced data" : "their synced data"}`] };
807
1199
  }
808
1200
  // ------------------------------------------------------------------ mason wait
1201
+ /** An added table as mason wait --json says it: its copy, its error, and how it is kept current, as the run's state has
1202
+ * them (mason status --json's own fields, never derived again here) */
1203
+ export function waitTable(t) {
1204
+ return { table: t.table, copy: t.copy, error: t.error, ...(t.kept ? { kept: t.kept } : {}) };
1205
+ }
809
1206
  const WAIT_USAGE = `usage: mason wait [--timeout <seconds>] [--json]
810
1207
 
811
- Waits until every added table is copied and served, showing every added table on a line of its own: 100% for the
812
- finished (in the order they finished), a bar for the ones still copying, at the bottom. Run it after mason add.
1208
+ Waits until every added table is synced and served, showing every added table on a line of its own: 100% for the
1209
+ finished (in the order they finished), a bar for the ones still syncing, at the bottom. Run it after mason add.
813
1210
 
814
1211
  mason add orders && mason wait
815
1212
  mason wait --timeout 3600 a large table: wait up to an hour
816
1213
 
817
- --timeout <seconds> how long to wait (default 600); the copy goes on after, and mason wait again picks it up
818
- --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error }]
819
- (copy: { phase, rowsCopied, progress: the chunks done, reading and for how long, the rate, the
820
- seconds left, while its chunks are read }),
1214
+ --timeout <seconds> how long to wait (default 600); the sync goes on after, and mason wait again picks it up
1215
+ --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error,
1216
+ kept }] (copy: { phase, rowsCopied, progress: the chunks done, reading and for how long, the
1217
+ rate, the seconds left, while its chunks are read }; kept as mason status --json says it —
1218
+ { by: poll, sync: { mode: off } or { mode: poll, every, seconds }, syncedAt (its last whole read
1219
+ or poll), nextAt (its next poll; null: off) }; kept's pending, when it applies, says why
1220
+ it is not kept current now, its phase included),
821
1221
  stream (replication mode: where the stream is, as mason status --json says it), error (on a
822
1222
  failure or a timeout: { code, message, next }), next }
823
1223
 
824
- Exit status: 0 ready; 1 a copy failed (printed, with what to do; Mason tries it again by itself); 124 timed out.
1224
+ Exit status: 0 ready; 1 a first sync failed (printed, with what to do; Mason tries it again by itself); 124 timed out.
825
1225
  `;
826
1226
  export const WAIT = {
827
1227
  needs: "run",
828
1228
  name: "wait",
829
- summary: "wait until every added table is copied and served (exit 0; 1 on a failure, with why)",
1229
+ summary: "wait until every added table is synced and served (exit 0; 1 on a failure, with why)",
830
1230
  usage: WAIT_USAGE,
831
1231
  options: { json: { type: "boolean" }, timeout: { type: "string" } },
832
1232
  json: ["ready", "failed", "waiting", "timedOut", "tables", "stream", "error", "next"],
@@ -881,24 +1281,24 @@ export async function wait(argv) {
881
1281
  // the outcome and what to run next, the same for a person and under --json
882
1282
  const readyNext = picked.length ? [...READY_STEPS] : addSteps(s.tables);
883
1283
  const failure = r.failed.length
884
- ? { message: r.failed.length === 1 ? r.failed[0] : `${r.failed.length} copies failed`, code: "failed", why: r.failed.length === 1 ? undefined : r.failed.join("\n"),
885
- fix: "Mason tries a failed copy again by itself; mason logs says why it failed and when it tries next",
1284
+ ? { message: r.failed.length === 1 ? r.failed[0] : `${r.failed.length} first syncs failed`, code: "failed", why: r.failed.length === 1 ? undefined : r.failed.join("\n"),
1285
+ fix: "Mason tries a failed first sync again by itself; mason logs says why it failed and when it tries next",
886
1286
  next: [["mason logs", "the detail of each failure"], ["mason status", "the rest of the project"]] }
887
1287
  : !done && Date.now() >= until
888
- ? { message: `not ready after ${timeout} s`, code: "timeout", why: r.waiting.join("\n"), fix: "nothing stopped: the copies go on",
1288
+ ? { message: `not ready after ${timeout} s`, code: "timeout", why: r.waiting.join("\n"), fix: "nothing stopped: the syncs go on",
889
1289
  next: [["mason wait", "keep waiting (--timeout <seconds> for longer)"], ["mason status", "what Mason is doing"]] }
890
1290
  : null;
891
1291
  if (json && (done || failure)) {
892
- out(jsonText(WAIT, { ...r, timedOut: !done, tables: picked.map((t) => ({ table: t.table, copy: t.copy, error: t.error, ...(t.unsynced ? { unsynced: t.unsynced } : {}) })),
1292
+ out(jsonText(WAIT, { ...r, timedOut: !done, tables: picked.map(waitTable),
893
1293
  ...(s.stream ? { stream: s.stream } : {}), ...(failure ? { error: errorJson(failure) } : {}) }, failure ? failure.next ?? [] : readyNext));
894
1294
  }
895
1295
  // the block as it ends, too: every table at 100% beside Ready (when the last change was not printed yet)
896
1296
  // while at work: a table already copied can be queried now; the final Next is Ready's, printed once below
897
1297
  const copied = picked.some((t) => t.copy?.phase === "done");
898
- const meanwhile = done ? [] : [...(copied ? [["mason user add <name>", "a user for your BI tool or psql: a copied table can be queried now"]] : []), ["mason status", "what Mason is doing"]];
1298
+ const meanwhile = done ? [] : [...(copied ? [["mason user add <name>", "a user for your BI tool or psql: a synced table can be queried now"]] : []), ["mason status", "what Mason is doing"]];
899
1299
  show([...tableLines(s), ...(s.stream ? ["Stream:", ...streamLines(s.stream).map((l) => ` ${l}`)] : [])], meanwhile); // done: the meanwhile Next is dropped
900
1300
  if (r.ready) {
901
- const head = !picked.length ? "Nothing to wait for: no table added yet." : "Ready: every added table is copied and served.";
1301
+ const head = !picked.length ? "Nothing to wait for: no table added yet." : "Ready: every added table is synced and served.";
902
1302
  if (!json)
903
1303
  out(withEnd(picked.length ? styleOut().green(head) : head, { next: readyNext }));
904
1304
  return 0;
@@ -910,38 +1310,77 @@ export async function wait(argv) {
910
1310
  });
911
1311
  }
912
1312
  // ------------------------------------------------------------------ mason logs
913
- const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [--failed]] [--json]
1313
+ /** What the sync's events leave out, said where they are listed (mason logs --help, the page's Sync events, held equal
1314
+ * by tests/web-page.test.ts): a scheduled poll that succeeds is no event, so a person who synced reads where it is. */
1315
+ export const EVENTS_LEAVE_OUT = "A scheduled poll that succeeds is not listed: the last one's result is each table's last sync (mason status, the Tables page's Last sync).";
1316
+ const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [<filters>]] [--events [--failed]] [--running] [--json]
914
1317
 
915
- The last lines of Mason's log: every copy, poll and statement it ran, warnings and errors with their reasons —
1318
+ The last lines of Mason's log: every sync, poll and statement it ran, warnings and errors with their reasons —
916
1319
  running or not. With --requests, the run's request log instead: every statement a tool sent the
917
- Postgres API that Mason answered, and every one it refused or failed, a line each, newest last — when, the outcome
918
- and SQLSTATE, how long, the rows sent, the client's application_name (and the mason user's name), the statement and
919
- the error's first line. It is kept MASON_REQUEST_LOG_DAYS days (default 7) in the run's copy, and read through the
920
- running Mason.
1320
+ Postgres API that Mason answered, and every one it refused, failed or cancelled, a line each, newest last — when, the
1321
+ outcome and SQLSTATE, how long, the rows sent, the client's application_name (and the mason user's name), the
1322
+ statement and the error's first line; the filters narrow it, and a page that has older rows ends with the line that
1323
+ reads them (--before). With --events, the sync's events: each first sync that failed or finished, each poll, catch-up or
1324
+ stream that failed, the database out of reach, a table current again, each sync asked by hand (mason sync, the
1325
+ page's Sync now) and what it did, and each restart of the run — a line each, newest last (a failure repeated word for
1326
+ word is said once an hour). A scheduled poll that succeeds is not listed: the last one's result is each table's last
1327
+ sync (mason status, the Tables page's Last sync). Both are kept MASON_HISTORY_DAYS days
1328
+ (default 7) in the run's synced data, and read through the running Mason. With --running, the statements running now
1329
+ instead, one per session, each with its number (mason cancel <session> --statement <n> stops one).
921
1330
 
922
1331
  mason logs
923
1332
  mason logs -f keep printing new lines as Mason works, until Ctrl-C
924
1333
  mason logs -n 200 --start what mason start printed: its steps, and the warnings and errors since
925
- mason logs --requests --failed -n 20 the last 20 statements refused or failed
1334
+ mason logs --requests --failed -n 20 the last 20 statements refused, failed or cancelled
1335
+ mason logs --requests --since 2h --user metabase --outcome failed,cancelled
1336
+ mason logs --requests --slow --search orders the statements over orders that took a second or more
1337
+ mason logs --events --failed the sync's latest failures
1338
+ mason logs --running the statements running now, and their sessions
926
1339
 
927
- -n, --lines <n> how many lines, or with --requests how many requests (default 40)
1340
+ -n, --lines <n> how many lines, or with --requests or --events how many of them (default 40; 1 to 10000 for each)
928
1341
  -f, --follow keep printing new lines until Ctrl-C
929
1342
  --start mason start's own terminal instead (its steps, then only the warnings and errors)
930
1343
  --requests the Postgres API's requests, from the run's request log (Mason must be running)
931
- --failed with --requests: only the refused and failed ones
932
- --json as data: { lines } (the log's), or with --requests { requests: [{ requestId, startedAt, session,
933
- applicationName, tokenName, user (the mason user), method, outcome (ok, refused, failed), sqlstate, message, detail, hint,
934
- rows, durationMs, query, queryTruncated }], keptDays }; next
1344
+ --events the sync's events and the run's restarts (Mason must be running)
1345
+ --failed with --requests: only the ones not answered (refused, failed or cancelled); with --events: only
1346
+ the failures
1347
+ --outcome <o,…> with --requests: only these outcomes — ok, refused (the statement's own fault), failed (Mason's or
1348
+ the machine's), cancelled (a cancel or a statement timeout, SQLSTATE 57014)
1349
+ --since <time> with --requests: started at or after it — an age (30m, 2h, 7d) or an ISO 8601 date or time with
1350
+ its zone (2026-10-28, 2026-10-28T09:00:00Z)
1351
+ --until <time> with --requests: started before it (as --since)
1352
+ --user <name> with --requests: the mason user's statements
1353
+ --client <name> with --requests: the statements of the client whose application_name it is
1354
+ --slow with --requests: only the ones that took ${(SLOW_MS / 1000).toLocaleString("en-US")} s or more (the slow ones mason status lists)
1355
+ --search <text> with --requests: only the statements whose text has it, in any case (at most ${MAX_SEARCH} characters)
1356
+ --before <pos> with --requests: the page before: the position a page answered with (its last Next line)
1357
+ --running the statements running now (Mason must be running)
1358
+ --json as data: { lines: [{ at, text }] } (the log's entries, oldest first; at is null for a line with no
1359
+ time); with --requests { requests: [{ requestId, startedAt, endedAt, session, applicationName,
1360
+ tokenName, user (the mason user), role, attributes (the user's values its role's row filters read, or
1361
+ null), method, outcome (ok, refused, failed, cancelled), status
1362
+ (completed, failed, cancelled: what a reader shows), sqlstate, message, detail, hint, rows,
1363
+ bytesSent, durationMs, query, queryTruncated }], keptDays, before (the position of the page before,
1364
+ or null: none older) }; with --events { events: [{ at, kind (copy_failed, poll_failed,
1365
+ catch_up_failed, stream_failed, source_unreachable, restart, copied, recovered, synced (a sync
1366
+ asked by hand), sync_changed (a table's sync: changed)), failed, table (null: the whole sync),
1367
+ message }], keptDays }; with --running { running: [{ session, user,
1368
+ applicationName, method, startedAt, runningMs, rows (sent so far), query, queryTruncated }] }; next
935
1369
  `;
936
1370
  export const LOGS = {
937
1371
  needs: "project",
938
1372
  name: "logs",
939
- summary: "Mason's log: every copy, poll and statement (-f to follow it; --requests, every statement the Postgres API met)",
1373
+ summary: "Mason's log (-f follows it); --requests: the Postgres API's statements; --events: the sync's; --running: running now",
940
1374
  usage: LOGS_USAGE,
941
1375
  options: { lines: { type: "string", short: "n" }, follow: { type: "boolean", short: "f" }, start: { type: "boolean" },
942
- requests: { type: "boolean" }, failed: { type: "boolean" }, json: { type: "boolean" } },
943
- json: ["lines", "requests", "keptDays", "next"],
1376
+ requests: { type: "boolean" }, events: { type: "boolean" }, failed: { type: "boolean" }, outcome: { type: "string" }, since: { type: "string" },
1377
+ until: { type: "string" }, user: { type: "string" }, client: { type: "string" }, slow: { type: "boolean" }, search: { type: "string" }, before: { type: "string" }, running: { type: "boolean" }, json: { type: "boolean" } },
1378
+ json: ["lines", "requests", "events", "keptDays", "before", "running", "next"],
944
1379
  };
1380
+ /** the request log's filters as mason logs spells them: -n, --since, … (src/cli/request-filter.ts names each so) */
1381
+ const flagOf = (k) => k === "limit" ? "-n" : `--${k}`;
1382
+ /** the filters of --requests (every one but -n) */
1383
+ const FILTER_FLAGS = ["failed", "outcome", "since", "until", "user", "client", "slow", "search", "before"];
945
1384
  /**
946
1385
  * One request of the run's ledger on one line (src/duck/requests.ts): when, the outcome and SQLSTATE, how long,
947
1386
  * the rows sent, who (application_name, a mason user's name), the statement on one line and the error's first line.
@@ -951,55 +1390,181 @@ export function requestLine(r, st) {
951
1390
  const paint = r.outcome === "ok" ? st.green : r.outcome === "failed" ? st.red : st.yellow;
952
1391
  const rows = r.rows === null ? "" : `${n(r.rows)} row${r.rows === 1 ? "" : "s"}`;
953
1392
  const who = `${r.applicationName || "(no application_name)"}${r.tokenName ? ` (token ${r.tokenName})` : ""}${r.user ? ` (user ${r.user})` : ""}`;
954
- return `${r.startedAt.slice(0, 19).replace("T", " ")} ${paint(word.padEnd(13))} ${`${n(r.durationMs)} ms`.padStart(9)} ${rows.padStart(11)} ${who} ` +
1393
+ return `${r.startedAt.slice(0, 19).replace("T", " ")} ${paint(word.padEnd(15))} ${msText(r.durationMs).padStart(9)} ${rows.padStart(11)} ${who} ` +
955
1394
  `${oneLine(r.query, 120) || "(no statement)"}${r.message ? ` — ${firstLine(r.message)}` : ""}`;
956
1395
  }
1396
+ /** One statement running now on one line (src/api/postgres/server.ts RunningStatement): its session, how long, who, what. */
1397
+ export function runningLine(r) {
1398
+ const who = `${r.applicationName || "(no application_name)"}${r.user ? ` (user ${r.user})` : ""}`;
1399
+ return `session ${String(r.session).padEnd(5)} statement ${String(r.statement).padEnd(6)} ${spanText(r.runningMs / 1000).padStart(8)} ${`${n(r.rows)} row${r.rows === 1 ? "" : "s"} sent`.padStart(14)} ${who} ` +
1400
+ `${oneLine(r.query, 120) || "(no statement)"}`;
1401
+ }
1402
+ /** The mason logs line that reads the same filters again, a page further back (`before`, the page's cursor). */
1403
+ function olderPage(q, before) {
1404
+ const quote = (v) => /^[\w.:,~+-]+$/.test(v) ? v : `'${v.replaceAll("'", "'\\''")}'`;
1405
+ const parts = ["mason logs --requests", ...(q.limit !== DEFAULT_REQUEST_LIMIT ? [`-n ${q.limit}`] : [])];
1406
+ if (q.failed === true)
1407
+ parts.push("--failed");
1408
+ if (q.outcomes !== undefined)
1409
+ parts.push(`--outcome ${q.outcomes.join(",")}`);
1410
+ for (const k of ["since", "until", "user", "client"]) {
1411
+ const v = q[k];
1412
+ if (v !== undefined)
1413
+ parts.push(`--${k} ${quote(v)}`);
1414
+ }
1415
+ if (q.minMs !== undefined)
1416
+ parts.push("--slow");
1417
+ if (q.search !== undefined)
1418
+ parts.push(`--search ${quote(q.search)}`);
1419
+ parts.push(`--before ${quote(before)}`);
1420
+ return parts.join(" ");
1421
+ }
1422
+ /**
1423
+ * WHAT THE REQUEST LOG DOES NOT LIST, said where a first statement is looked for (`mason logs --requests` with none, the
1424
+ * Queries page; src/web/shared.js, held equal by the tests): a catalog read, a SET or a SHOW is the protocol's chatter —
1425
+ * a BI tool sends hundreds as it connects — and is not recorded (src/api/postgres/server.ts `recordRequest`), so a
1426
+ * `select 1` typed to test the connection is answered and never appears.
1427
+ */
1428
+ export const UNLISTED_STATEMENTS = "A statement that reads no synced table (select 1, SET, SHOW, a BI tool's catalog queries) is answered but not listed";
957
1429
  /** mason logs --requests: the run's ledger through its command server (one process holds the copy's file) */
958
- async function requestLogs(limit, failedOnly, json) {
1430
+ async function requestLogs(q, json) {
959
1431
  const r0 = await theRun(process.cwd());
960
1432
  if ("problem" in r0)
961
1433
  return failed("logs", r0, { json });
962
1434
  return guarded("logs", json, async () => {
963
- const reply = await callRun(r0.run, "GET", `/api/requests?limit=${limit}${failedOnly ? "&failed=1" : ""}`);
964
- if (reply.status !== 200) {
965
- return failed("logs", { message: String(reply.body.error ?? `the run answered ${reply.status}`), code: reply.status === 409 ? "refused" : "failed",
966
- next: [["mason status", "the slow and refused queries of the last day"], ["mason logs", "Mason's log"]] }, { json });
967
- }
1435
+ const reply = await callRun(r0.run, "GET", `/api/requests?${requestFilterParams(q).toString()}`);
1436
+ if (reply.status !== 200)
1437
+ return failed("logs", runFailure(reply, [["mason status", "the slow and refused queries of the last day"], ["mason logs", "Mason's log"]]), { json });
968
1438
  const requests = reply.body.requests;
969
1439
  const keptDays = Number(reply.body.keptDays);
970
- const next = failedOnly ? [["mason logs --requests", "every request, answered or not"], ["mason logs", "Mason's log, where each refusal is a line too"]]
971
- : [["mason logs --requests --failed", "only the refused and failed ones"], ["mason status", "the slow and refused ones of the last day, grouped"]];
1440
+ const before = typeof reply.body.before === "string" ? reply.body.before : null;
1441
+ const filtered = q.failed === true || q.outcomes !== undefined || q.since !== undefined || q.until !== undefined || q.user !== undefined || q.client !== undefined ||
1442
+ q.minMs !== undefined || q.search !== undefined;
1443
+ const next = [
1444
+ ...(before !== null ? [[olderPage(q, before), "the page before this one"]] : []),
1445
+ ...(filtered ? [["mason logs --requests", "every request, answered or not"]] : [["mason logs --requests --failed", "only the ones not answered"]]),
1446
+ ["mason status", "the last day's and week's statements, and the slow and refused ones grouped"],
1447
+ ];
972
1448
  if (json) {
973
- out(jsonText(LOGS, { requests, keptDays }, next));
1449
+ out(jsonText(LOGS, { requests, keptDays, before }, next));
974
1450
  return 0;
975
1451
  }
976
1452
  const st = styleOut();
977
1453
  if (!requests.length)
978
- out(`No ${failedOnly ? "refused or failed request" : "request"} in the last ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_REQUEST_LOG_DAYS).`);
1454
+ out(`No ${filtered ? "request that passes the filters" : "request"} in the last ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_HISTORY_DAYS).`);
979
1455
  for (const r of requests)
980
1456
  out(requestLine(r, st));
1457
+ out(endText({ next, ...(!requests.length && !filtered ? { tips: [`${UNLISTED_STATEMENTS}.`] } : {}) }, st));
1458
+ return 0;
1459
+ });
1460
+ }
1461
+ /** mason logs --events: the sync's events and the run's restarts through its command server (src/duck/events.ts) */
1462
+ async function eventLogs(limit, failedOnly, json) {
1463
+ const r0 = await theRun(process.cwd());
1464
+ if ("problem" in r0)
1465
+ return failed("logs", r0, { json });
1466
+ return guarded("logs", json, async () => {
1467
+ const reply = await callRun(r0.run, "GET", `/api/events?limit=${limit}${failedOnly ? "&failed=1" : ""}`);
1468
+ if (reply.status !== 200) {
1469
+ return failed("logs", runFailure(reply, [["mason status", "each synced table and its error now"], ["mason logs", "Mason's log, where each event is a line too"]]), { json });
1470
+ }
1471
+ const events = reply.body.events;
1472
+ const keptDays = Number(reply.body.keptDays);
1473
+ const next = failedOnly ? [["mason logs --events", "every event, the first syncs finished and the tables current again too"], ["mason status", "each synced table and its error now"]]
1474
+ : [["mason logs --events --failed", "only the failures"], ["mason status", "each synced table and its error now"]];
1475
+ if (json) {
1476
+ out(jsonText(LOGS, { events, keptDays }, next));
1477
+ return 0;
1478
+ }
1479
+ const st = styleOut();
1480
+ if (!events.length)
1481
+ out(`No ${failedOnly ? "sync failure" : "sync event"} in the last ${keptDays} day${keptDays === 1 ? "" : "s"} (MASON_HISTORY_DAYS).`);
1482
+ for (const e of events)
1483
+ out(eventLine(e, st));
981
1484
  out(endText({ next }, st));
982
1485
  return 0;
983
1486
  });
984
1487
  }
1488
+ /** mason logs --running: the statements the listener runs now, through the run's command server */
1489
+ async function runningLogs(json) {
1490
+ const r0 = await theRun(process.cwd());
1491
+ if ("problem" in r0)
1492
+ return failed("logs", r0, { json });
1493
+ return guarded("logs", json, async () => {
1494
+ const reply = await callRun(r0.run, "GET", "/api/running");
1495
+ if (reply.status !== 200)
1496
+ return failed("logs", runFailure(reply, [["mason logs", "Mason's log"]]), { json });
1497
+ const running = reply.body.running;
1498
+ const next = running.length
1499
+ ? [[`mason cancel ${running[0].session} --statement ${running[0].statement}`, "stop a session's statement, as a client's cancel does"], ["mason logs --requests", "the statements answered, refused and cancelled"]]
1500
+ : [["mason logs --requests", "the statements answered, refused and cancelled"]];
1501
+ if (json) {
1502
+ out(jsonText(LOGS, { running }, next));
1503
+ return 0;
1504
+ }
1505
+ const st = styleOut();
1506
+ out(running.length ? st.bold(`Running now (${running.length}):`) : "No statement is running now.");
1507
+ for (const r of running)
1508
+ out(` ${runningLine(r)}`);
1509
+ out(endText({ next }, st));
1510
+ return 0;
1511
+ });
1512
+ }
1513
+ /** A refusal the run answered, in the Failure shape it carries (`failure`, src/cli/run-server.ts sendFailure). */
1514
+ export function runFailure(reply, next) {
1515
+ const f = reply.body.failure;
1516
+ const code = typeof f?.code === "string" ? f.code : reply.status === 400 ? "usage" : reply.status === 404 ? "not_found" : reply.status === 409 ? "refused" : "failed";
1517
+ return { message: typeof f?.message === "string" ? f.message : String(reply.body.error ?? `the run answered ${reply.status}`), code,
1518
+ ...(typeof f?.why === "string" ? { why: f.why } : {}), ...(typeof f?.fix === "string" ? { fix: f.fix } : {}), next };
1519
+ }
985
1520
  export async function logs(argv) {
986
1521
  const f = parseCommand(LOGS, argv);
987
1522
  if (typeof f === "number")
988
1523
  return f;
989
1524
  const json = f.values.json === true;
990
- const lines = f.values.lines === undefined ? 40 : Number(f.values.lines);
991
- if (!Number.isInteger(lines) || lines < 0)
992
- return failed("logs", { message: `-n (--lines) is a number of lines, not ${JSON.stringify(f.values.lines)}`, code: "usage" }, { json });
993
- if (f.values.failed && !f.values.requests)
994
- return failed("logs", { message: "--failed is a filter of --requests", code: "usage", fix: "mason logs --requests --failed" }, { json });
1525
+ if (f.values.requests && f.values.events)
1526
+ return failed("logs", { message: "--requests and --events are two lists: ask for one", code: "usage", fix: "mason logs --requests, or mason logs --events" }, { json });
1527
+ if (f.values.failed && !f.values.requests && !f.values.events)
1528
+ return failed("logs", { message: "--failed is a filter of --requests or --events", code: "usage", fix: "mason logs --requests --failed, or mason logs --events --failed" }, { json });
1529
+ // the request log's own filters (--failed is --events' too)
1530
+ const filter = FILTER_FLAGS.find((k) => k !== "failed" && f.values[k] !== undefined);
1531
+ if (filter !== undefined && !f.values.requests) {
1532
+ return failed("logs", { message: `--${filter} is a filter of --requests`, code: "usage", fix: `mason logs --requests --${filter} …` }, { json });
1533
+ }
1534
+ if (f.values.running && (f.values.requests || f.values.events || f.values.start || f.values.follow)) {
1535
+ const other = f.values.requests ? "--requests" : f.values.events ? "--events" : f.values.start ? "--start" : "-f";
1536
+ const whose = other === "--requests" ? "the request log's" : other === "--events" ? "the sync's events'" : "the log file's";
1537
+ return failed("logs", { message: `--running lists the statements running now; ${other} is ${whose}`, code: "usage", fix: `mason logs --running, or mason logs ${other}` }, { json });
1538
+ }
1539
+ if (f.values.running && f.values.lines !== undefined) {
1540
+ return failed("logs", { message: "--running lists every statement running now: -n (--lines) does not apply to it", code: "usage", fix: "mason logs --running" }, { json });
1541
+ }
995
1542
  if (f.values.requests && (f.values.start || f.values.follow))
996
1543
  return failed("logs", { message: `--requests lists the request log; ${f.values.start ? "--start" : "-f"} is the log file's`, code: "usage", fix: "mason logs --requests, or mason logs " + (f.values.start ? "--start" : "-f") }, { json });
1544
+ if (f.values.events && (f.values.start || f.values.follow))
1545
+ return failed("logs", { message: `--events lists the sync's events; ${f.values.start ? "--start" : "-f"} is the log file's`, code: "usage", fix: "mason logs --events, or mason logs " + (f.values.start ? "--start" : "-f") }, { json });
997
1546
  if (json && f.values.follow)
998
1547
  return failed("logs", { message: "-f prints lines as they come, and --json is one answer", code: "usage", fix: "mason logs --json, or mason logs -f" }, { json });
1548
+ if (f.values.running)
1549
+ return runningLogs(json);
999
1550
  if (f.values.requests) {
1000
- if (lines < 1)
1001
- return failed("logs", { message: "-n with --requests is a number of requests, 1 or more", code: "usage" }, { json });
1002
- return requestLogs(lines, f.values.failed === true, json);
1551
+ // the filters, read as the run reads its query string (src/cli/request-filter.ts): a mistake is said here, exit 2
1552
+ const text = (v) => typeof v === "string" ? v : undefined;
1553
+ const q = requestFilter({ limit: text(f.values.lines), failed: f.values.failed === true, outcome: text(f.values.outcome), since: text(f.values.since),
1554
+ until: text(f.values.until), user: text(f.values.user), client: text(f.values.client),
1555
+ slow: f.values.slow === true, search: text(f.values.search), before: text(f.values.before) }, Date.now(), flagOf);
1556
+ if ("message" in q)
1557
+ return failed("logs", { ...q, usage: LOGS_USAGE }, { json });
1558
+ return requestLogs(q, json);
1559
+ }
1560
+ const lines = f.values.lines === undefined ? 40 : Number(f.values.lines);
1561
+ if (!Number.isInteger(lines) || lines < 0)
1562
+ return failed("logs", { message: `-n (--lines) is a number of lines, not ${JSON.stringify(f.values.lines)}`, code: "usage" }, { json });
1563
+ if (f.values.events) {
1564
+ const limit = eventLimit(typeof f.values.lines === "string" ? f.values.lines : undefined, "-n");
1565
+ if (typeof limit !== "number")
1566
+ return failed("logs", { ...limit, usage: LOGS_USAGE }, { json });
1567
+ return eventLogs(limit, f.values.failed === true, json);
1003
1568
  }
1004
1569
  // a stopped run's log is still worth reading: the project's, running or not
1005
1570
  const t = theProject(process.cwd());
@@ -1012,15 +1577,18 @@ export async function logs(argv) {
1012
1577
  return failed("logs", { message: `${file} does not exist yet`, code: "nothing_yet", why: `Mason has not run for ${t.project.config.name}`, next: [["mason start", "start it, in the background"]] }, { json });
1013
1578
  }
1014
1579
  const text = fs.readFileSync(file, "utf8");
1015
- const all = text.split("\n");
1016
- if (all[all.length - 1] === "")
1017
- all.pop();
1580
+ // WHY THE FILE, even with the run up: it is the whole record (the run's memory keeps only its last LOG_KEPT entries,
1581
+ // src/lib/log-buffer.ts) and the only one when the run is down. The entries are parsed by the buffer's own parser, so
1582
+ // `{ at, text }` is the shape of GET /api/logs too (the run's answer, which a `mason serve` run has no file for).
1583
+ // -n counts entries, a multi-line one being ONE; dev.log's lines (the start's steps, indented under each other) are a line each.
1584
+ const all = logEntries(text, { perLine: f.values.start === true });
1585
+ const last = all.slice(Math.max(0, all.length - lines));
1018
1586
  if (json) {
1019
- out(jsonText(LOGS, { lines: all.slice(Math.max(0, all.length - lines)) }, [["mason logs --requests", "every statement the Postgres API met"]]));
1587
+ out(jsonText(LOGS, { lines: last }, [["mason logs --requests", "every statement the Postgres API met"]]));
1020
1588
  return 0;
1021
1589
  }
1022
- for (const l of all.slice(Math.max(0, all.length - lines)))
1023
- out(l);
1590
+ for (const l of last)
1591
+ out(logLineText(l));
1024
1592
  if (!f.values.follow)
1025
1593
  return 0;
1026
1594
  let offset = Buffer.byteLength(text);
@@ -1046,19 +1614,19 @@ export async function logs(argv) {
1046
1614
  // ------------------------------------------------------------------ mason stop
1047
1615
  const STOP_USAGE = `usage: mason stop [--json]
1048
1616
 
1049
- Stops Mason for this folder's project. The copy of your data and Mason's state stay: the next mason start resumes
1617
+ Stops Mason for this folder's project. The synced data and Mason's state stay: the next mason start resumes
1050
1618
  in seconds, reading only what changed.
1051
1619
 
1052
1620
  mason stop
1053
1621
 
1054
- mason destroy removes the copy and the state too (your files stay), to free the disk or begin again.
1622
+ mason destroy removes the synced data and the state too (your files stay), to free the disk or begin again.
1055
1623
 
1056
1624
  --json as data: { stopped: [name], killed (a run that did not answer and ignored SIGTERM), supervisor (its pid, when one was stopped), next }
1057
1625
  `;
1058
1626
  export const STOP = {
1059
1627
  needs: "project",
1060
1628
  name: "stop",
1061
- summary: "stop Mason for this folder's project (the copy stays: the next mason start resumes)",
1629
+ summary: "stop Mason for this folder's project (the synced data stays: the next mason start resumes)",
1062
1630
  usage: STOP_USAGE,
1063
1631
  options: { json: { type: "boolean" } },
1064
1632
  json: ["stopped", "killed", "supervisor", "next"],
@@ -1068,7 +1636,7 @@ export async function stop(argv) {
1068
1636
  if (typeof f === "number")
1069
1637
  return f;
1070
1638
  const json = f.values.json === true;
1071
- const next = [["mason start", "resume, in seconds (in the project's folder)"], ["mason destroy", "remove its copy of the data and its state; your files stay"]];
1639
+ const next = [["mason start", "resume, in seconds (in the project's folder)"], ["mason destroy", "remove its synced data and its state; your files stay"]];
1072
1640
  const r = await theRun(process.cwd());
1073
1641
  if ("problem" in r && r.hung !== undefined) {
1074
1642
  // A RUN THAT DOES NOT ANSWER (src/cli/runs.ts "alive and silent"): its supervisor first, so it is not started again
@@ -1095,7 +1663,7 @@ export async function stop(argv) {
1095
1663
  return 0;
1096
1664
  }
1097
1665
  out(reportText({ headline: `Stopped ${hung.name}`, details: [["how", `${how}${sup === null ? "" : `; its supervisor (pid ${sup}) will not start it again`}`],
1098
- ["kept", "its copy of the data and its state (DuckDB recovers from its write-ahead log at the next start)"], ["log", "mason logs shows what it was doing"]] }));
1666
+ ["kept", "its synced data and its state (it recovers from its write-ahead log at the next start)"], ["log", "mason logs shows what it was doing"]] }));
1099
1667
  out(endText({ next }));
1100
1668
  return 0;
1101
1669
  }
@@ -1131,13 +1699,16 @@ export async function stop(argv) {
1131
1699
  return failed("stop", { message: `Mason for ${run.name} (pid ${run.pid}) is still running after 90 s`, code: "failed", next: [["mason logs", "what it is doing"]] }, { json });
1132
1700
  // a replication slot outlives the run that reads it, and holds the database's WAL meanwhile: said where it is left
1133
1701
  const recorded = readProject(run.root);
1134
- const replicating = isProject(recorded) && recorded.config.changes === "replication";
1702
+ // what a start made on the database, as it recorded it (src/cli/dev.ts): a project none of whose tables is replicated has none
1703
+ const replicating = isProject(recorded) && (recorded.config.replication ?? null) !== null;
1135
1704
  const named = isProject(recorded) ? recorded.config.publication ?? null : null;
1705
+ // a demo project's database (src/cli/demo.ts) stays too, as the copy does: said where a person reads the stop
1706
+ const demo = isProject(recorded) && recorded.config.demo === true;
1136
1707
  if (json) {
1137
1708
  out(jsonText(STOP, { stopped: [run.name] }, next));
1138
1709
  return 0;
1139
1710
  }
1140
- out(reportText({ headline: `Stopped ${run.name}`, details: [["kept", "its copy of the data and its state"],
1711
+ out(reportText({ headline: `Stopped ${run.name}`, details: [["kept", `its synced data and its state${demo ? ` and the demo database (${STATE_DIR}/${DEMO_DIR})` : ""}`],
1141
1712
  ...(replicating ? [["slot", `its replication slot stays on the database, holding WAL until the next mason start (mason destroy drops it)`],
1142
1713
  ["publication", named ? `${named}, its owner's: read as it is, never altered (mason destroy leaves it)` : "Mason's own (mason destroy drops it)"]] : [])] }));
1143
1714
  out(endText({ next }));