@mason-data/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +6 -4
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/proxy.js +6 -160
  4. package/dist/src/api/postgres/server.js +22 -4
  5. package/dist/src/cli/command.js +169 -5
  6. package/dist/src/cli/commands.js +290 -157
  7. package/dist/src/cli/deploy.js +134 -28
  8. package/dist/src/cli/doctor.js +14 -11
  9. package/dist/src/cli/guides.js +20 -11
  10. package/dist/src/cli/hosted.js +1 -1
  11. package/dist/src/cli/init.js +110 -25
  12. package/dist/src/cli/login.js +20 -17
  13. package/dist/src/cli/machine.js +125 -40
  14. package/dist/src/cli/main.js +51 -8
  15. package/dist/src/cli/mcp.js +15 -12
  16. package/dist/src/cli/project.js +1 -1
  17. package/dist/src/cli/registry.js +80 -35
  18. package/dist/src/cli/replica-hosted.js +761 -102
  19. package/dist/src/cli/replica.js +43 -5
  20. package/dist/src/cli/replication-mode.js +5 -0
  21. package/dist/src/cli/request-filter.js +16 -6
  22. package/dist/src/cli/run-server.js +25 -2
  23. package/dist/src/cli/secret-input.js +193 -0
  24. package/dist/src/cli/serve.js +2 -2
  25. package/dist/src/cli/share.js +20 -15
  26. package/dist/src/cli/source-role.js +14 -15
  27. package/dist/src/cli/sources.js +20 -3
  28. package/dist/src/cli/style.js +14 -0
  29. package/dist/src/cli/sync.js +17 -8
  30. package/dist/src/cli/token-hosted.js +30 -24
  31. package/dist/src/cli/tunnel.js +1 -1
  32. package/dist/src/cli/ui-hosted.js +163 -0
  33. package/dist/src/cli/ui-ssh.js +3 -3
  34. package/dist/src/cli/ui.js +67 -26
  35. package/dist/src/cli/usage.js +19 -15
  36. package/dist/src/cli/user-hosted.js +78 -13
  37. package/dist/src/cli/user.js +168 -50
  38. package/dist/src/duck/users.js +105 -9
  39. package/dist/src/lib/cidr.js +177 -0
  40. package/dist/src/lib/table-sync.js +69 -19
  41. package/package.json +6 -6
@@ -8,13 +8,13 @@
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 synced: 0 ready, 1 a copy failed (said), 124 timed out
11
+ * mason wait [--timeout <duration>] [--json] until every added table is synced: 0 ready, 1 a copy failed (said), 124 timed out
12
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";
16
16
  import * as path from "node:path";
17
- import { asFailure, endText, errorJson, failed, jsonText, nextBlock, parseCommand, reportText, withEnd } from "./command.js";
17
+ import { asFailure, endText, errorJson, failed, failureCode, jsonText, listFlag, nextBlock, noArguments, parseCommand, reportText, withEnd } from "./command.js";
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";
@@ -25,7 +25,7 @@ import { withPlaceholders, postgresFacts, postgresLink } from "./pg-url.js";
25
25
  import { listenerText } from "./pg-tls.js";
26
26
  import { entriesOfLines, logLineText } from "../lib/log-buffer.js";
27
27
  import { LogReader, logPath } from "../lib/log-read.js";
28
- import { catalogTable, entryName, looksSecret, tableNameProblem } from "./sources.js";
28
+ import { catalogTable, entryName, findEntry, looksSecret, tableNameProblem } from "./sources.js";
29
29
  import { addEntry, Refusal, removeEntries, sourceToEdit } from "./source-edit.js";
30
30
  import { connectSource, maskedUrl, parseSource, readSourceFacts } from "./preflight.js";
31
31
  import { reach } from "./state.js";
@@ -33,7 +33,8 @@ import { copyingText, copyProgress, duration, generatedReadiness, msText, readin
33
33
  import { pathText } from "../duck/kept-route.js";
34
34
  import { DEFAULT_REQUEST_LIMIT, eventLimit, MAX_SEARCH, SLOW_MS, requestFilter, requestFilterParams } from "./request-filter.js";
35
35
  import { countsText } from "../duck/stream.js";
36
- import { agoText, BIG_TABLE_ROWS, parseSync, syncText } from "../lib/table-sync.js";
36
+ import { agoText, BIG_TABLE_ROWS, parseDuration, parseSync, syncText } from "../lib/table-sync.js";
37
+ import { didYouMean } from "../lib/did-you-mean.js";
37
38
  const out = (m) => { process.stdout.write(`${m}\n`); };
38
39
  const err = (m) => { process.stderr.write(`${m}\n`); };
39
40
  // ------------------------------------------------------------------ shared
@@ -265,7 +266,14 @@ const STATUS_USAGE = `usage: mason status [--json]
265
266
 
266
267
  Status: running, not running, or hung — then this project's facts, one a line: its folder, its database and how
267
268
  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
+ and how it listens.
270
+
271
+ mason status
272
+ mason status --json
273
+
274
+ --json as data: its shape is at the end
275
+
276
+ Then each added table as mason wait shows it, with how it is kept current and when it was last synced
269
277
  (sync: off, last synced 3 days ago; polled every 30s, last 4 s ago); the sync's latest failures (mason logs --events
270
278
  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
279
  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
@@ -273,76 +281,6 @@ const STATUS_USAGE = `usage: mason status [--json]
273
281
  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
274
282
  not answer its commands (a statement may be holding its main thread): the same rows, and mason stop ends it (exit 1).
275
283
 
276
- mason status
277
- mason status --json
278
-
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:
281
- { host (what it is bound to), tls (certificate, self-signed or null), fingerprint } }, source: { name (the
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) }
319
- or { by: replication, slot, state (streamed, catching-up, waiting, left-out), why, position, lastCommit,
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,
324
- file, url, error (the sources file's problem, or null) }, engine, tables: [{ table }], lastRun, lastStop:
325
- { at, how } or null, next }; hung: the same with hung: { pid, problem, why },
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,
329
- applied, database: { lsn, at } or { error }, lag: { bytes, seconds }, lastBatch: { appliedAt, committedAt },
330
- tables: [{ table, state (streamed, catching-up, waiting, left-out), why, catchUp: { phase (queued, polling,
331
- applying), from, startedAt, held, retryAt }, applied: { inserts, updates, deletes, truncates } }] }.
332
- jsonPaths: [{ table, column, steps, either, canonical, asText (false: a path's -> and ->> share it), name,
333
- state (waiting, backfilling, ready, dropped), rowsTotal, rowsDone, marked, statements, slowestMs, totalMs,
334
- fill: { rows, bytesPerRow, seconds, basis (measured: from the last fill of its column; disk or text: modelled from
335
- the column's bytes a row on disk, or its text's before it is on disk), rowsPerSecond } (the fill's estimate it
336
- was decided on), filled: { rows, seconds } (what its own fill measured), queued (a backfill waiting for the fill
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) }
345
-
346
284
  In replication mode a Stream section says where the stream is: its position against the database's (bytes behind),
347
285
  when the newest change applied was committed and how long after it was applied, and each added table — streamed,
348
286
  with the changes applied this run; being caught up by one poll; not streamed yet; or left out — and why.
@@ -355,6 +293,73 @@ const STATUS_USAGE = `usage: mason status [--json]
355
293
  waiting (its fill's cost holds it back: the line says the estimate and what would store it), queued (its column
356
294
  added, its fill after the one under way), backfilling (its existing rows being filled, N %) or ready (read from its
357
295
  column). MASON_STORE_JSON_PATHS=off turns it off.
296
+
297
+ The --json answer: { running, pid, postgres: { url, host, port, database, sslmode (require when it is TLS), shown (the URL with
298
+ <user>:<password> in the credentials' place), psql (its psql line), listener:
299
+ { host (what it is bound to), tls (certificate, self-signed or null), fingerprint } }, source: { name (the
300
+ source's: its sources file's), file (the sources file), shownFile (it from the project's root), url, database, remote, version, standby, changes,
301
+ 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
302
+ says its own: off, poll 30s), written (the file's top sync: says it; false: the default) } }, warnings, folder, project, tables: [{ table, rows,
303
+ bytes, key, added, addedColumns, copy, error (why its sync failed: its first sync, or the poll, catch-up, stream or
304
+ publication check keeping a synced table current; null otherwise), kept, sync: { state (synced, syncing,
305
+ behind: served and kept but older than its sync: promises, failed, waiting), syncedAt (when
306
+ it was last brought current), syncSeconds (how long that took), syncedBy (copy, poll, catch-up, stream),
307
+ ageSeconds (how long ago that was), asOf (the time the synced data holds the database as of: kept's syncedAt),
308
+ promisedSeconds (the age its sync: promises not to pass; null: sync: off promises nothing), behindBySeconds
309
+ (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),
310
+ syncedAt (the latest sync of any) }, engine: { file, bytes, keptDays (MASON_HISTORY_DAYS), version (the run's), startedAt,
311
+ uptimeSeconds, restarts (the times its supervisor started the run again: { count, last: { at, why, upSeconds }
312
+ or null }, or { error }) }, disk: { path (where the synced data's file is), freeBytes, copyBytes (the file),
313
+ projectedBytes (what the first syncs running are projected to add; null: none, or too early to say), error },
314
+ sessions: { open (the Postgres API's sessions logged in), byClient: [{ user ('' a token's), client (its
315
+ application_name), open, running }] }, share: (null, or what mason share has open) { port, fingerprint, sessions,
316
+ 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
317
+ days' statements, each { days, covered (the days of it the request log keeps), statements, completed, refused,
318
+ failed, cancelled, rows, bytesSent, durationMs: { avg, p50, p95 } (the completed ones') or null }; { error }
319
+ when it could not be read), slowQueries, refusedQueries, refusedTotal (how many in all), unsupported: [{ gap
320
+ (its entry of docs/SQL-SUPPORT.md), title (what it is; null for an entry this Mason no longer has), runs
321
+ (the statements it refused in the last day), users, clients (application_name), last, example (the latest,
322
+ cut at 1,000 characters) }] (the most refused first: GET /api/unsupported) or { error },
323
+ recentErrors: { sync: [{ at, kind, failed, table, message }] (the latest failures of the sync and restarts,
324
+ newest last: mason logs --events), error (why they could not be read, or null), queries: [{ requestId,
325
+ startedAt, outcome, sqlstate, message, query, … }] (the latest statements refused, failed or cancelled, newest
326
+ last, as mason logs --requests --json shows each: mason logs --requests --failed), queriesError (or null) },
327
+ readiness (what mason wait waits on), ui (whether this run serves the web page: mason ui), served (this is
328
+ mason serve's run: mason ui --ssh opens its page), sourcesReadOnly (why the page offers no Add or Remove — the
329
+ project's files deployed read-only, MASON_PROJECT_READONLY, which mason add and mason remove refuse too, or a
330
+ server's sources file it cannot write — or null), next }.
331
+ Each table's kept is how it is kept current now (absent for a table the sync does not hold):
332
+ { by: poll, sync: { mode: off } or { mode: poll, every (30s), seconds } (its entry's sync:, else the file's,
333
+ else off), syncedAt (its last whole read or poll), nextAt (when its next poll is due; null: off, or not polled
334
+ now), pending (only when it applies: why it is not kept current now, its phase included: it waits for
335
+ its first sync, it is being read whole, its first sync failed, it is read again whole, caught up first) }
336
+ or { by: recopy, copiedAt, syncedAt, pending (only when it applies, as the poll's) }
337
+ or { by: replication, slot, state (streamed, catching-up, waiting, left-out), why, position, lastCommit,
338
+ notRunning (why the stream is not running, null when it is) }; each with promisedSeconds (the age of
339
+ its synced data past which it is behind: poll, its interval plus the larger of 60 s and twice its last poll;
340
+ replication, 120 s; off, null) and lateWhy (what would make it late now: the pass's or its own poll's
341
+ time, which Mason waits four times, the stream not running); with Mason stopped: { running: false, project, source: { name,
342
+ file, url, error (the sources file's problem, or null) }, engine, tables: [{ table }], lastRun, lastStop:
343
+ { at, how } or null, next }; hung: the same with hung: { pid, problem, why },
344
+ exit 1. In replication mode stream: { slot, slotWal: { walHeldBytes (the WAL the slot holds on the
345
+ database), walStatus (reserved, extended, unreserved, lost), safeWalSizeBytes (the WAL left before Postgres
346
+ invalidates it; null: max_slot_wal_keep_size unset, nothing caps it), at } or { error }, publication, running, failure, position,
347
+ applied, database: { lsn, at } or { error }, lag: { bytes, seconds }, lastBatch: { appliedAt, committedAt },
348
+ tables: [{ table, state (streamed, catching-up, waiting, left-out), why, catchUp: { phase (queued, polling,
349
+ applying), from, startedAt, held, retryAt }, applied: { inserts, updates, deletes, truncates } }] }.
350
+ jsonPaths: [{ table, column, steps, either, canonical, asText (false: a path's -> and ->> share it), name,
351
+ state (waiting, backfilling, ready, dropped), rowsTotal, rowsDone, marked, statements, slowestMs, totalMs,
352
+ fill: { rows, bytesPerRow, seconds, basis (measured: from the last fill of its column; disk or text: modelled from
353
+ the column's bytes a row on disk, or its text's before it is on disk), rowsPerSecond } (the fill's estimate it
354
+ was decided on), filled: { rows, seconds } (what its own fill measured), queued (a backfill waiting for the fill
355
+ under way), keptAt, readyAt, why }]. generatedTables: [{ table, file, kind, method (whole: its SQL run again),
356
+ state (refused, waiting, building, failed, current, behind), targetLag, behindSeconds (across its chain: the
357
+ oldest base change below it not in it), behindSince (when that change was committed; null: none), missingTarget, serving,
358
+ lastRun: { at, seconds, rows }, error (the last run's, no value of a row in it), retryAt,
359
+ refusals: [{ key, message, why, options, text }], waitingFor, warnings }]. roles (a run with a roles file, mason/roles.yml: what a user added
360
+ with --role may read): { file, roles: [name], problem (what is wrong with the file now, every statement of a
361
+ user with a role refused while there is; null), default (the role of a user added without --role; null: the
362
+ file names none, and such a user reads nothing), all (the roles of tables: all) }
358
363
  `;
359
364
  export const STATUS = {
360
365
  needs: "project",
@@ -370,6 +375,9 @@ export async function status(argv) {
370
375
  const f = parseCommand(STATUS, argv);
371
376
  if (typeof f === "number")
372
377
  return f;
378
+ const extra = noArguments(STATUS, f.positionals, f.values.json === true);
379
+ if (extra !== null)
380
+ return extra;
373
381
  const r0 = await theRun(process.cwd());
374
382
  // the first line goes straight to the point (Status: running); when the service is not running, the same rows and
375
383
  // tables show, with "not running" for each service that is not
@@ -788,10 +796,12 @@ const TABLES_USAGE = `usage: mason tables [<table>…] [--all] [--json]
788
796
  mason tables --all every table with its columns, at once (long)
789
797
 
790
798
  --all every table with its columns, not only the named ones
791
- --json as data: { database, source: { name (the source's: its sources file's), file (the sources file) },
792
- tables: [{ table, rows, bytes, primaryKey, comment, addable,
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 }
799
+ --json as data: its shape is at the end
800
+
801
+ The --json answer: { database, source: { name (the source's: its sources file's), file (the sources file) },
802
+ tables: [{ table, rows, bytes, primaryKey, comment, addable,
803
+ reason, added, addedColumns (the columns it syncs: null when not added), copyKey (the key: the sources
804
+ file keys it by, null: its primary key), columns: [{ name, type, nullable, comment, references }] }], next }
795
805
  `;
796
806
  export const TABLES = {
797
807
  // the project, not the run (the hands-on review of #451, item 15): what it lists is the database's catalog and the
@@ -978,12 +988,22 @@ async function viaRun(run, route, body) {
978
988
  const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--include <column>,…] [--key <column>,…] [--sync <sync>] [--json]
979
989
 
980
990
  Syncs tables into Mason: each one is added to the project's sources file (mason/sources/<name>.yml), under tables:
991
+
981
992
  mason add orders every column: orders: {}
982
993
  mason add orders --columns id,status,total those columns: orders: { columns: [id, status, total] }
983
994
  mason add orders customers several tables, every column of each
984
995
  mason add users --include password_hash every column, and one that looks like a secret
985
996
  mason add event_log --key tenant_id,id a table with no primary key, keyed by those columns
986
997
  mason add orders --sync "poll 30s" polled every 30 seconds: orders: { sync: poll 30s }
998
+
999
+ --columns <a,b,…> only these columns (one table at a time: the list is that table's); a list flag takes a comma
1000
+ list or the flag repeated, the same: --columns id,status is --columns id --columns status
1001
+ --include <a,b,…> these columns as well as every other one — a column that looks like a secret, named on purpose
1002
+ --key <a,b,…> for a table with no primary key: the columns that tell its rows apart, which it is keyed by (one
1003
+ table at a time; polled or off: logical replication cannot apply a change by them)
1004
+ --sync <sync> how it is kept current after its first sync, written on its entry: off, "poll 30s" or replication
1005
+ --json as data: its shape is at the end
1006
+
987
1007
  A table is its name (orders) or schema.table (billing.invoices). Its primary key is always synced. The rest of the
988
1008
  file — your comments, other tables — stays as it was.
989
1009
 
@@ -1007,19 +1027,14 @@ const ADD_USAGE = `usage: mason add <table>… [--columns <column>,…] [--inclu
1007
1027
  and the table is synced again whole with them. A project deployed as committed (MASON_PROJECT_READONLY=1 where Mason
1008
1028
  runs) is refused, its files left as they are: add the table where the project is committed, then deploy.
1009
1029
 
1010
- --columns <a,b,…> only these columns (one table at a time: the list is that table's)
1011
- --include <a,b,…> these columns as well as every other one — a column that looks like a secret, named on purpose
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
1015
- --json as data: { added: [{ table, entry, columns (null: every one), exclude, key, addedKey (key columns
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 }],
1021
- file (the sources file), error (when a table was not added: { code, message, why }), next };
1022
- exit 1 when a table could not be added
1030
+ The --json answer: { added: [{ table, entry, columns (null: every one), exclude, key, addedKey (key columns
1031
+ added to --columns), skippedSecrets, already (the file said so already), sync: { value (off, poll
1032
+ 30s), from (entry: its own sync:, file: the file's, default: off, as the file writes none) },
1033
+ bigOff (it is over ${BIG_TABLE_ROWS.toLocaleString("en-US")} rows and its entry was written sync: off), notReplicated (why
1034
+ its entry was written sync: poll 5m under a file whose sync: is replication, or null), pollCost (an estimate of
1035
+ what a frequent poll of a big table costs your database, or null) } or { table, error }],
1036
+ file (the sources file), error (when a table was not added: { code, message, why }), next };
1037
+ exit 1 when a table could not be added
1023
1038
  `;
1024
1039
  export const ADD = {
1025
1040
  // the project, not the run: the sources file is the list, so a table is added before mason start as well as after
@@ -1027,7 +1042,7 @@ export const ADD = {
1027
1042
  name: "add",
1028
1043
  summary: "sync tables into Mason, adding them to the sources file: mason add orders (--columns id,status: those only)",
1029
1044
  usage: ADD_USAGE,
1030
- options: { json: { type: "boolean" }, columns: { type: "string" }, include: { type: "string" }, key: { type: "string" }, sync: { type: "string" } },
1045
+ options: { json: { type: "boolean" }, columns: { type: "string", multiple: true }, include: { type: "string", multiple: true }, key: { type: "string", multiple: true }, sync: { type: "string" } },
1031
1046
  json: ["added", "file", "error", "next"],
1032
1047
  };
1033
1048
  /** `--sync`, read by the sources file's own grammar (src/lib/table-sync.ts parseSync): a sync, or the usage's problem */
@@ -1070,7 +1085,7 @@ export async function add(argv) {
1070
1085
  if (typeof f === "number")
1071
1086
  return f;
1072
1087
  const json = f.values.json === true;
1073
- const specs = addSpecs(f.positionals, f.values.columns, f.values.include, f.values.key);
1088
+ const specs = addSpecs(f.positionals, listFlag(f.values.columns), listFlag(f.values.include), listFlag(f.values.key));
1074
1089
  if (typeof specs === "string")
1075
1090
  return failed("add", { message: specs, code: "usage", usage: ADD_USAGE }, { json });
1076
1091
  const sync = addSync(f.values.sync);
@@ -1213,7 +1228,7 @@ export function addedLines(b, file) {
1213
1228
  ] };
1214
1229
  }
1215
1230
  // ------------------------------------------------------------------ mason remove
1216
- const REMOVE_USAGE = `usage: mason remove <table>… [--json]
1231
+ const REMOVE_USAGE = `usage: mason remove <table>… [--dry-run] [--json]
1217
1232
 
1218
1233
  Takes tables out of the project: each one's entry in the sources file (mason/sources/<name>.yml) is removed, the
1219
1234
  rest of the file as it was. With Mason running it stops syncing the table and drops its synced data at once; with Mason
@@ -1223,8 +1238,12 @@ const REMOVE_USAGE = `usage: mason remove <table>… [--json]
1223
1238
 
1224
1239
  mason remove customers
1225
1240
  mason remove customers billing.invoices several at once
1241
+ mason remove customers --dry-run what it would remove, changing nothing
1226
1242
 
1227
- --json as data: { removed (the tables, schema.table), file (the sources file), next }
1243
+ --dry-run say which entries would go, and change nothing: the file is read, never written, and neither Mason nor
1244
+ the database is asked
1245
+ --json as data: { dryRun, removedTables (the tables, schema.table; on a dry run, the ones that would go), removed
1246
+ (the same, kept until 1.0; not on a dry run), file (the sources file), next }
1228
1247
  `;
1229
1248
  export const REMOVE = {
1230
1249
  // the project, not the run, as mason add
@@ -1232,8 +1251,8 @@ export const REMOVE = {
1232
1251
  name: "remove",
1233
1252
  summary: "take tables out of the sources file: mason remove customers (its synced data is dropped)",
1234
1253
  usage: REMOVE_USAGE,
1235
- options: { json: { type: "boolean" } },
1236
- json: ["removed", "file", "next"],
1254
+ options: { json: { type: "boolean" }, "dry-run": { type: "boolean" } },
1255
+ json: ["dryRun", "removedTables", "removed", "file", "next"],
1237
1256
  };
1238
1257
  export async function remove(argv) {
1239
1258
  const f = parseCommand(REMOVE, argv);
@@ -1251,6 +1270,8 @@ export async function remove(argv) {
1251
1270
  const readOnly = projectWriteRefusal(where.project.root, process.env);
1252
1271
  if (readOnly !== null)
1253
1272
  return failed("remove", readOnly, { json });
1273
+ if (f.values["dry-run"] === true)
1274
+ return removeDryRun(where.project, where.state === "up", names, json);
1254
1275
  if (where.state === "up") {
1255
1276
  // Mason up: the run removes it and drops the copy at once (src/cli/run-server.ts removeTables)
1256
1277
  const run = where.run;
@@ -1272,13 +1293,53 @@ function removedAnswer(r, names, running, json) {
1272
1293
  const next = running ? [["mason status", "the tables left"]]
1273
1294
  : [["mason start", "Mason in the background, for this project: it syncs the tables left"], ["mason status", "the tables left"]];
1274
1295
  if (json) {
1275
- out(jsonText(REMOVE, { removed: b.removed, file: b.file }, next));
1296
+ out(jsonText(REMOVE, { dryRun: false, removed: b.removed, removedTables: b.removed, file: b.file }, next));
1276
1297
  return 0;
1277
1298
  }
1278
1299
  const rel = path.relative(process.cwd(), b.file);
1279
1300
  out(reportText({ ...removedReport(b.removed, rel.startsWith("..") ? b.file : rel, { running }), next }, styleOut()));
1280
1301
  return 0;
1281
1302
  }
1303
+ /**
1304
+ * `mason remove --dry-run`: the entries the sources file would lose, CHANGING NOTHING — read as the edit reads the file
1305
+ * (`sourceToEdit`: its refusals said the same), each name found as the edit finds it (`findEntry`); neither the run nor
1306
+ * the database is asked, so the file's other entries are not checked against the database here, as the edit does.
1307
+ */
1308
+ async function removeDryRun(p, up, names, json) {
1309
+ if (!up) {
1310
+ const st = await runState(p);
1311
+ // a hung run refuses the edit itself (withoutRun): a dry run says what the edit would
1312
+ if (st.kind === "hung")
1313
+ return failed("remove", { ...asFailure(notAnswering(p, st.run)), code: "unreachable" }, { json });
1314
+ }
1315
+ const r = edited(() => {
1316
+ const at = sourceToEdit(p.folder, p.root, () => projectSource(p));
1317
+ const tables = names.map((n) => {
1318
+ const e = findEntry(at.parsed, n);
1319
+ if ("problem" in e)
1320
+ throw new Refusal(e.usage ? 400 : 404, e.problem);
1321
+ return e.table;
1322
+ });
1323
+ return { removed: [...new Set(tables)], file: at.file };
1324
+ });
1325
+ if (!("ok" in r)) {
1326
+ return failed("remove", { message: `${names.join(", ")} would not be removed`, code: r.status === 404 ? "not_found" : r.status === 400 ? "usage" : "refused", why: r.error,
1327
+ fix: "nothing was changed (a dry run)", next: [["mason status", "the tables added"]] }, { json });
1328
+ }
1329
+ const b = r.ok;
1330
+ const next = [[`mason remove ${names.join(" ")}`, "remove them"]];
1331
+ if (json) {
1332
+ out(jsonText(REMOVE, { dryRun: true, removedTables: b.removed, file: b.file }, next));
1333
+ return 0;
1334
+ }
1335
+ const rel = path.relative(process.cwd(), b.file);
1336
+ const one = b.removed.length === 1;
1337
+ out(reportText({ headline: `mason remove would remove ${b.removed.join(", ")}; nothing was changed`, details: [["from", rel.startsWith("..") ? b.file : rel]],
1338
+ tips: [up ? `Mason would stop syncing ${one ? "it" : "them"} and drop ${one ? "its" : "their"} synced data at once`
1339
+ : `what an earlier run synced of ${one ? "it" : "them"} would be dropped at the next mason start`,
1340
+ "mason remove also checks the file's other entries against the database, which a dry run does not ask"], next }, styleOut()));
1341
+ return 0;
1342
+ }
1282
1343
  /** What `mason remove` prints for the tables removed, `file` the sources file as the reader is shown it (the web page
1283
1344
  * says the same, src/web/app.js; tests/web-page.test.ts holds the two equal). */
1284
1345
  export function removedReport(removed, file, o = {}) {
@@ -1294,30 +1355,22 @@ export function removedReport(removed, file, o = {}) {
1294
1355
  export function waitTable(t) {
1295
1356
  return { table: t.table, copy: t.copy, error: t.error, ...(t.kept ? { kept: t.kept } : {}), ...(t.sync ? { sync: t.sync } : {}) };
1296
1357
  }
1297
- const WAIT_USAGE = `usage: mason wait [<generated table>] [--timeout <seconds>] [--json]
1358
+ const WAIT_USAGE = `usage: mason wait [<table>…] [--timeout <duration>] [--json]
1298
1359
 
1299
1360
  Waits until every added table is synced and served, showing every added table on a line of its own: 100% for the
1300
1361
  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
1362
+ Named synced tables: waits for those alone, until the first sync of each is done and it is served. Named, a
1363
+ generated table (alone) instead: waits until it holds every change Mason had synced when the wait began, across its
1302
1364
  chain (the tables it is generated from, and theirs), so what is read from it next includes them.
1303
1365
 
1304
1366
  mason add orders && mason wait
1305
- mason wait --timeout 3600 a large table: wait up to an hour
1367
+ mason wait --timeout 1h a large table: wait up to an hour
1368
+ mason wait orders customers these synced tables only
1306
1369
  mason wait daily_requests a generated table: until it holds what is synced now
1307
1370
 
1308
- --timeout <seconds> how long to wait (default 600); the sync goes on after, and mason wait again picks it up
1309
- --json as data, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error,
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),
1316
- stream (replication mode: where the stream is, as mason status --json says it), error (on a
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)
1371
+ --timeout <duration> how long to wait (default 600, seconds; 10m, 1h); the sync goes on after, and mason wait
1372
+ again picks it up
1373
+ --json as data: its shape is at the end
1321
1374
 
1322
1375
  Exit status: 0 ready (a table behind what its sync: promises is said on its line, and ready); 1 a first sync failed,
1323
1376
  or the sync keeping a synced table current failed (its poll, its catch-up, the stream, or a named publication that
@@ -1325,6 +1378,19 @@ const WAIT_USAGE = `usage: mason wait [<generated table>] [--timeout <seconds>]
1325
1378
  before it was built (printed, with what to do; Mason tries a failed one again by itself); 124 timed out.
1326
1379
  A generated table that serves and holds everything synced is ready (0) though its last run failed: after editing its
1327
1380
  file, read mason status for whether the new definition was refused or failed.
1381
+
1382
+ The --json answer, once it ends: { ready, failed, waiting, timedOut, tables: [{ table, copy, error,
1383
+ kept }] (copy: { phase, rowsCopied, progress: the chunks done, reading and for how long, the
1384
+ rate, the seconds left, while its chunks are read }; kept as mason status --json says it —
1385
+ { by: poll, sync: { mode: off } or { mode: poll, every, seconds }, syncedAt (its last whole read
1386
+ or poll), nextAt (its next poll; null: off) }; kept's pending, when it applies, says why
1387
+ it is not kept current now, its phase included; sync as mason status --json says it: its state —
1388
+ synced, syncing, behind, failed, waiting — asOf, promisedSeconds, behindBySeconds, behindWhy),
1389
+ stream (replication mode: where the stream is, as mason status --json says it), error (on a
1390
+ failure or a timeout: { code, message, next }), next }
1391
+ Named, a generated table: the same ready, failed, waiting, timedOut, error and next, tables
1392
+ empty, and generatedTable (as mason status --json's generatedTables says it: its state,
1393
+ behindSeconds, behindSince — when the oldest change not in it was committed, null: none)
1328
1394
  `;
1329
1395
  /** how often a wait not drawn in place says its block again though nothing in it changed, so a long one never goes
1330
1396
  * quiet: the copies' wait and a generated table's */
@@ -1337,7 +1403,7 @@ export function sayAgain(what, said, saidAt, now, every = SAY_AGAIN_MS) {
1337
1403
  export const WAIT = {
1338
1404
  needs: "run",
1339
1405
  name: "wait",
1340
- summary: "wait until every added table is synced, or a generated table holds what is synced now (1: a failure)",
1406
+ summary: "wait until every added table (or those named) is synced, or a generated table holds what is synced now (1: a failure)",
1341
1407
  usage: WAIT_USAGE,
1342
1408
  options: { json: { type: "boolean" }, timeout: { type: "string" } },
1343
1409
  json: ["ready", "failed", "waiting", "timedOut", "tables", "stream", "generatedTable", "error", "next"],
@@ -1347,18 +1413,26 @@ export async function wait(argv) {
1347
1413
  if (typeof f === "number")
1348
1414
  return f;
1349
1415
  const json = f.values.json === true;
1350
- const timeout = f.values.timeout === undefined ? 600 : Number(f.values.timeout);
1351
- if (!Number.isFinite(timeout) || timeout <= 0)
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];
1416
+ const read = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : "600");
1417
+ if ("problem" in read)
1418
+ return failed("wait", { message: `--timeout ${read.problem}`, code: "usage", usage: WAIT_USAGE }, { json });
1419
+ const timeout = read.seconds;
1420
+ const named = [...new Set(f.positionals)];
1356
1421
  const run = await runOr(WAIT, json);
1357
1422
  if (!run)
1358
1423
  return 1;
1359
- if (named !== undefined)
1360
- return waitGenerated(run, named, timeout, json);
1361
1424
  return guarded("wait", json, async () => {
1425
+ // tables named: a generated table alone waits for its chain; synced tables for their first syncs alone
1426
+ let only = null;
1427
+ if (named.length) {
1428
+ const pick = waitPick(await readState(run), named);
1429
+ if ("message" in pick)
1430
+ return failed("wait", pick, { json });
1431
+ if ("generated" in pick)
1432
+ return waitGenerated(run, pick.generated, timeout, json);
1433
+ only = new Set(pick.tables);
1434
+ }
1435
+ const again = named.length ? `mason wait ${named.join(" ")}` : "mason wait";
1362
1436
  const until = Date.now() + timeout * 1000;
1363
1437
  // a terminal gets the block — every added table on its line (tableLines), then the stream — redrawn in place;
1364
1438
  // anything else (a coding agent's shell, a log) the block when what is under way changes, and every 30 s, so a
@@ -1390,10 +1464,18 @@ export async function wait(argv) {
1390
1464
  };
1391
1465
  for (;;) {
1392
1466
  // a copy's rate and progress are the run's own, measured per chunk (src/duck/sync.ts copyProgressNow)
1393
- const s = await readState(run);
1467
+ const all = await readState(run);
1468
+ // named tables: the wait reads them alone, so its lines, its readiness and its answer are theirs
1469
+ const s = only === null ? all : { ...all, tables: all.tables.filter((t) => only.has(t.table)) };
1394
1470
  const r = readiness(s);
1395
1471
  const done = r.ready || r.failed.length > 0;
1396
1472
  const picked = s.tables.filter((t) => t.added);
1473
+ // a table named and removed while it is waited for (mason remove, the page) has nothing left to wait for
1474
+ if (only !== null && picked.length < only.size) {
1475
+ const gone = [...only].filter((n) => !picked.some((t) => t.table === n));
1476
+ return failed("wait", { message: `${gone.join(", ")} ${gone.length === 1 ? "is" : "are"} not added any more`, code: "not_found",
1477
+ next: [["mason status", "the added tables"]] }, { json });
1478
+ }
1397
1479
  // the outcome and what to run next, the same for a person and under --json
1398
1480
  const readyNext = picked.length ? [...READY_STEPS] : addSteps(s.tables);
1399
1481
  const failure = r.failed.length
@@ -1402,7 +1484,7 @@ export async function wait(argv) {
1402
1484
  next: [["mason logs", "the detail of each failure"], ["mason status", "the rest of the project"]] }
1403
1485
  : !done && Date.now() >= until
1404
1486
  ? { message: `not ready after ${timeout} s`, code: "timeout", why: r.waiting.join("\n"), fix: "nothing stopped: the syncs go on",
1405
- next: [["mason wait", "keep waiting (--timeout <seconds> for longer)"], ["mason status", "what Mason is doing"]] }
1487
+ next: [[again, "keep waiting (--timeout <duration> for longer)"], ["mason status", "what Mason is doing"]] }
1406
1488
  : null;
1407
1489
  if (json && (done || failure)) {
1408
1490
  out(jsonText(WAIT, { ...r, timedOut: !done, tables: picked.map(waitTable),
@@ -1414,7 +1496,9 @@ export async function wait(argv) {
1414
1496
  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"]];
1415
1497
  show([...tableLines(s), ...(s.stream ? ["Stream:", ...streamLines(s.stream).map((l) => ` ${l}`)] : [])], meanwhile); // done: the meanwhile Next is dropped
1416
1498
  if (r.ready) {
1417
- const head = !picked.length ? "Nothing to wait for: no table added yet." : "Ready: every added table is synced and served.";
1499
+ const head = !picked.length ? "Nothing to wait for: no table added yet."
1500
+ : only !== null ? `Ready: ${picked.map((t) => t.table).join(", ")} ${picked.length === 1 ? "is" : "are"} synced and served.`
1501
+ : "Ready: every added table is synced and served.";
1418
1502
  if (!json)
1419
1503
  out(withEnd(picked.length ? styleOut().green(head) : head, { next: readyNext }));
1420
1504
  return 0;
@@ -1425,6 +1509,44 @@ export async function wait(argv) {
1425
1509
  }
1426
1510
  });
1427
1511
  }
1512
+ /**
1513
+ * WHAT `mason wait <table>…` WAITS FOR: a generated table named alone (its chain: `waitGenerated`), or synced tables, each
1514
+ * found among the added ones as `mason sync` finds them (`findTable`) — a table of the database not added, or a name
1515
+ * nothing has, refused with the way out.
1516
+ */
1517
+ export function waitPick(s, names) {
1518
+ const generated = s.generatedTables ?? [];
1519
+ const added = s.tables.filter((t) => t.added);
1520
+ const tables = [];
1521
+ for (const name of names) {
1522
+ const g = generated.find((t) => t.table === name || t.table === `public.${name}`);
1523
+ if (g !== undefined) {
1524
+ if (names.length > 1) {
1525
+ return { message: `${entryName(g.table)} is a generated table, which is waited for alone`, code: "usage",
1526
+ fix: `mason wait ${entryName(g.table)}, then mason wait ${names.filter((n) => n !== name).join(" ")}`, next: [["mason wait --help", "what a wait waits for"]] };
1527
+ }
1528
+ return { generated: name };
1529
+ }
1530
+ const t = findTable(added, name);
1531
+ if (!("message" in t)) {
1532
+ tables.push(t.table);
1533
+ continue;
1534
+ }
1535
+ // a table of the database that is not added is told so, with the command that adds it
1536
+ const db = findTable(s.tables, name);
1537
+ if ("message" in db && db.code === "usage")
1538
+ return db;
1539
+ if (!("message" in db)) {
1540
+ return { message: `${db.table} is not added, so Mason does not sync it`, code: "not_found", fix: `mason add ${tableArg(db.table)}`, next: [["mason status", "the added tables"]] };
1541
+ }
1542
+ const choices = [...added.flatMap((x) => [x.table, tableArg(x.table)]), ...generated.map((x) => entryName(x.table))];
1543
+ const named = [...added.map((x) => tableArg(x.table)), ...generated.map((x) => entryName(x.table))];
1544
+ return { message: `there is no added or generated table ${JSON.stringify(name)}${didYouMean(name, choices)}`, code: "not_found",
1545
+ ...(named.length ? { fix: `name one of: ${named.join(", ")}` } : {}),
1546
+ next: [["mason status", "the added tables and the generated ones"], ["mason tables", "the database's tables"]] };
1547
+ }
1548
+ return { tables: [...new Set(tables)] };
1549
+ }
1428
1550
  /** `mason wait <generated table>`: until it holds every change synced when the wait began (src/cli/readiness.ts
1429
1551
  * generatedReadiness), a line when what it waits for changes; the same outcome and exit codes as the wait for copies */
1430
1552
  async function waitGenerated(run, named, timeout, json) {
@@ -1446,7 +1568,7 @@ async function waitGenerated(run, named, timeout, json) {
1446
1568
  ? { 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
1569
  : !r.ready && Date.now() >= until
1448
1570
  ? { 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] }
1571
+ next: [[`mason wait ${entryName(g.table)}`, "keep waiting (--timeout <duration> for longer)"], ...next] }
1450
1572
  : null;
1451
1573
  if (json && (r.ready || failure)) {
1452
1574
  out(jsonText(WAIT, { ...r, timedOut: !r.ready && failure?.code === "timeout", tables: [], generatedTable: g, ...(failure ? { error: errorJson(failure) } : {}) }, failure ? failure.next ?? [] : next));
@@ -1476,17 +1598,7 @@ export const EVENTS_LEAVE_OUT = "A scheduled poll that succeeds is not listed: t
1476
1598
  const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [<filters>]] [--events [--failed]] [--running] [--json]
1477
1599
 
1478
1600
  The last lines of Mason's log: every sync, poll and statement it ran, warnings and errors with their reasons —
1479
- running or not. With --requests, the run's request log instead: every statement a tool sent the
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).
1601
+ running or not.
1490
1602
 
1491
1603
  mason logs
1492
1604
  mason logs -f keep printing new lines as Mason works, until Ctrl-C
@@ -1505,7 +1617,8 @@ const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [<
1505
1617
  --failed with --requests: only the ones not answered (refused, failed or cancelled); with --events: only
1506
1618
  the failures
1507
1619
  --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)
1620
+ the machine's), cancelled (a cancel or a statement timeout, SQLSTATE 57014); a comma list or
1621
+ the flag repeated, the same
1509
1622
  --since <time> with --requests: started at or after it — an age (30m, 2h, 7d) or an ISO 8601 date or time with
1510
1623
  its zone (2026-10-28, 2026-10-28T09:00:00Z)
1511
1624
  --until <time> with --requests: started before it (as --since)
@@ -1515,18 +1628,32 @@ const LOGS_USAGE = `usage: mason logs [-n <lines>] [-f] [--start] [--requests [<
1515
1628
  --search <text> with --requests: only the statements whose text has it, in any case (at most ${MAX_SEARCH} characters)
1516
1629
  --before <pos> with --requests: the page before: the position a page answered with (its last Next line)
1517
1630
  --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
1631
+ --json as data: its shape is at the end
1632
+
1633
+ With --requests, the run's request log instead: every statement a tool sent the
1634
+ Postgres API that Mason answered, and every one it refused, failed or cancelled, a line each, newest last — when, the
1635
+ outcome and SQLSTATE, how long, the rows sent, the client's application_name (and the mason user's name), the
1636
+ statement and the error's first line; the filters narrow it, and a page that has older rows ends with the line that
1637
+ reads them (--before). With --events, the sync's events: each first sync that failed or finished, each poll, catch-up or
1638
+ stream that failed, the database out of reach, a table current again, each sync asked by hand (mason sync, the
1639
+ page's Sync now) and what it did, and each restart of the run — a line each, newest last (a failure repeated word for
1640
+ word is said once an hour). A scheduled poll that succeeds is not listed: the last one's result is each table's last
1641
+ sync (mason status, the Tables page's Last sync). Both are kept MASON_HISTORY_DAYS days
1642
+ (default 7) in the run's synced data, and read through the running Mason. With --running, the statements running now
1643
+ instead, one per session, each with its number (mason cancel <session> --statement <n> stops one).
1644
+
1645
+ The --json answer: { lines: [{ at, text }] } (the log's entries, oldest first; at is null for a line with no
1646
+ time); with --requests { requests: [{ requestId, startedAt, endedAt, session, applicationName,
1647
+ clientAddress (the client's address; behind a TCP proxy, the one its PROXY header carries),
1648
+ tokenName, user (the mason user), role, attributes (the user's values its role's row filters
1649
+ read, or null), method, outcome (ok, refused, failed, cancelled), status
1650
+ (completed, failed, cancelled: what a reader shows), sqlstate, message, detail, hint, rows,
1651
+ bytesSent, durationMs, query, queryTruncated }], keptDays, before (the position of the page before,
1652
+ or null: none older) }; with --events { events: [{ at, kind (copy_failed, poll_failed,
1653
+ catch_up_failed, stream_failed, source_unreachable, restart, copied, recovered, synced (a sync
1654
+ asked by hand), sync_changed (a table's sync: changed)), failed, table (null: the whole sync),
1655
+ message }], keptDays }; with --running { running: [{ session, user,
1656
+ applicationName, method, startedAt, runningMs, rows (sent so far), query, queryTruncated }] }; next
1530
1657
  `;
1531
1658
  export const LOGS = {
1532
1659
  needs: "project",
@@ -1534,7 +1661,7 @@ export const LOGS = {
1534
1661
  summary: "Mason's log (-f follows it); --requests: the Postgres API's statements; --events: the sync's; --running: running now",
1535
1662
  usage: LOGS_USAGE,
1536
1663
  options: { lines: { type: "string", short: "n" }, follow: { type: "boolean", short: "f" }, start: { type: "boolean" },
1537
- requests: { type: "boolean" }, events: { type: "boolean" }, failed: { type: "boolean" }, outcome: { type: "string" }, since: { type: "string" },
1664
+ requests: { type: "boolean" }, events: { type: "boolean" }, failed: { type: "boolean" }, outcome: { type: "string", multiple: true }, since: { type: "string" },
1538
1665
  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
1666
  json: ["lines", "requests", "events", "keptDays", "before", "running", "next"],
1540
1667
  };
@@ -1674,7 +1801,7 @@ async function runningLogs(json) {
1674
1801
  /** A refusal the run answered, in the Failure shape it carries (`failure`, src/cli/run-server.ts sendFailure). */
1675
1802
  export function runFailure(reply, next) {
1676
1803
  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";
1804
+ const code = failureCode(f?.code, reply.status === 400 ? "usage" : reply.status === 404 ? "not_found" : reply.status === 409 ? "refused" : "failed");
1678
1805
  return { message: typeof f?.message === "string" ? f.message : String(reply.body.error ?? `the run answered ${reply.status}`), code,
1679
1806
  ...(typeof f?.why === "string" ? { why: f.why } : {}), ...(typeof f?.fix === "string" ? { fix: f.fix } : {}), next };
1680
1807
  }
@@ -1682,6 +1809,9 @@ export async function logs(argv) {
1682
1809
  const f = parseCommand(LOGS, argv);
1683
1810
  if (typeof f === "number")
1684
1811
  return f;
1812
+ const extra = noArguments(LOGS, f.positionals, f.values.json === true);
1813
+ if (extra !== null)
1814
+ return extra;
1685
1815
  const json = f.values.json === true;
1686
1816
  if (f.values.requests && f.values.events)
1687
1817
  return failed("logs", { message: "--requests and --events are two lists: ask for one", code: "usage", fix: "mason logs --requests, or mason logs --events" }, { json });
@@ -1711,7 +1841,7 @@ export async function logs(argv) {
1711
1841
  if (f.values.requests) {
1712
1842
  // the filters, read as the run reads its query string (src/cli/request-filter.ts): a mistake is said here, exit 2
1713
1843
  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),
1844
+ const q = requestFilter({ limit: text(f.values.lines), failed: f.values.failed === true, outcome: listFlag(f.values.outcome), since: text(f.values.since),
1715
1845
  until: text(f.values.until), user: text(f.values.user), client: text(f.values.client),
1716
1846
  slow: f.values.slow === true, search: text(f.values.search), before: text(f.values.before) }, Date.now(), flagOf);
1717
1847
  if ("message" in q)
@@ -1775,9 +1905,9 @@ const STOP_USAGE = `usage: mason stop [--json]
1775
1905
 
1776
1906
  mason stop
1777
1907
 
1778
- mason destroy removes the synced data and the state too (your files stay), to free the disk or begin again.
1779
-
1780
1908
  --json as data: { stopped: [name], killed (a run that did not answer and ignored SIGTERM), supervisor (its pid, when one was stopped), next }
1909
+
1910
+ mason destroy removes the synced data and the state too (your files stay), to free the disk or begin again.
1781
1911
  `;
1782
1912
  export const STOP = {
1783
1913
  needs: "project",
@@ -1791,6 +1921,9 @@ export async function stop(argv) {
1791
1921
  const f = parseCommand(STOP, argv);
1792
1922
  if (typeof f === "number")
1793
1923
  return f;
1924
+ const extra = noArguments(STOP, f.positionals, f.values.json === true);
1925
+ if (extra !== null)
1926
+ return extra;
1794
1927
  const json = f.values.json === true;
1795
1928
  const next = [["mason start", "resume, in seconds (in the project's folder)"], ["mason destroy", "remove its synced data and its state; your files stay"]];
1796
1929
  const r = await theRun(process.cwd());