@mason-data/cli 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -3,17 +3,23 @@
3
3
  * command's summary, in this order — so a command cannot exist without being listed, and tests/cli-help.test.ts holds
4
4
  * each one to src/cli/command.ts's rules (its own --help, an example, every option documented).
5
5
  */
6
- import { failed, parseCommand, reportText } from "./command.js";
6
+ import { failed, parseCommand, reportText, wantsJson } from "./command.js";
7
+ import { removedStartFlags } from "../lib/table-sync.js";
8
+ import { sourcesTop } from "./sources.js";
7
9
  import { dev } from "./dev.js";
8
10
  import { devInBackground } from "./background.js";
9
11
  import { ADD, LOGS, REMOVE, STATUS, STOP, TABLES, WAIT, add, logs, remove, status, stop, tables, wait } from "./commands.js";
10
12
  import { USER, user } from "./user.js";
13
+ import { MCP, mcp } from "./mcp.js";
14
+ import { SHARE, share } from "./share.js";
11
15
  import { UI, ui } from "./ui.js";
12
16
  import { GUIDES, guideText } from "./guides.js";
13
17
  import { DOCTOR, NODE_MIN, doctor } from "./doctor.js";
14
18
  import { DESTROY, destroy } from "./machine.js";
15
19
  import { INIT, init } from "./init.js";
16
20
  import { SYNC, sync } from "./sync.js";
21
+ import { USAGE, usage } from "./usage.js";
22
+ import { CANCEL, cancel } from "./cancel.js";
17
23
  import { SERVE, serve } from "./serve.js";
18
24
  import { PROJECT_LINE } from "./dev.js";
19
25
  import { version } from "./version.js";
@@ -26,21 +32,31 @@ export const DEV = {
26
32
  needs: "project",
27
33
  name: "start",
28
34
  summary: "start Mason for this folder's project, in the background (mason init makes the project)",
29
- usage: `usage: mason start [--attach] [--replication [--publication <name>] | --poll] [--self-signed-tls] [--port <n>] [--json]
35
+ usage: `usage: mason start [--attach] [--self-signed-tls] [--port <n>] [--json]
30
36
 
31
- Starts Mason for the project of this folder (mason init made it): one process, which copies the tables you add
32
- (mason add) from the project's database into its own file, keeps them current, and answers the Postgres protocol
33
- for BI tools and your app. It only reads the database (SELECT): no wal_level, replication slot or publication is
34
- needed or made. It returns once Mason is up; run it again any time (it says so when Mason is already running). The
35
- copy and Mason's state stay between runs: the next start resumes.
37
+ Starts Mason for the project of this folder (mason init made it): one process, which syncs the tables you add
38
+ (mason add) from the project's database into its own file, keeps them current as the sources file says, and answers
39
+ the Postgres protocol for BI tools and your app. It only reads the database (SELECT, and a table's change log when
40
+ its sync: says replication). It returns once Mason is up; run it again any time (it says so when Mason is
41
+ already running). The synced data and Mason's state stay between runs: the next start resumes.
36
42
 
37
- With --replication it reads the database's change log instead: it needs wal_level = logical and a user that may
38
- replicate, and makes its own publication and replication slot on the database (mason destroy, or mason start --poll,
39
- drops them). The project keeps the mode until --poll; a switch keeps the copies and catches each table up by one
40
- poll. --publication <name> (else PG_PUBLICATION) names a publication that is the database owner's: it is read as it
41
- is, verified to carry every added table and column, and never created, altered or dropped — Mason makes only its
42
- slot. A read replica needs one (it takes no CREATE PUBLICATION). The name is kept with the project, so the next
43
- mason start --replication reads it again.
43
+ After its first sync, each table is kept current as the sources file's sync: says — at the top for every table, or
44
+ on a table's entry: off (the default, which mason init writes) reads it again only when you run mason sync <table>;
45
+ poll <interval> (poll 5s, poll 5m, poll 1h) polls it with SELECTs at that interval; replication reads its changes
46
+ from the database's change log, as they are made. Each poll scans its table whole
47
+ on your database, and the next waits at least four times as long as that poll took, so the polls never take more
48
+ than a fifth of your database's time. The start says the file's default once; mason status says how each table is
49
+ kept current and when it was last synced. A table whose sync: changed keeps its synced data and is caught up by one poll.
50
+
51
+ A table under sync: replication needs wal_level = logical, a user that may replicate, and a primary key (a key:
52
+ table is polled): Mason makes one replication slot for the project and its own publication, carrying the replicated
53
+ tables alone, and drops them, verified gone, when no table's sync is replication any more (and at mason destroy).
54
+ publication: <name> at the top of the sources file (\${PG_PUBLICATION} reads it from .env) names a publication that
55
+ is the database owner's: it is read as it is, verified to carry every replicated table and column, and never
56
+ created, altered or dropped — Mason makes only its slot. A read replica needs one (it takes no CREATE PUBLICATION).
57
+
58
+ --replication, --poll and --publication are gone: each answers with the line to write at the top of the sources
59
+ file (sync: replication, sync: poll 5m, publication: <name>) and exit 2, and starts nothing.
44
60
 
45
61
  The Postgres API's port is the project's: the first start takes 5433, or the next port free on every address when a
46
62
  second Postgres holds it, and records it; every later start listens on the same port, so a BI tool's saved connection
@@ -57,15 +73,9 @@ export const DEV = {
57
73
 
58
74
  mason start
59
75
  mason start --attach in the foreground: Ctrl-C stops it
60
- mason start --replication read the change log, as production does
61
- mason start --replication --publication app_pub through the database owner's publication
62
76
  MASON_PG_HOST=0.0.0.0 mason start --self-signed-tls the Postgres API for other machines, over TLS
63
77
 
64
78
  --attach stay in the foreground (Ctrl-C stops Mason)
65
- --replication read the database's changes from its change log (logical replication), as production does
66
- --publication <name> with --replication: the publication to read, the owner's, never altered (PG_PUBLICATION;
67
- default: Mason's own, mason_dev_<project>_<source>)
68
- --poll read them with SELECTs (the default; takes back --replication)
69
79
  --self-signed-tls the Postgres API requires TLS, from a certificate made for this project in .mason/ (kept and
70
80
  served again by every later start with the flag); not with MASON_PG_TLS_CERT / _KEY
71
81
  --port <n> the port the mason commands reach it on (default 4039, or the next free one)
@@ -73,22 +83,22 @@ export const DEV = {
73
83
  `,
74
84
  options: {
75
85
  port: { type: "string" }, attach: { type: "boolean", default: false }, json: { type: "boolean", default: false },
76
- replication: { type: "boolean", default: false }, poll: { type: "boolean", default: false }, publication: { type: "string" },
77
86
  "self-signed-tls": { type: "boolean", default: false },
78
87
  },
79
88
  json: ["running", "pid", "postgres", "next"],
80
89
  };
81
90
  async function runDev(argv) {
91
+ // --replication, --poll and --publication are gone (issue 33): each answered with the edit to make in the sources
92
+ // file, exit 2, before the options are parsed (it would be an unknown option there) and before anything starts. The
93
+ // file is read only when one is given. A decision: `mason start --replication --help` answers the refusal, not the
94
+ // usage — the flag is wrong whatever else the line asks
95
+ const removed = removedStartFlags(argv, () => startSourcesTop(process.cwd()));
96
+ if (removed !== null)
97
+ return failed("start", { code: "usage", ...removed, next: [["mason start", "each table kept current as its sync: says"], ["mason help production", "sync: and publication:, and what each needs"]] }, { json: wantsJson(argv, DEV) });
82
98
  const f = parseCommand(DEV, argv);
83
99
  if (typeof f === "number")
84
100
  return f;
85
101
  const v = f.values;
86
- if (v.replication && v.poll)
87
- return failed("start", { message: "--replication or --poll, not both", code: "usage", usage: DEV.usage }, { json: v.json });
88
- if (v.publication !== undefined && v.poll)
89
- return failed("start", { message: "--publication goes with --replication, not --poll", code: "usage", usage: DEV.usage }, { json: v.json });
90
- if (v.publication !== undefined && !/^\S+$/.test(v.publication))
91
- return failed("start", { message: `--publication must name a publication, not ${JSON.stringify(v.publication)}`, code: "usage", usage: DEV.usage }, { json: v.json });
92
102
  if (f.positionals.length) {
93
103
  // the database is the project's now: a URL here is the old habit, answered with the new way
94
104
  return failed("start", { message: "it takes no database URL: the project has one", code: "usage", why: "mason init <url> made it; .mason/config.json holds it", usage: DEV.usage }, { json: v.json });
@@ -98,7 +108,13 @@ async function runDev(argv) {
98
108
  return failed("start", { message: `--port must be a port number (1-65535), not ${JSON.stringify(v.port)}`, code: "usage" }, { json: v.json });
99
109
  if (!v.attach)
100
110
  return devInBackground(argv, { json: v.json, selfSignedTls: v["self-signed-tls"] });
101
- return dev({ port, version: version(), changes: v.replication ? "replication" : v.poll ? "poll" : null, publication: v.publication ?? null, selfSignedTls: v["self-signed-tls"] });
111
+ return dev({ port, version: version(), selfSignedTls: v["self-signed-tls"] });
112
+ }
113
+ /** The project's sources file as a person names it (`mason/sources/app.yml`), for a removed flag's answer: the one file
114
+ * of the project of this folder, else the name every project's has. */
115
+ function startSourcesTop(cwd) {
116
+ const t = theProject(cwd);
117
+ return "problem" in t ? { shown: "mason/sources/<name>.yml", sync: null, publication: null } : sourcesTop(t.project.root, t.project.folder);
102
118
  }
103
119
  export const HELP = {
104
120
  name: "help",
@@ -151,8 +167,8 @@ export const STEPS = [
151
167
  ["Learn", [{ ...HELP, run: runHelp }]],
152
168
  ["Set up", [{ ...DOCTOR, run: (argv) => doctor(argv) }, { ...INIT, run: (argv) => init(argv) }, { ...DEV, run: runDev }]],
153
169
  ["Add your tables", [{ ...TABLES, run: tables }, { ...ADD, run: add }, { ...REMOVE, run: remove }, { ...WAIT, run: wait }]],
154
- ["Connect", [{ ...USER, run: user }]],
155
- ["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
170
+ ["Connect", [{ ...USER, run: user }, { ...MCP, run: mcp }, { ...SHARE, run: share }]],
171
+ ["Run", [{ ...STATUS, run: status }, { ...UI, run: ui }, { ...SYNC, run: sync }, { ...LOGS, run: logs }, { ...CANCEL, run: cancel }, { ...USAGE, run: (argv) => usage(argv) }, { ...STOP, run: stop }, { ...DESTROY, run: (argv) => destroy(argv) }]],
156
172
  ["Go to production", [{ ...SERVE, run: serve }]],
157
173
  ];
158
174
  /** Every command, in the order `mason --help` lists them. */
@@ -163,6 +179,9 @@ export const COMMANDS = STEPS.flatMap(([, commands]) => commands);
163
179
  * it, then the next command for that moment — and mason help, every command. `mason help` and `mason --help` are the
164
180
  * whole list (topUsage).
165
181
  */
182
+ /** WHAT MASON IS, in one line: the first `mason` alone and `mason --help` print, in the words its reader was told it
183
+ * in ("a drop-in read replica for analytics"). */
184
+ export const TAGLINE = "Mason: a drop-in read replica of your Postgres, for analytics — the tables you add, synced, kept current and served to psql, BI tools and your app.";
166
185
  export async function orientation(cwd, s = styleOut()) {
167
186
  const t = theProject(cwd);
168
187
  const project = "project" in t ? t.project : null;
@@ -171,10 +190,12 @@ export async function orientation(cwd, s = styleOut()) {
171
190
  ? [["Project", `${project.config.name}, files in ${project.folder}`], ["Mason", run ? `running (pid ${run.pid})` : "not running"]]
172
191
  : [["Project", "none here: mason init makes this folder one"]];
173
192
  const next = !project
174
- ? [["mason init", "make this folder a project (DATABASE_URL from .env, or mason init postgres://…)"], ["mason doctor", "what this machine needs for Mason, and whether it has it"]]
193
+ ? [["mason init", "make this folder a project (MASON_DATABASE_URL or DATABASE_URL from .env, or mason init postgres://…)"],
194
+ ["mason init demo", "or try it on a demo database Mason makes itself: nothing to install"],
195
+ ["mason doctor", "what this machine needs for Mason, and whether it has it"]]
175
196
  : !run ? [["mason start", "start Mason for this project, in the background"], ["mason status", "the project and its tables"]]
176
197
  : [["mason status", "what Mason is doing: its tables, and the last day's slow and refused statements"], ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"]];
177
- return `${reportText({ headline: "Mason: your Postgres tables, copied and served to BI tools and your app.", details: rows,
198
+ return `${reportText({ headline: TAGLINE, details: rows,
178
199
  next: [...next, ["mason help", "every command, and the guides"]] }, s)}\n`;
179
200
  }
180
201
  /** `mason --help`: what Mason is, every command with its summary, and where each one's help is. On a terminal the steps are
@@ -201,15 +222,16 @@ export function topUsage(s = styleOut()) {
201
222
  return [
202
223
  `${s.bold("usage:")} mason <command> [options]`,
203
224
  "",
204
- para("Mason: your Postgres tables, copied and served to BI tools and your app. Make your app's folder a project (mason init), " +
205
- `start Mason (mason start), add tables, connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
225
+ para(`${TAGLINE} Make your app's folder a project (mason init), ` +
226
+ `add tables (mason add, before or after the start), start Mason (mason start), connect a tool (mason user add). It needs Node ${NODE_MIN} or later; mason doctor checks.`),
227
+ para("Try it first with nothing to install: mason init demo, then mason start and mason ui (a demo shop database, hosted by Mason itself)."),
206
228
  para(PROJECT_LINE),
207
229
  "",
208
230
  listed.map(([step, es]) => ` ${s.bold(step)}\n${block(es)}`).join("\n\n"),
209
231
  "",
210
232
  para("mason <command> --help (or mason help <command>) says how to use one, with examples; the guides are offline and short enough to read whole."),
211
233
  para("A command means the project of the folder it runs in (or above it)."),
212
- para("Every command but help takes --json: one JSON object for a program, always with next (what to run " +
234
+ para("Every command but help and mcp (whose stdout is the MCP protocol's) takes --json: one JSON object for a program, always with next (what to run " +
213
235
  "next); a failure is { error: { code, message, why, fix }, next }. The exit code says how it went: 0 done, 1 failed " +
214
236
  "with the reason, 2 a wrong command line, 124 mason wait timed out. Driving Mason from a coding agent: mason help agent."),
215
237
  "",
@@ -64,6 +64,8 @@ export function suggestedWalCapGb(bytesPerSecond) {
64
64
  return null;
65
65
  return Math.max(4, Math.ceil((bytesPerSecond * 86_400) / 1024 ** 3));
66
66
  }
67
+ /** What Mason does about a lost slot (src/duck/sync.ts `prepareReplication`), one wording for every check that says it. */
68
+ const SLOT_REMADE = "Mason makes a new slot and catches each table up with one poll (a scan of each table on your database), never a full sync";
67
69
  /** What wal_level = logical takes on this server: its provider's way, or postgresql.conf's. */
68
70
  function walLevelFix(f) {
69
71
  if (f.rds)
@@ -77,12 +79,12 @@ export function replicationChecks(f, slot, slotState) {
77
79
  const out = [];
78
80
  out.push(f.walLevel === "logical"
79
81
  ? { name: "wal_level", ok: true, detail: "logical" }
80
- : { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or read changes by polling instead (--poll: SELECT alone, for a small database)` });
82
+ : { name: "wal_level", ok: false, detail: `${f.walLevel}: logical replication needs logical. To fix: ${walLevelFix(f)}. Or poll the table instead: give its entry sync: poll <interval> in the sources file (SELECTs alone, no change log)` });
81
83
  if (f.standby) {
82
84
  out.push(f.version >= 160000
83
85
  ? { name: "standby", ok: true, warn: !f.hotStandbyFeedback,
84
- detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : ". hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (Mason then rebuilds from scratch) — turn it on"}` }
85
- : { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll it (--poll)` });
86
+ detail: `a read replica (Postgres 16+ decodes on a standby)${f.hotStandbyFeedback ? "" : `. hot_standby_feedback is off: the primary may remove catalog rows the slot still needs, which invalidates it (${SLOT_REMADE}) — turn it on`}` }
87
+ : { name: "standby", ok: false, detail: `a read replica on Postgres ${f.versionText}: logical decoding on a standby needs Postgres 16. Point Mason at the primary, upgrade the replica, or poll the table: give its entry sync: poll <interval> in the sources file` });
86
88
  }
87
89
  const canReplicate = f.superuser || f.replication || f.rds?.member === true;
88
90
  out.push(canReplicate
@@ -92,7 +94,7 @@ export function replicationChecks(f, slot, slotState) {
92
94
  const free = f.maxSlots - f.slotsUsed;
93
95
  if (slotState) {
94
96
  out.push(slotState.walStatus === "lost"
95
- ? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): Mason drops it and rebuilds from scratch at its start` }
97
+ ? { name: "slot", ok: true, warn: true, detail: `${slot} exists and Postgres invalidated it (wal_status lost): ${SLOT_REMADE}` }
96
98
  : slotState.active
97
99
  ? { name: "slot", ok: false, detail: `${slot} is in use by another reader${slotState.activePid !== null ? ` (pid ${slotState.activePid} on the database)` : ""}: one slot has one reader. ` +
98
100
  `Stop the other Mason for this project (mason stop). A run that crashed holds it until its connection ends${slotState.activePid !== null ? `: SELECT pg_terminate_backend(${slotState.activePid}) ends it now` : ""}` }
@@ -110,7 +112,7 @@ export function replicationChecks(f, slot, slotState) {
110
112
  const size = gb === null ? "a day of this database's WAL" : `${gb}GB`;
111
113
  const basis = gb === null ? "" : ` (a day of the WAL this server writes: ${bytesText(f.walBytesPerSecond)}/s since its statistics were reset)`;
112
114
  out.push(f.slotWalKeepMb >= 0
113
- ? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres drops the slot rather than fill its disk, and Mason rebuilds from scratch${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means a rebuild` : ""}` }
115
+ ? { name: "WAL cap", ok: true, detail: `max_slot_wal_keep_size = ${f.slotWalKeepMb >= 1024 ? `${(f.slotWalKeepMb / 1024).toFixed(f.slotWalKeepMb % 1024 ? 1 : 0)}GB` : `${f.slotWalKeepMb}MB`}: past it Postgres invalidates the slot rather than fill its disk, and ${SLOT_REMADE}${gb !== null && f.slotWalKeepMb < gb * 1024 ? `. A day of this server's WAL is about ${gb}GB${basis}: an outage longer than the cap's worth means that catch-up` : ""}` }
114
116
  : { name: "WAL cap", ok: false,
115
117
  detail: `max_slot_wal_keep_size is unlimited: while Mason is stopped its slot holds every byte of WAL, until the disk fills and Postgres stops taking writes. Cap it at the outage you want Mason to survive, e.g. ${size}${basis}` +
116
118
  (f.rds || f.cloudSql ? ` — in the ${f.rds ? "DB parameter group" : "database flags"} (max_slot_wal_keep_size, in MB)` : ""),
@@ -1,15 +1,17 @@
1
1
  /**
2
- * `mason start --replication` (the journey to production, item 6): a project's run reading its database's
3
- * change log as production does — a publication, a replication slot, `wal_level = logical` — instead of polling it with
4
- * SELECTs (src/cdc/poll.ts). A developer then meets the setup production needs on their own database first. The mode is
5
- * the project's (`.mason/config.json` `changes`), kept between starts until `--poll` takes it back.
2
+ * LOGICAL REPLICATION FOR THE TABLES WHOSE `sync:` SAYS SO (the journey to production, item 6; issue 33): a project's run
3
+ * reading its database's change log as production does — a publication, a replication slot, `wal_level = logical` —
4
+ * for each table whose sync is `replication`, beside the polled ones (src/cdc/poll.ts). A developer then meets the setup
5
+ * production needs on their own database first. The sources file decides it, per table, and nothing else does
6
+ * (`mason start --replication`, `--poll`, `--publication` and `MASON_CHANGES` are gone: src/lib/table-sync.ts
7
+ * `removedStartFlags`, `changesRetired`).
6
8
  *
7
9
  * What it costs the developer's database, said where it happens: Mason creates its slot there (`mason_<deployment>`), and
8
- * a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or going back to polling, drops
10
+ * a slot holds WAL while nobody reads it — so `mason stop` says so, and `mason destroy`, or no table replicated any more, drops
9
11
  * it (`dropOwnReplication`), verified gone, by the name the start that made it RECORDED (`madeReplication`), never one
10
12
  * computed again. The PUBLICATION is Mason's own (`mason_<deployment>`, made and dropped with
11
- * the slot) unless the operator names one (`--publication <name>`, else `PG_PUBLICATION`, else the one
12
- * an earlier start recorded): that one is the database owner's, read as it is and never created, altered or dropped
13
+ * the slot) unless the operator names one (`publication:` at the top of the sources file, else `PG_PUBLICATION`):
14
+ * that one is the database owner's, read as it is and never created, altered or dropped
13
15
  * (src/duck/publication.ts) — on a read replica it is the only way, since CREATE PUBLICATION is refused on a standby.
14
16
  *
15
17
  * In the ClickHouse service this was cut from, the two modes kept row versions that could not continue each other (a
@@ -20,6 +22,8 @@
20
22
  import { createHash } from "node:crypto";
21
23
  import { replicationNames } from "../cdc/source.js";
22
24
  import { connectSource, systemIdentifier } from "./preflight.js";
25
+ import { STATE_DIR } from "./project.js";
26
+ import { replicatedEntries, silentOverSlot, syncWritten } from "./sources.js";
23
27
  import { readServerFacts, readSlotFacts, replicationChecks } from "./replication-checks.js";
24
28
  /** Postgres's longest identifier (NAMEDATALEN - 1): a longer slot name is refused, a longer publication's cut with a notice. */
25
29
  export const PG_NAME_BYTES = 63;
@@ -38,8 +42,8 @@ export function deploymentOf(project, source) {
38
42
  return `${full.slice(0, room - 9)}_${createHash("sha256").update(full).digest("hex").slice(0, 8)}`;
39
43
  }
40
44
  /**
41
- * The publication and slot a project's run reads: the publication `named` (the flag, `PG_PUBLICATION` or the recorded
42
- * one: `namedPublication`), else Mason's own — by the change reader's own rule (src/cdc/source.ts `replicationNames`),
45
+ * The publication and slot a project's run reads: the publication `named` (the sources file's or `PG_PUBLICATION`:
46
+ * `namedPublication`, or the recorded one), else Mason's own — by the change reader's own rule (src/cdc/source.ts `replicationNames`),
43
47
  * which also says whether it is Mason's to make. The slot is always the project's own.
44
48
  */
45
49
  export function projectReplication(name, source, named = null) {
@@ -48,27 +52,69 @@ export function projectReplication(name, source, named = null) {
48
52
  }
49
53
  /**
50
54
  * WHAT REPLICATION MODE MADE ON THE DATABASE, as the start that made it recorded it (`config.json` `replication`): what
51
- * `--poll` and `mason destroy` drop. Recorded, never computed again, so a name that changes since — the sources file
55
+ * a start whose sources file replicates no table, and `mason destroy`, drop. Recorded, never computed again, so a name that changes since — the sources file
52
56
  * renamed, the naming rule — never leaves a slot that nobody drops, holding the database's WAL for ever. A project with
53
57
  * no record (none started in replication mode since it was kept) has the names the rule gives now.
54
58
  */
55
59
  export function madeReplication(cfg, source) {
56
60
  return cfg.replication ?? projectReplication(cfg.name, source, cfg.publication ?? null);
57
61
  }
58
- /** The publication an operator named, and where: `--publication`, else `PG_PUBLICATION`, else the one an earlier start
59
- * of this copy recorded (`config.json` `publication`), else none (Mason's own). */
60
- export function namedPublication(flag, env, recorded) {
61
- if (flag)
62
- return { name: flag, from: "flag" };
62
+ /**
63
+ * THE PUBLICATION A START READS, and where it is named: the sources file's `publication:` (its `${PG_PUBLICATION}`
64
+ * resolved), else `PG_PUBLICATION` in the environment, else none — Mason's own, which `publication: null` asks for past
65
+ * the environment too. NEVER the one an earlier start recorded, read silently: a record the file does not take up is
66
+ * refused (`recordRefusal`), so what runs is what is committed.
67
+ */
68
+ export function namedPublication(file, env) {
69
+ if (file.publicationName !== null)
70
+ return { name: file.publicationName, from: "file" };
71
+ if (file.publication && "own" in file.publication)
72
+ return null;
63
73
  if (env.PG_PUBLICATION)
64
74
  return { name: env.PG_PUBLICATION, from: "env" };
65
- if (recorded)
66
- return { name: recorded, from: "recorded" };
67
75
  return null;
68
76
  }
69
- /** The mode a start runs: the flag, else the project's, else polling. */
70
- export function changesOf(flag, recorded) {
71
- return flag ?? recorded ?? "poll";
77
+ /**
78
+ * WHAT `.mason/config.json` RECORDS AND THE SOURCES FILE DOES NOT SAY, refused before anything is written or connected
79
+ * (one report, for the start that is typed, the run it spawns and `mason serve`). The file says how a project replicates,
80
+ * and a file that is silent is an omission, not a decision, whose price is on someone's production database:
81
+ * - a project that streams (a slot recorded, of any release, or an older Mason's `changes: "replication"`) under a file
82
+ * naming tables with no `sync:` at its top or on any entry would have its slot dropped, and streaming again copies
83
+ * every table whole. Any `sync:` written is the file saying so: `off` or `poll <interval>` confirms the drop,
84
+ * `replication` keeps the slot. The run's re-read holds the same line (`silentOverSlot`, `rereadSources`).
85
+ * - a project that replicates through a publication it recorded, with no `publication:` and no `PG_PUBLICATION`,
86
+ * would silently make and read a publication of Mason's own. The literal `publication: null` asks for that on purpose.
87
+ * - `publication: null` beside a set `PG_PUBLICATION` says two things, and one of them is a CREATE on the source.
88
+ * `file` null: there is no sources file (a serve would write one saying `sync: off`, which must not read as a choice). A
89
+ * project is told the first two at once: the first edit must not meet the second refusal.
90
+ */
91
+ export function recordRefusal(cfg, file, env, shown, cmd) {
92
+ const recorded = cfg.publication ?? null;
93
+ const declined = file?.publication != null && "own" in file.publication;
94
+ const unnamed = recorded !== null && !declined && namedPublication(file ?? { publicationName: null }, env) === null;
95
+ const next = [[cmd, "once the file says it; nothing was dropped or changed"]];
96
+ if (declined && env.PG_PUBLICATION) {
97
+ return { message: `${shown} says publication: null, and PG_PUBLICATION names ${env.PG_PUBLICATION}`, code: "refused",
98
+ why: "one asks Mason to make a publication of its own on your database, the other to read that one, and the file would win by saying nothing of the setting",
99
+ fix: `for Mason's own, unset PG_PUBLICATION; to read ${env.PG_PUBLICATION}, take publication: null out of ${shown}`, next };
100
+ }
101
+ const slotRecorded = (cfg.replication ?? null) !== null;
102
+ const older = cfg.changes === "replication";
103
+ if (file === null ? slotRecorded || older : older ? !syncWritten(file) : silentOverSlot(slotRecorded, file)) {
104
+ const slot = cfg.replication?.slot;
105
+ const made = `${slot === undefined ? "its replication slot" : `replication slot ${slot}`}${(cfg.replication?.owned ?? recorded === null) ? " and Mason's publication" : ""}`;
106
+ return { message: `this project streams, and ${file === null ? "it has no sources file to say so" : `${shown} does not say so: no sync: at its top or on an entry`}`, code: "refused",
107
+ why: `${older ? `an older Mason recorded that this project streams every table (changes: replication in ${STATE_DIR}/config.json)` : `${STATE_DIR}/config.json records that this project streams`}; left unsaid, sync is off, which drops ${made}, and streaming again reads every table whole from your database`,
108
+ fix: file === null
109
+ ? `mount the project folder with its sources file (MASON_PROJECT), or make ${shown} there with kind: postgres, url: \${DATABASE_URL}, tables: and the tables it streams, and sync: replication${unnamed ? ` and publication: ${recorded}` : ""} to keep streaming; sync: off or sync: poll <interval> in it instead confirms dropping ${made}`
110
+ : `to keep streaming, write sync: replication${unnamed ? ` and publication: ${recorded}` : ""} at the top of ${shown}; to drop ${made}, confirm it with sync: off or sync: poll <interval> there`, next };
111
+ }
112
+ if (unnamed && file !== null && replicatedEntries(file).length > 0) {
113
+ return { message: `this project reads publication ${recorded}, and ${shown} does not name it`, code: "refused",
114
+ why: `${STATE_DIR}/config.json records ${recorded}, its owner's, named at an earlier start; with no publication: line and no PG_PUBLICATION, Mason would make and read a publication of its own on your database instead`,
115
+ fix: `to keep reading it, write publication: ${recorded} at the top of ${shown}; for a publication of Mason's own, write publication: null there`, next };
116
+ }
117
+ return null;
72
118
  }
73
119
  /**
74
120
  * Whether the developer's database can be read by replication: what refuses it (wal_level, the role — REPLICATION, a
@@ -81,7 +127,7 @@ export function readinessVerdict(facts, slot, slotFacts, repl) {
81
127
  // a standby takes no CREATE PUBLICATION (it is read-only): the publication must be the primary's, named
82
128
  if (facts.standby && repl.owned) {
83
129
  checks.push({ name: "publication", ok: false, detail: `this database is a read replica, where Mason cannot make its publication ${repl.publication} (CREATE ` +
84
- "PUBLICATION is refused on a standby). Create one on the primary for the tables you add, then name it: mason start --replication --publication <name>" });
130
+ "PUBLICATION is refused on a standby). Create one on the primary for the tables whose sync: is replication, then name it: publication: <name> at the top of the sources file" });
85
131
  }
86
132
  // a laptop's database has no cap on its slots by default: worth saying, never a reason to refuse a dev run
87
133
  return { refused: checks.filter((x) => x.ok === false && x.name !== "WAL cap"), warnings: checks.filter((x) => (x.name === "WAL cap" && x.ok === false) || x.warn === true) };
@@ -97,18 +143,51 @@ export async function replicationReadiness(source, repl) {
97
143
  c.pg.close();
98
144
  }
99
145
  }
146
+ /**
147
+ * A TABLE SET TO `sync: replication` WHILE THE RUN IS UP, in a run that replicated nothing when it started (so the
148
+ * database was never checked for it): the start's own checks, as the edit's refusal — what the database lacks, and the
149
+ * line that keeps the table polled — or null when it may replicate. `tables` are the entries' names (`orders`).
150
+ */
151
+ export function liveReadinessFailure(ready, tables, shown, database) {
152
+ const refused = "failure" in ready ? [{ name: "database", ok: false, detail: ready.failure }] : ready.refused;
153
+ if (!refused.length)
154
+ return null;
155
+ const one = tables.length === 1;
156
+ return { message: `${tables.join(", ")} cannot be kept current by logical replication from ${database}: ${refused.map((c) => c.name).join(", ")}`, code: "refused",
157
+ why: refused.map((c) => `${c.name}: ${c.detail}${c.sql ? ` (${c.sql})` : ""}`).join("\n"),
158
+ fix: `poll ${one ? "it" : "them"} instead: mason add ${tables[0]} --sync "poll 5m"${one ? "" : " (and each of the others)"}, or sync: poll 5m on ${one ? "its entry" : "their entries"} in ${shown}, ` +
159
+ "which needs none of this — or fix the database as said, then mason stop && mason start",
160
+ next: [[`mason add ${tables[0]} --sync "poll 5m"`, "polls it with SELECTs instead"]] };
161
+ }
100
162
  /** The statements that remove what replication mode made, for a person to run when Mason could not: the slot, and the
101
163
  * publication only when it is Mason's own (a named one is its owner's). */
102
164
  export function dropStatements(o) {
103
165
  return `SELECT pg_drop_replication_slot('${o.slot}') FROM pg_replication_slots WHERE slot_name = '${o.slot}' AND NOT active;` +
104
166
  (o.owned ? ` DROP PUBLICATION IF EXISTS ${o.publication};` : "");
105
167
  }
168
+ /**
169
+ * WHAT THE RECORD NAMES THAT THIS START NO LONGER READS, to drop before the record names the new one (each holds what
170
+ * Mason made on a database nothing would drop otherwise): a slot under another name (the sources file renamed) goes
171
+ * with Mason's own publication; Mason's own publication alone when the project now reads another (one named), on the
172
+ * same slot — a review finding, the own one was left on the database after the switch and after mason destroy.
173
+ */
174
+ export function staleReplication(was, now) {
175
+ if (was === null)
176
+ return null;
177
+ if (was.slot !== now.slot)
178
+ return { drop: was, what: "both", why: `this source's slot is ${now.slot} now` };
179
+ if (was.owned && (was.publication !== now.publication || !now.owned)) {
180
+ return { drop: was, what: "publication", why: `the replicated tables are read through publication ${now.publication} now${now.owned ? "" : ", its owner's"}` };
181
+ }
182
+ return null;
183
+ }
106
184
  /**
107
185
  * Drop what replication mode made on the developer's database — the slot, and the publication when it is Mason's own,
108
186
  * never one the operator named — and verify it is gone: what was dropped, in words. A slot a reader still holds is never
109
- * dropped (the run must be stopped first): that is a failure.
187
+ * dropped (the run must be stopped first): that is a failure. `what: "publication"` drops Mason's own publication alone
188
+ * (the slot is still read).
110
189
  */
111
- export async function dropOwnReplication(source, o, from = null) {
190
+ export async function dropOwnReplication(source, o, from = null, what = "both") {
112
191
  const c = await connectSource(source);
113
192
  if ("failure" in c)
114
193
  throw new Error(`could not connect to drop them: ${c.failure.message}`);
@@ -119,11 +198,11 @@ export async function dropOwnReplication(source, o, from = null) {
119
198
  if (from?.systemIdentifier) {
120
199
  const now = await systemIdentifier(pg);
121
200
  if (now !== null && now !== from.systemIdentifier) {
122
- throw new Error(`${source.database} on ${source.host} is another server than the copy came from (system identifier ${now}, the copy's ${from.systemIdentifier}), where the slot is: put that server's URL back in the sources file and run mason destroy again, or drop them there by hand`);
201
+ throw new Error(`${source.database} on ${source.host} is another server than the synced data came from (system identifier ${now}, the synced data's ${from.systemIdentifier}), where the slot is: put that server's URL back in the sources file and run mason destroy again, or drop them there by hand`);
123
202
  }
124
203
  }
125
204
  const dropped = [];
126
- const slot = await readSlotFacts(pg, o.slot);
205
+ const slot = what === "both" ? await readSlotFacts(pg, o.slot) : null;
127
206
  if (slot) {
128
207
  if (slot.active)
129
208
  throw new Error(`replication slot ${o.slot} is in use by a reader: stop it first`);
@@ -136,7 +215,7 @@ export async function dropOwnReplication(source, o, from = null) {
136
215
  dropped.push(`publication ${o.publication}`);
137
216
  }
138
217
  // verified, never assumed: a slot left behind holds the database's WAL for ever
139
- if (await readSlotFacts(pg, o.slot))
218
+ if (what === "both" && await readSlotFacts(pg, o.slot))
140
219
  throw new Error(`replication slot ${o.slot} is still there after pg_drop_replication_slot`);
141
220
  if (o.owned && await hasPub())
142
221
  throw new Error(`publication ${o.publication} is still there after DROP PUBLICATION`);
@@ -0,0 +1,134 @@
1
+ import { OUTCOMES } from "../api/request-log.js";
2
+ import { parseRequestCursor, requestCursor } from "../duck/requests.js";
3
+ import { didYouMean } from "../lib/did-you-mean.js";
4
+ /** How many requests a read returns unless it says. */
5
+ export const DEFAULT_REQUEST_LIMIT = 40;
6
+ export const MAX_REQUEST_LIMIT = 10_000;
7
+ /** A statement answered in this many milliseconds or more is slow: what mason status lists, and what the `slow` filter
8
+ * keeps (`--slow`, `slow=1`). One constant, here, where the filter is read; src/cli/run-server.ts re-exports it. */
9
+ export const SLOW_MS = 1000;
10
+ /** The longest text `search` takes: a fragment of a statement, never a statement pasted whole. */
11
+ export const MAX_SEARCH = 200;
12
+ /** How many sync events a read returns unless it says, and the most it may. */
13
+ export const DEFAULT_EVENT_LIMIT = 40;
14
+ export const MAX_EVENT_LIMIT = MAX_REQUEST_LIMIT;
15
+ const AGE = /^(\d+)(s|m|h|d)$/;
16
+ const AGE_MS = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
17
+ /** a date, or a date and time with its zone (a time without one would be read in this machine's zone: refused) */
18
+ const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z|[+-]\d{2}:?\d{2}))?$/;
19
+ const usage = (message, fix) => ({ message, code: "usage", ...(fix !== undefined ? { fix } : {}) });
20
+ export const QUERY_NAMES = (k) => k;
21
+ /**
22
+ * THE TIME GRAMMAR, ONE FOR EVERY ENTRANCE (`--since` / `--until` of mason logs and mason usage, and the run's `since` and
23
+ * `until` of /api/requests, /api/events, /api/usage and /api/logs): an ISO 8601 date or time with its zone, or an age
24
+ * before `now` (30s, 30m, 2h, 7d), as an ISO time; a time with no zone is refused, as is anything else.
25
+ */
26
+ export function timeOf(name, text, now) {
27
+ const age = AGE.exec(text.trim());
28
+ if (age !== null)
29
+ return new Date(now - Number(age[1]) * AGE_MS[age[2]]).toISOString();
30
+ const t = ISO.test(text.trim()) ? Date.parse(text.trim()) : NaN;
31
+ if (Number.isNaN(t)) {
32
+ return usage(`${name} ${JSON.stringify(text)} is not a time`, "an age (30m, 2h, 7d) or an ISO 8601 date or time with its zone (2026-10-28, 2026-10-28T09:00:00Z)");
33
+ }
34
+ return new Date(t).toISOString();
35
+ }
36
+ /** `-n` / `limit` of the sync's events: a whole number, 1 to the cap, said in the entrance's own spelling of the key. */
37
+ export function eventLimit(text, key) {
38
+ const limit = text === undefined ? DEFAULT_EVENT_LIMIT : Number(text);
39
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENT_LIMIT)
40
+ return usage(`${key} is a whole number of events, 1 to ${MAX_EVENT_LIMIT}`);
41
+ return limit;
42
+ }
43
+ /**
44
+ * The filters read, or the first thing wrong with them, its key spelled as the entrance spells it (`name`). `failed`
45
+ * is every outcome but ok, so it is never named beside `outcome`; `slow` keeps the ones that took SLOW_MS or more, and
46
+ * `search` the statements whose text has it (any case, at most MAX_SEARCH characters); `since` must come before `until`; `before` is a
47
+ * cursor a page handed on, passed back as it came.
48
+ */
49
+ export function requestFilter(f, now = Date.now(), name = QUERY_NAMES) {
50
+ const limit = f.limit === undefined ? DEFAULT_REQUEST_LIMIT : Number(f.limit);
51
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_REQUEST_LIMIT)
52
+ return usage(`${name("limit")} is a whole number of requests, 1 to ${MAX_REQUEST_LIMIT}`);
53
+ const q = { limit };
54
+ if (f.outcome !== undefined) {
55
+ if (f.failed === true) {
56
+ return usage(`${name("failed")} is every outcome but ok: name it or ${name("outcome")}, never both`, `${name("outcome")} refused,failed,cancelled is ${name("failed")}`);
57
+ }
58
+ const names = f.outcome.split(",").map((o) => o.trim()).filter((o) => o !== "");
59
+ if (!names.length)
60
+ return usage(`${name("outcome")} names one or more of ${OUTCOMES.join(", ")}`);
61
+ for (const o of names) {
62
+ if (!OUTCOMES.includes(o))
63
+ return usage(`${name("outcome")} ${JSON.stringify(o)} is none of ${OUTCOMES.join(", ")}${didYouMean(o, OUTCOMES)}`);
64
+ }
65
+ q.outcomes = [...new Set(names)];
66
+ }
67
+ if (f.failed === true)
68
+ q.failed = true;
69
+ for (const k of ["since", "until"]) {
70
+ const text = f[k];
71
+ if (text === undefined)
72
+ continue;
73
+ const t = timeOf(name(k), text, now);
74
+ if (typeof t !== "string")
75
+ return t;
76
+ q[k] = t;
77
+ }
78
+ if (q.since !== undefined && q.until !== undefined && q.since >= q.until) {
79
+ return usage(`${name("since")} (${q.since}) is not before ${name("until")} (${q.until}): no request started in between`);
80
+ }
81
+ for (const k of ["user", "client"]) {
82
+ const text = f[k];
83
+ if (text === undefined)
84
+ continue;
85
+ if (text === "")
86
+ return usage(`${name(k)} names ${k === "user" ? "a mason user" : "a client's application_name"}: it is empty`);
87
+ q[k] = text;
88
+ }
89
+ if (f.slow === true)
90
+ q.minMs = SLOW_MS;
91
+ if (f.search !== undefined) {
92
+ if (f.search.trim() === "")
93
+ return usage(`${name("search")} is a piece of a statement's text: it is empty`);
94
+ if (f.search.length > MAX_SEARCH)
95
+ return usage(`${name("search")} is a piece of a statement's text, at most ${MAX_SEARCH} characters: it has ${f.search.length}`);
96
+ q.search = f.search;
97
+ }
98
+ if (f.before !== undefined) {
99
+ const c = parseRequestCursor(f.before);
100
+ if (c === null)
101
+ return usage(`${name("before")} ${JSON.stringify(f.before)} is not a position in the request log`, `pass back the before a page answered with, as it came`);
102
+ q.before = c;
103
+ }
104
+ return q;
105
+ }
106
+ /** The query string the run reads a RequestQuery from (requestFilter again, on its side): its times as resolved. */
107
+ export function requestFilterParams(q) {
108
+ const p = new URLSearchParams({ limit: String(q.limit) });
109
+ if (q.failed === true)
110
+ p.set("failed", "1");
111
+ if (q.outcomes !== undefined)
112
+ p.set("outcome", q.outcomes.join(","));
113
+ if (q.since !== undefined)
114
+ p.set("since", q.since);
115
+ if (q.until !== undefined)
116
+ p.set("until", q.until);
117
+ if (q.user !== undefined)
118
+ p.set("user", q.user);
119
+ if (q.client !== undefined)
120
+ p.set("client", q.client);
121
+ if (q.minMs !== undefined)
122
+ p.set("slow", "1");
123
+ if (q.search !== undefined)
124
+ p.set("search", q.search);
125
+ if (q.before !== undefined)
126
+ p.set("before", requestCursor(q.before.at, q.before.seq));
127
+ return p;
128
+ }
129
+ /** A query string's filters as text (`GET /api/requests`): what requestFilter reads. */
130
+ export function requestFilterText(p) {
131
+ const get = (k) => p.get(k) ?? undefined;
132
+ return { limit: get("limit"), outcome: get("outcome"), failed: p.get("failed") === "1", since: get("since"), until: get("until"),
133
+ user: get("user"), client: get("client"), slow: p.get("slow") === "1", search: get("search"), before: get("before") };
134
+ }