@mason-data/cli 0.8.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 (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -19,16 +19,17 @@ 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
21
  import { restartsFact, signalSupervisor, stopSupervisor, supervisorGone } from "./supervise.js";
22
- import { DEMO_DIR, DUCK_FILE, STATE_DIR, databaseMoved, isProject, projectDatabase, projectSource, readProject, theProject, writeConfig } from "./project.js";
22
+ 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
24
  import { withPlaceholders, postgresFacts, postgresLink } from "./pg-url.js";
25
25
  import { listenerText } from "./pg-tls.js";
26
- import { logEntries, logLineText } from "../lib/log-buffer.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
30
  import { connectSource, maskedUrl, parseSource, readSourceFacts } from "./preflight.js";
30
31
  import { reach } from "./state.js";
31
- import { copyingText, copyProgress, duration, msText, readiness, readinessWord } from "./readiness.js";
32
+ import { copyingText, copyProgress, duration, generatedReadiness, msText, readiness, readinessWord } from "./readiness.js";
32
33
  import { pathText } from "../duck/kept-route.js";
33
34
  import { DEFAULT_REQUEST_LIMIT, eventLimit, MAX_SEARCH, SLOW_MS, requestFilter, requestFilterParams } from "./request-filter.js";
34
35
  import { countsText } from "../duck/stream.js";
@@ -104,7 +105,8 @@ export { copyingText, copyProgress, duration, msText, readiness, readinessWord }
104
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
105
106
  * (a table whose sync is off is copied as it asked: green). */
106
107
  export function paintBar(line, s) {
107
- const colour = / its first sync failed: /.test(line) ? s.red : / NOT kept current /.test(line) ? s.yellow : / 100% [\d,]+ rows · synced( · |$)/.test(line) ? s.green : s.cyan;
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;
108
110
  return line.replace(/(█+)(░*)|(░+)/, (_m, full, empty, none) => full !== undefined ? `${colour(full)}${s.dim(empty ?? "")}` : s.dim(none ?? ""));
109
111
  }
110
112
  /** The width of a table's progress bar, in characters. */
@@ -127,12 +129,12 @@ export function tableLines(s, now = Date.now()) {
127
129
  let fraction, what;
128
130
  if (r === 0) {
129
131
  fraction = 1;
130
- const age = keptAge(t, now);
132
+ const age = keptWords(t, now);
131
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 first sync 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);
@@ -163,7 +165,8 @@ export function keptAge(t, now = Date.now()) {
163
165
  if (k.state !== "streamed")
164
166
  return `NOT kept current (${k.syncedAt === null ? "never synced" : `last synced ${agoText(k.syncedAt, now)}`}): ${k.why ?? "it is not streamed yet"}`;
165
167
  const at = `replicated, at ${k.position}, ${k.syncedAt === null ? "not synced yet" : agoText(k.syncedAt, now)}`;
166
- return k.notRunning === null ? at : `${at} — NOT kept current now, the stream is not running: ${k.notRunning}`;
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}`;
167
170
  }
168
171
  if (k?.by !== "poll")
169
172
  return null;
@@ -175,6 +178,33 @@ export function keptAge(t, now = Date.now()) {
175
178
  return `sync: off, ${synced} — mason sync ${tableArg(t.table)} syncs it now; sync: poll 5m on its entry polls it`;
176
179
  return `polled every ${k.sync.every}, ${k.syncedAt === null ? "not polled yet" : `last ${agoText(k.syncedAt, now)}`}`;
177
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
+ }
178
208
  /** A span of seconds as the stream's lines say it: `0.3 s` under ten seconds, then as a wait reads (`duration`). */
179
209
  export const spanText = (seconds) => seconds < 10 ? `${Math.max(0, seconds).toFixed(1)} s` : duration(seconds);
180
210
  /**
@@ -250,18 +280,22 @@ const STATUS_USAGE = `usage: mason status [--json]
250
280
  <user>:<password> in the credentials' place), psql (its psql line), listener:
251
281
  { host (what it is bound to), tls (certificate, self-signed or null), fingerprint } }, source: { name (the
252
282
  source's: its sources file's), file (the sources file), shownFile (it from the project's root), url, database, remote, version, standby, changes,
253
- replication, demo (the demo database this run hosts), sync: { default (every table's sync unless its entry
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
254
284
  says its own: off, poll 30s), written (the file's top sync: says it; false: the default) } }, warnings, folder, project, tables: [{ table, rows,
255
- bytes, key, added, addedColumns, copy, error, kept, sync: { state (synced, syncing, failed, waiting), syncedAt (when
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
256
288
  it was last brought current), syncSeconds (how long that took), syncedBy (copy, poll, catch-up, stream),
257
- ageSeconds (how long ago that was) }, … }], totals: { added, copied, failed, rows (the synced tables' rows),
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),
258
292
  syncedAt (the latest sync of any) }, engine: { file, bytes, keptDays (MASON_HISTORY_DAYS), version (the run's), startedAt,
259
293
  uptimeSeconds, restarts (the times its supervisor started the run again: { count, last: { at, why, upSeconds }
260
294
  or null }, or { error }) }, disk: { path (where the synced data's file is), freeBytes, copyBytes (the file),
261
295
  projectedBytes (what the first syncs running are projected to add; null: none, or too early to say), error },
262
296
  sessions: { open (the Postgres API's sessions logged in), byClient: [{ user ('' a token's), client (its
263
297
  application_name), open, running }] }, share: (null, or what mason share has open) { port, fingerprint, sessions,
264
- refusedLogins, droppedConnections, shares: [{ user, role, reads, openedAt, via, host, port, endsAt, state }] }, queries: { keptDays, day, week } (the last day's and the last 7
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
265
299
  days' statements, each { days, covered (the days of it the request log keeps), statements, completed, refused,
266
300
  failed, cancelled, rows, bytesSent, durationMs: { avg, p50, p95 } (the completed ones') or null }; { error }
267
301
  when it could not be read), slowQueries, refusedQueries, refusedTotal (how many in all), unsupported: [{ gap
@@ -272,15 +306,21 @@ const STATUS_USAGE = `usage: mason status [--json]
272
306
  newest last: mason logs --events), error (why they could not be read, or null), queries: [{ requestId,
273
307
  startedAt, outcome, sqlstate, message, query, … }] (the latest statements refused, failed or cancelled, newest
274
308
  last, as mason logs --requests --json shows each: mason logs --requests --failed), queriesError (or null) },
275
- readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), next }.
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 }.
276
313
  Each table's kept is how it is kept current now (absent for a table the sync does not hold):
277
314
  { by: poll, sync: { mode: off } or { mode: poll, every (30s), seconds } (its entry's sync:, else the file's,
278
315
  else off), syncedAt (its last whole read or poll), nextAt (when its next poll is due; null: off, or not polled
279
316
  now), pending (only when it applies: why it is not kept current now, its phase included: it waits for
280
317
  its first sync, it is being read whole, its first sync failed, it is read again whole, caught up first) }
281
- or { by: recopy, copiedAt, pending (only when it applies, as the poll's) }
318
+ or { by: recopy, copiedAt, syncedAt, pending (only when it applies, as the poll's) }
282
319
  or { by: replication, slot, state (streamed, catching-up, waiting, left-out), why, position, lastCommit,
283
- notRunning (why the stream is not running, null when it is) }; with Mason stopped: { running: false, project, source: { name,
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,
284
324
  file, url, error (the sources file's problem, or null) }, engine, tables: [{ table }], lastRun, lastStop:
285
325
  { at, how } or null, next }; hung: the same with hung: { pid, problem, why },
286
326
  exit 1. In replication mode stream: { slot, slotWal: { walHeldBytes (the WAL the slot holds on the
@@ -294,7 +334,11 @@ const STATUS_USAGE = `usage: mason status [--json]
294
334
  fill: { rows, bytesPerRow, seconds, basis (measured: from the last fill of its column; disk or text: modelled from
295
335
  the column's bytes a row on disk, or its text's before it is on disk), rowsPerSecond } (the fill's estimate it
296
336
  was decided on), filled: { rows, seconds } (what its own fill measured), queued (a backfill waiting for the fill
297
- under way), keptAt, readyAt, why }]. roles (a run with a roles file, mason/roles.yml: what a user added
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
298
342
  with --role may read): { file, roles: [name], problem (what is wrong with the file now, every statement of a
299
343
  user with a role refused while there is; null), default (the role of a user added without --role; null: the
300
344
  file names none, and such a user reads nothing), all (the roles of tables: all) }
@@ -318,9 +362,9 @@ export const STATUS = {
318
362
  summary: "what Mason is doing: the database, each synced table, the Postgres API, its statements and errors",
319
363
  usage: STATUS_USAGE,
320
364
  options: { json: { type: "boolean" } },
321
- json: ["running", "pid", "postgres", "source", "warnings", "folder", "project", "tables", "totals", "engine", "disk", "sessions", "stream", "jsonPaths", "queries", "slowQueries", "refusedQueries",
322
- "refusedTotal", "unsupported", "recentErrors", "readiness", "ui",
323
- "roles", "share", "lastRun", "lastStop", "hung", "next"],
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"],
324
368
  };
325
369
  export async function status(argv) {
326
370
  const f = parseCommand(STATUS, argv);
@@ -396,7 +440,7 @@ export async function status(argv) {
396
440
  ["Listener", listenerText(run.postgresListener, postgresLink(run.postgresUrl).port)],
397
441
  ...shareRows(s.share),
398
442
  // the web page (mason ui), without its one-time token: mason ui prints the URL that carries it, and opens it
399
- ...(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)`]] : []),
400
444
  // the roles file as the listener reads it now (mason user add --role): its roles, or what is wrong with it
401
445
  ...(s.roles && s.roles.file !== null
402
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)`)
@@ -424,6 +468,8 @@ export async function status(argv) {
424
468
  out(l);
425
469
  for (const l of jsonPathLines(s.jsonPaths ?? [], st))
426
470
  out(l);
471
+ for (const l of generatedLines(s.generatedTables ?? [], st))
472
+ out(l);
427
473
  for (const l of queryLines(queries, st))
428
474
  out(l);
429
475
  for (const l of slowLines(slowReply.status === 200 ? slow : { error: String(slowReply.body.error ?? slowReply.status) }, st))
@@ -459,6 +505,7 @@ export function sourceRows(s) {
459
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`
460
506
  : s.source.demo ? "the demo database is checked this way only"
461
507
  : "sync: replication on an entry streams that table as it happens instead")],
508
+ ...(s.source.textOrder ? [["Text order", s.source.textOrder]] : []),
462
509
  ];
463
510
  }
464
511
  /** An SQL statement on one line, cut at `max` characters (the whole of it is mason logs --requests --json's). */
@@ -487,6 +534,38 @@ export function jsonPathLines(paths, st) {
487
534
  lines.push(` ${st.dim(`${dropped} dropped (mason status --json says why)`)}`);
488
535
  return lines;
489
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
+ }
490
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`. */
491
570
  export function totalsText(t, now = Date.now()) {
492
571
  const last = t.syncedAt === null ? "" : `, the last synced ${duration((now - Date.parse(t.syncedAt)) / 1000)} ago`;
@@ -815,9 +894,9 @@ async function readCatalog(p) {
815
894
  return src;
816
895
  const demo = p.config.demo === true;
817
896
  const fix = `change the URL where it is (${src.from}) when the database's password or host changed`;
818
- const parsed = parseSource(src.resolved);
897
+ const parsed = parseSource(src.resolved, src.from);
819
898
  if ("problem" in parsed)
820
- return { message: `the database's URL (${src.from}): ${parsed.problem}`, code: "bad_source", fix };
899
+ return { message: parsed.problem, code: "bad_source", fix };
821
900
  const connected = await connectSource(parsed.source, { demo });
822
901
  if ("failure" in connected) {
823
902
  // the demo database's way out is its run (src/cli/demo.ts): its hint is the fix, mason start the next command
@@ -925,7 +1004,8 @@ const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--inclu
925
1004
  With Mason running the first sync starts at once; mason wait returns when it is done and the table is served. Before
926
1005
  mason start (mason init && mason add orders && mason start) the entry is written, checked against the database as
927
1006
  the run checks it, and the first sync begins at mason start. Adding a table again with other columns changes its entry,
928
- and the table is synced again whole with them.
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.
929
1009
 
930
1010
  --columns <a,b,…> only these columns (one table at a time: the list is that table's)
931
1011
  --include <a,b,…> these columns as well as every other one — a column that looks like a secret, named on purpose
@@ -1003,6 +1083,11 @@ export async function add(argv) {
1003
1083
  // (a state with no project never meets the need: reach said it)
1004
1084
  if (where === null || where.project === null)
1005
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 });
1006
1091
  if (where.state === "up") {
1007
1092
  // Mason up: the run adds it (the copy starts now, and the run's own re-read never races the file's write)
1008
1093
  const run = where.run;
@@ -1132,7 +1217,9 @@ const REMOVE_USAGE = `usage: mason remove <table>… [--json]
1132
1217
 
1133
1218
  Takes tables out of the project: each one's entry in the sources file (mason/sources/<name>.yml) is removed, the
1134
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
1135
- stopped, what an earlier run synced is dropped at the next mason start.
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.
1136
1223
 
1137
1224
  mason remove customers
1138
1225
  mason remove customers billing.invoices several at once
@@ -1160,6 +1247,10 @@ export async function remove(argv) {
1160
1247
  // (a state with no project never meets the need: reach said it)
1161
1248
  if (where === null || where.project === null)
1162
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 });
1163
1254
  if (where.state === "up") {
1164
1255
  // Mason up: the run removes it and drops the copy at once (src/cli/run-server.ts removeTables)
1165
1256
  const run = where.run;
@@ -1201,15 +1292,18 @@ export function removedReport(removed, file, o = {}) {
1201
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
1202
1293
  * them (mason status --json's own fields, never derived again here) */
1203
1294
  export function waitTable(t) {
1204
- return { table: t.table, copy: t.copy, error: t.error, ...(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 } : {}) };
1205
1296
  }
1206
- const WAIT_USAGE = `usage: mason wait [--timeout <seconds>] [--json]
1297
+ const WAIT_USAGE = `usage: mason wait [<generated table>] [--timeout <seconds>] [--json]
1207
1298
 
1208
1299
  Waits until every added table is synced and served, showing every added table on a line of its own: 100% for the
1209
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.
1210
1303
 
1211
1304
  mason add orders && mason wait
1212
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
1213
1307
 
1214
1308
  --timeout <seconds> how long to wait (default 600); the sync goes on after, and mason wait again picks it up
1215
1309
  --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error,
@@ -1217,19 +1311,36 @@ const WAIT_USAGE = `usage: mason wait [--timeout <seconds>] [--json]
1217
1311
  rate, the seconds left, while its chunks are read }; kept as mason status --json says it —
1218
1312
  { by: poll, sync: { mode: off } or { mode: poll, every, seconds }, syncedAt (its last whole read
1219
1313
  or poll), nextAt (its next poll; null: off) }; kept's pending, when it applies, says why
1220
- it is not kept current now, its phase included),
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),
1221
1316
  stream (replication mode: where the stream is, as mason status --json says it), error (on a
1222
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)
1223
1321
 
1224
- Exit status: 0 ready; 1 a first sync 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.
1225
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
+ }
1226
1337
  export const WAIT = {
1227
1338
  needs: "run",
1228
1339
  name: "wait",
1229
- summary: "wait until every added table is synced 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)",
1230
1341
  usage: WAIT_USAGE,
1231
1342
  options: { json: { type: "boolean" }, timeout: { type: "string" } },
1232
- json: ["ready", "failed", "waiting", "timedOut", "tables", "stream", "error", "next"],
1343
+ json: ["ready", "failed", "waiting", "timedOut", "tables", "stream", "generatedTable", "error", "next"],
1233
1344
  };
1234
1345
  export async function wait(argv) {
1235
1346
  const f = parseCommand(WAIT, argv);
@@ -1239,9 +1350,14 @@ export async function wait(argv) {
1239
1350
  const timeout = f.values.timeout === undefined ? 600 : Number(f.values.timeout);
1240
1351
  if (!Number.isFinite(timeout) || timeout <= 0)
1241
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];
1242
1356
  const run = await runOr(WAIT, json);
1243
1357
  if (!run)
1244
1358
  return 1;
1359
+ if (named !== undefined)
1360
+ return waitGenerated(run, named, timeout, json);
1245
1361
  return guarded("wait", json, async () => {
1246
1362
  const until = Date.now() + timeout * 1000;
1247
1363
  // a terminal gets the block — every added table on its line (tableLines), then the stream — redrawn in place;
@@ -1266,7 +1382,7 @@ export async function wait(argv) {
1266
1382
  }
1267
1383
  // the same work, other numbers (a stream's positions included: they move with every change)
1268
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, "#");
1269
- if (what !== said || Date.now() - saidAt >= 30_000) {
1385
+ if (sayAgain(what, said, saidAt, Date.now())) {
1270
1386
  err(lines.map((l) => ` ${l}`).join("\n"));
1271
1387
  said = what;
1272
1388
  saidAt = Date.now();
@@ -1309,6 +1425,50 @@ export async function wait(argv) {
1309
1425
  }
1310
1426
  });
1311
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
+ }
1312
1472
  // ------------------------------------------------------------------ mason logs
1313
1473
  /** What the sync's events leave out, said where they are listed (mason logs --help, the page's Sync events, held equal
1314
1474
  * by tests/web-page.test.ts): a scheduled poll that succeeds is no event, so a person who synced reads where it is. */
@@ -1357,8 +1517,9 @@ const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [<
1357
1517
  --running the statements running now (Mason must be running)
1358
1518
  --json as data: { lines: [{ at, text }] } (the log's entries, oldest first; at is null for a line with no
1359
1519
  time); with --requests { requests: [{ requestId, startedAt, endedAt, session, applicationName,
1360
- tokenName, user (the mason user), role, attributes (the user's values its role's row filters read, or
1361
- null), method, outcome (ok, refused, failed, cancelled), status
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
1362
1523
  (completed, failed, cancelled: what a reader shows), sqlstate, message, detail, hint, rows,
1363
1524
  bytesSent, durationMs, query, queryTruncated }], keptDays, before (the position of the page before,
1364
1525
  or null: none older) }; with --events { events: [{ at, kind (copy_failed, poll_failed,
@@ -1572,16 +1733,22 @@ export async function logs(argv) {
1572
1733
  return failed("logs", t, { json });
1573
1734
  // the run's log by default (mason logs once showed no actual logs: it showed
1574
1735
  // mason start's terminal, which after the start carries only warnings and errors)
1575
- 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");
1576
1737
  if (!fs.existsSync(file)) {
1577
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 });
1578
1739
  }
1579
- const text = fs.readFileSync(file, "utf8");
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);
1580
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,
1581
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
1582
1749
  // `{ at, text }` is the shape of GET /api/logs too (the run's answer, which a `mason serve` run has no file for).
1583
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.
1584
- const all = logEntries(text, { perLine: f.values.start === true });
1751
+ const all = entriesOfLines(read, { perLine: f.values.start === true });
1585
1752
  const last = all.slice(Math.max(0, all.length - lines));
1586
1753
  if (json) {
1587
1754
  out(jsonText(LOGS, { lines: last }, [["mason logs --requests", "every statement the Postgres API met"]]));
@@ -1591,24 +1758,13 @@ export async function logs(argv) {
1591
1758
  out(logLineText(l));
1592
1759
  if (!f.values.follow)
1593
1760
  return 0;
1594
- let offset = Buffer.byteLength(text);
1595
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);
1596
1767
  await new Promise((res) => setTimeout(res, 500));
1597
- const size = fs.statSync(file).size;
1598
- if (size < offset)
1599
- offset = 0; // a new run started the file over
1600
- if (size === offset)
1601
- continue;
1602
- const buf = Buffer.alloc(size - offset);
1603
- const fd = fs.openSync(file, "r");
1604
- try {
1605
- fs.readSync(fd, buf, 0, buf.length, offset);
1606
- }
1607
- finally {
1608
- fs.closeSync(fd);
1609
- }
1610
- offset = size;
1611
- process.stdout.write(buf.toString());
1612
1768
  }
1613
1769
  }
1614
1770
  // ------------------------------------------------------------------ mason stop