@mason-data/cli 0.7.0 → 0.9.0

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