@mason-data/cli 0.8.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 (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
@@ -2,23 +2,40 @@
2
2
  * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): the Overview (what `mason
3
3
  * status` says), the Tables (`mason tables`, `add`, `remove`, `sync`), the Users (`mason user`) and Observability (`mason
4
4
  * logs`, `mason usage`), refreshed while the tab is seen. It prints the page's URL WITH the run's token (src/cli/security.ts: the first request trades it for an HttpOnly cookie and is redirected to a URL without
5
- * it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
5
+ * it) and opens the browser when its stdout is a terminal (as `mason login` does: a pipe, a file or an agent's shell has
6
+ * no one at a browser, so it gets the address alone). The reader is the person at this terminal, whom `mason user add` already shows a
6
7
  * password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
7
8
  * `mason status`, which prints the URL without it.
8
9
  *
9
10
  * Before it prints anything it ASKS the page (`GET /` with the run's token as the commands send it), so a run that serves
10
- * none (`mason serve`'s, or one from before the page) is said, never a URL that answers 404.
11
+ * none (one from before the page) is said, never a URL that answers 404.
12
+ *
13
+ * `--ssh <user@server>` (src/cli/ui-ssh.ts, docs/WEB-UI.md "On a server") opens a server's page from this machine, through
14
+ * the machine's own ssh, and stays in the foreground until Ctrl-C, as `mason share` does; it needs no project here. Its
15
+ * URL carries a ticket for one use, never the run's token, which stays on the server. `--bridge` is what it runs there.
16
+ *
17
+ * `--replica <org>/<name>` (else `MASON_REPLICA`; src/cli/ui-hosted.ts, docs/HOSTED-API.md "A replica's page") opens a
18
+ * replica's page on a hosted Mason service: one request, whose one-use address is opened as this command opens a run's.
11
19
  */
12
20
  import { spawn } from "node:child_process";
13
21
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
14
22
  import { reach } from "./state.js";
23
+ import { bridge, openSshTunnel, targetProblem } from "./ui-ssh.js";
24
+ import { version } from "./version.js";
15
25
  import { RunUnreachable } from "./control.js";
26
+ import { parseDuration } from "../lib/table-sync.js";
27
+ import { replicaOf } from "./replica.js";
28
+ import { hostedUi } from "./ui-hosted.js";
16
29
  const out = (m) => { process.stdout.write(`${m}\n`); };
30
+ const say = (m) => { process.stderr.write(`${m}\n`); };
31
+ const DEFAULT_TIMEOUT_S = 60;
17
32
  export const UI = {
18
33
  needs: "run",
19
34
  name: "ui",
20
35
  summary: "the status, tables, users and statements, in your browser",
21
36
  usage: `usage: mason ui [--no-open] [--json]
37
+ mason ui --ssh <user@server> [--mason <command>] [--timeout <duration>] [--no-open] [--json]
38
+ mason ui --replica <org>/<name> [--no-open] [--json]
22
39
 
23
40
  Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
24
41
  says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
@@ -26,20 +43,51 @@ export const UI = {
26
43
  the URL to paste, or remove one); and Observability: the statements running and that ran (mason logs --requests,
27
44
  cancel one), usage over time (mason usage), and the sync's events (mason logs --events).
28
45
 
29
- The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
30
- bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
31
- token, so after a restart run mason ui again. The page is mason start's: mason serve serves none (off this
32
- machine it would need TLS and a login).
33
-
34
46
  mason ui
35
47
  mason ui --no-open print the URL, open no browser
36
48
  mason ui --json
49
+ mason ui --ssh deploy@mason.example.com
50
+ mason ui --ssh deploy@mason.example.com --mason "docker exec -i mason-mason-1 mason"
51
+ mason ui --ssh deploy@mason.example.com --mason "cd /var/lib/mason && mason" --timeout 120 --no-open
52
+ mason ui --replica acme/shop a replica's page on a hosted Mason service
53
+ mason ui --bridge --local-port 51000 what --ssh runs on the server; its stdout is a stream, not for reading
54
+
55
+ --no-open print the URL only (as when its output is not a terminal: a browser opens only for one)
56
+ --ssh <target> the server, as you give it to ssh: user@server, or a Host of your ssh config
57
+ --mason <command> how to run mason on the server (default mason); a shell's text, kept to ssh's command line
58
+ --timeout <duration> how long ssh and the server have to answer (default ${DEFAULT_TIMEOUT_S}s; a bare number is seconds;
59
+ 2m): a passphrase or a touch counts; past it, exit 124
60
+ --bridge the server's end of --ssh, on stdin and stdout
61
+ --local-port <n> the port the browser names, for --bridge: the laptop's
62
+ --replica <org>/<name> a replica on a hosted Mason service, as the service lists it
63
+ --json as data: its shape is at the end
37
64
 
38
- --no-open print the URL only
39
- --json as data: { url (with this run's token), port }, next; it never opens a browser
65
+ The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
66
+ bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
67
+ token, so after a restart run mason ui again.
68
+
69
+ --ssh opens a server's page (mason serve's) from this machine, with nothing public: it runs your ssh, so your
70
+ ~/.ssh/config, keys and SSO apply, starts mason there, and forwards a free local port to the page, which listens on the
71
+ server's loopback alone. It stays in the foreground: Ctrl-C closes the tunnel. It needs no project here. Its URL
72
+ carries a ticket for one use, never the run's token, which stays on the server. The server's Mason must be this
73
+ version or close to it, and must be a command ssh can run: --mason says how, for a container or a folder.
74
+
75
+ --replica opens the page of a replica on a hosted Mason service (else MASON_REPLICA, unless --ssh or --bridge names
76
+ the target), for the organisation's owner alone: the service answers an address that opens it once, for a few
77
+ minutes. The page is read-only: the service refuses changes through it, so add or remove users with mason user …
78
+ --replica. It needs no project here, and goes with --no-open and --json alone. Opened in your browser, the address is
79
+ not printed: run it again for another tab. A replica not ready yet is said, with when to try again.
80
+
81
+ The --json answer: { url (with this run's token; with --ssh a ticket for one use; null when Ctrl-C came
82
+ before the server answered, as port is, exit 0; with --replica the one-use address), port, server,
83
+ project, version (with --ssh: the server's Mason, for what its answer may lack), replica
84
+ (with --replica: "<org>/<name>"), expiresAt (with --replica: until when the address opens it)
85
+ }, next; it never opens a
86
+ browser. With --ssh it then holds the tunnel: the end is on stderr, exit 0 for Ctrl-C or SIGTERM, 1 otherwise
40
87
  `,
41
- options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false } },
42
- json: ["url", "port", "next"],
88
+ options: { "no-open": { type: "boolean", default: false }, json: { type: "boolean", default: false }, ssh: { type: "string" }, mason: { type: "string" },
89
+ timeout: { type: "string" }, bridge: { type: "boolean", default: false }, "local-port": { type: "string" }, replica: { type: "string" } },
90
+ json: ["url", "port", "server", "project", "version", "replica", "expiresAt", "next"],
43
91
  };
44
92
  const NEXT = [["mason status", "the same, in this terminal"], ["mason user list", "the users"]];
45
93
  /** The command that opens a URL in the person's browser, by platform (macOS `open`, Windows `start`, else `xdg-open`). */
@@ -51,8 +99,11 @@ export function openCommand(platform, url) {
51
99
  return ["cmd", ["/c", "start", "", url]];
52
100
  return ["xdg-open", [url]];
53
101
  }
102
+ /** An address Mason hands the browser by itself (a sign-in page, a replica's page): a plain https (or this machine's
103
+ * http) URL, and none of the characters a shell or `cmd /c start` reads (the service's URL is not ours). */
104
+ export const OPENABLE = /^https?:\/\/[A-Za-z0-9.:[\]-]+(?:\/[A-Za-z0-9._~/?=+-]*)?$/;
54
105
  /** Open `url` in the browser: null once the opener started, else why it could not (the URL is printed either way). */
55
- function openBrowser(url) {
106
+ export function openBrowser(url) {
56
107
  const [command, args] = openCommand(process.platform, url);
57
108
  return new Promise((resolve) => {
58
109
  const child = spawn(command, args, { detached: true, stdio: "ignore" });
@@ -60,6 +111,9 @@ function openBrowser(url) {
60
111
  child.once("spawn", () => { child.unref(); resolve(null); });
61
112
  });
62
113
  }
114
+ /** Whether to open a browser: asked for (no --no-open) and stdout a terminal, a person in front of it (never a pipe,
115
+ * a file or a coding agent's shell, which get the address alone). */
116
+ const opens = (noOpen) => !noOpen && process.stdout.isTTY === true;
63
117
  /** Whether the run serves the page: its `GET /` answered, with the token the commands send. */
64
118
  async function servesPage(run) {
65
119
  let res;
@@ -72,21 +126,111 @@ async function servesPage(run) {
72
126
  await res.arrayBuffer();
73
127
  return res.status === 200 && /^text\/html/.test(res.headers.get("content-type") ?? "");
74
128
  }
129
+ /**
130
+ * THE PAGE OF A SERVER (the header): the tunnel, the URL once, and held until Ctrl-C or until it drops. As `mason share`:
131
+ * what `--json` prints is one object, once, and everything after it is on stderr, so stdout stays one document. The
132
+ * signals are heard before ssh starts: a Ctrl-C at a passphrase ends both.
133
+ */
134
+ async function overSsh(target, mason, timeoutMs, open, json) {
135
+ let signalled = null;
136
+ const signal = new Promise((resolve) => {
137
+ const heard = (s) => { signalled = s; resolve(s); };
138
+ process.once("SIGINT", heard);
139
+ process.once("SIGTERM", heard);
140
+ });
141
+ const t = await openSshTunnel({ target, mason, timeoutMs, interactive: !json && process.stdin.isTTY === true, stopped: signal });
142
+ const next = [[`mason ui --ssh ${target}`, "again, once this one is closed: a new port and a new ticket"]];
143
+ if ("message" in t) {
144
+ if (signalled === null)
145
+ return failed("ui", t, { json });
146
+ // stopped on purpose, exit 0: under --json still the one object every answer is, its url null as nothing opened
147
+ if (json)
148
+ out(jsonText(UI, { url: null, port: null, server: target }, next));
149
+ else
150
+ say(`stopped before ${target} answered: nothing was left open`);
151
+ return 0;
152
+ }
153
+ if (json)
154
+ out(jsonText(UI, { url: t.url, port: t.port, server: target, project: t.project, version: t.serverVersion }, next));
155
+ else {
156
+ const couldNot = open ? await openBrowser(t.url) : null;
157
+ out(reportText({
158
+ headline: open && couldNot === null ? `${t.project} on ${target}, opened in your browser` : `${t.project} on ${target}`,
159
+ details: [["url", t.url], ["server", target], ["project", t.project], ["version", t.serverVersion]],
160
+ tips: [
161
+ ...(couldNot !== null ? [`Could not open a browser (${couldNot}): open the URL yourself.`] : []),
162
+ ...(t.serverVersion !== version() ? [`This machine's Mason is ${version()}: the page is the server's.`] : []),
163
+ "The URL carries a ticket for one use, and works for 5 minutes. Ctrl-C closes the tunnel.",
164
+ ],
165
+ next,
166
+ }));
167
+ }
168
+ const how = await Promise.race([signal.then((s) => ({ signal: s })), t.ended.then((why) => ({ why }))]);
169
+ if ("signal" in how) {
170
+ await t.stop();
171
+ say(`the tunnel to ${target} is closed`);
172
+ return 0;
173
+ }
174
+ return failed("ui", { message: `the tunnel to ${target} dropped`, code: "failed", why: how.why, fix: "is the server up, and this machine online? The page in your browser stops answering until a new tunnel opens one", next }, { json: false });
175
+ }
75
176
  export async function ui(argv) {
76
177
  const f = parseCommand(UI, argv);
77
178
  if (typeof f === "number")
78
179
  return f;
79
180
  const json = f.values.json === true;
181
+ const v = f.values;
182
+ const wrong = (message) => failed("ui", { message, code: "usage", usage: UI.usage }, { json });
80
183
  if (f.positionals.length)
81
- return failed("ui", { message: `mason ui takes no argument: ${f.positionals.join(" ")}`, code: "usage", usage: UI.usage }, { json });
184
+ return wrong(`mason ui takes no argument: ${f.positionals.join(" ")}`);
185
+ if (v.replica !== undefined) {
186
+ const beside = [["--ssh", v.ssh !== undefined], ["--bridge", v.bridge], ["--mason", v.mason !== undefined], ["--timeout", v.timeout !== undefined],
187
+ ["--local-port", v["local-port"] !== undefined]].filter(([, given]) => given).map(([name]) => name);
188
+ if (beside.length)
189
+ return wrong(`--replica opens a hosted replica's page: it goes with --no-open and --json alone, not ${beside.join(", ")}`);
190
+ }
191
+ if (v.bridge) {
192
+ if (v.ssh !== undefined || v.mason !== undefined || v.timeout !== undefined || json)
193
+ return wrong("--bridge is mason ui --ssh's own end on the server: it goes with --local-port alone");
194
+ const port = Number(v["local-port"]);
195
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
196
+ return wrong(`--bridge needs --local-port, a port number, not ${JSON.stringify(v["local-port"] ?? "")}`);
197
+ return bridge(port);
198
+ }
199
+ if (v["local-port"] !== undefined)
200
+ return wrong("--local-port goes with --bridge");
201
+ if (v.ssh === undefined && (v.mason !== undefined || v.timeout !== undefined))
202
+ return wrong(`${v.mason !== undefined ? "--mason" : "--timeout"} goes with --ssh <user@server>`);
203
+ if (v.ssh !== undefined) {
204
+ const problem = targetProblem(v.ssh);
205
+ if (problem !== null)
206
+ return wrong(`--ssh: ${problem}`);
207
+ if (v.mason !== undefined && (v.mason.trim() === "" || /[\r\n]/.test(v.mason)))
208
+ return wrong("--mason is one line of shell that runs mason on the server");
209
+ const read = parseDuration(v.timeout ?? String(DEFAULT_TIMEOUT_S));
210
+ if ("problem" in read)
211
+ return wrong(`--timeout ${read.problem}`);
212
+ const seconds = read.seconds;
213
+ return overSsh(v.ssh, v.mason ?? "mason", seconds * 1000, opens(v["no-open"]), json);
214
+ }
215
+ // a replica on a hosted service (--replica, else MASON_REPLICA): --ssh and --bridge name their own target, above
216
+ const replica = replicaOf(v.replica, UI.usage);
217
+ if (replica !== null && "message" in replica)
218
+ return failed("ui", replica, { json });
219
+ if (replica !== null)
220
+ return hostedUi(replica, { open: opens(v["no-open"]), json });
82
221
  const w = await reach(UI, json);
83
222
  if (w === null || w.state !== "up")
84
223
  return 1;
85
224
  const run = w.run;
225
+ if (w.project.config.served === true) {
226
+ return failed("ui", { message: `Mason for ${run.name} runs on this server: its page is opened from your own machine`, code: "refused",
227
+ why: "the page listens on this machine's loopback alone, and a browser on a server has no one in front of it",
228
+ fix: "on your machine, mason ui --ssh <user@server> (mason ui --help: for a container, --mason)", next: [["mason status", "what it is doing, in this terminal"]] }, { json });
229
+ }
86
230
  if (!await servesPage(run)) {
87
231
  return failed("ui", { message: `Mason for ${run.name} serves no web page`, code: "refused",
88
- why: "the page is mason start's: this is mason serve's run, or a run started by a Mason from before the page",
89
- fix: "on your machine, mason stop && mason start runs it with the page; a server's Mason is read with mason status",
232
+ why: "this run was started by a Mason from before the page",
233
+ fix: "mason stop && mason start runs it with the page",
90
234
  next: [["mason status", "what it is doing, in this terminal"]] }, { json });
91
235
  }
92
236
  const url = `${run.control.url}/?token=${encodeURIComponent(run.control.token)}`;
@@ -95,8 +239,9 @@ export async function ui(argv) {
95
239
  out(jsonText(UI, { url, port }, NEXT));
96
240
  return 0;
97
241
  }
98
- const couldNot = f.values["no-open"] === true ? null : await openBrowser(url);
99
- const opened = f.values["no-open"] !== true && couldNot === null;
242
+ const open = opens(v["no-open"]);
243
+ const couldNot = open ? await openBrowser(url) : null;
244
+ const opened = open && couldNot === null;
100
245
  out(reportText({
101
246
  headline: opened ? `Mason's web page, opened in your browser` : `Mason's web page`,
102
247
  details: [["url", url], ["project", run.name]],
@@ -12,9 +12,10 @@ import { fileSize, guarded, runFailure } from "./commands.js";
12
12
  import { timeOf } from "./request-filter.js";
13
13
  import { duration, msText } from "./readiness.js";
14
14
  import { SIZE_EVERY_MS } from "../duck/usage.js";
15
+ import { parseDuration } from "../lib/table-sync.js";
15
16
  const out = (m) => { process.stdout.write(`${m}\n`); };
16
17
  const n = (x) => x.toLocaleString("en-US");
17
- export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <span>] [--json]
18
+ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <duration>] [--json]
18
19
 
19
20
  What Mason was and did over time, from a sample it takes every minute and keeps MASON_HISTORY_DAYS days
20
21
  (default 7): a line each span — the statements of the Postgres API (how many, how many were refused, failed or
@@ -29,15 +30,18 @@ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>]
29
30
 
30
31
  --since <when> from when: an age (90s, 30m, 6h, 7d) or an ISO 8601 date or time with its zone; default 1h
31
32
  --until <when> until when, the same way; default now
32
- --step <span> a line per span (1m to the days kept; default 5m), or 0 for every sample as it was taken
33
- --json as data: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
34
- (with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
35
- tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
36
- rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
37
- heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
38
- slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
39
- sessionsOpen (the Postgres API's sessions logged in) }],
40
- copySizes: [{ table, bytes, at }], next }
33
+ --step <duration> a line per span (1m to the days kept; default 5m; a bare number is seconds), or 0 for every
34
+ sample as it was taken
35
+ --json as data: its shape is at the end
36
+
37
+ The --json answer: { since, until, stepSeconds (null: every sample), sampleSeconds, keptDays, samples: [{ at
38
+ (with a step, the span's start), seconds, copyBytes, copyRows, tablesAdded, tablesCopied,
39
+ tablesFailed, oldestSyncSeconds, statements, statementsRefused, statementsFailed, statementsCancelled,
40
+ rowsSent, bytesSent, durationP50Ms, durationP95Ms, streamLagBytes, streamLagSeconds, rssBytes,
41
+ heapUsedBytes, copyMemoryBytes, copyMemoryLimitBytes, slotWalHeldBytes (replication: the WAL the
42
+ slot holds on the database), diskFreeBytes (free where the synced data is; with a step, the lowest),
43
+ sessionsOpen (the Postgres API's sessions logged in) }],
44
+ copySizes: [{ table, bytes, at }], next }
41
45
  `;
42
46
  export const USAGE = {
43
47
  needs: "run",
@@ -47,13 +51,13 @@ export const USAGE = {
47
51
  options: { since: { type: "string" }, until: { type: "string" }, step: { type: "string" }, json: { type: "boolean" } },
48
52
  json: ["since", "until", "stepSeconds", "sampleSeconds", "keptDays", "samples", "copySizes", "next"],
49
53
  };
50
- const UNIT = { s: 1, m: 60, h: 3600, d: 86_400 };
51
- /** A span as the options write it (90s, 30m, 6h, 7d; 0), in seconds, or null when it is not one. */
54
+ /** A span as the options write it (the command line's one duration grammar, src/lib/table-sync.ts `parseDuration`:
55
+ * 90s, 30m, 6h, 7d, a bare number of seconds; 0 for every sample), in whole seconds, or null when it is not one. */
52
56
  export function spanSeconds(text) {
53
57
  if (text.trim() === "0")
54
58
  return 0;
55
- const m = /^\s*(\d+)\s*([smhd])\s*$/.exec(text);
56
- return m ? Number(m[1]) * UNIT[m[2]] : null;
59
+ const d = parseDuration(text, { zero: true });
60
+ return "problem" in d || !Number.isInteger(d.seconds) ? null : d.seconds;
57
61
  }
58
62
  /** One sample on one line: the time, the statements, those refused, failed or cancelled, p95, rows sent, the copy, its rows, the stalest copy, the lag, the memory. */
59
63
  export function sampleLine(s, withLag) {
@@ -87,7 +91,7 @@ export async function usage(argv, now = Date.now()) {
87
91
  return failed("usage", { ...t, usage: USAGE_USAGE }, { json });
88
92
  const step = spanSeconds(v.step ?? "5m");
89
93
  if (step === null)
90
- return failed("usage", { message: `--step is a span (1m, 5m, 1h, 1d) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
94
+ return failed("usage", { message: `--step is a duration (300, 1m, 5m, 1h, 1d: a bare number is seconds) or 0 for every sample, not ${JSON.stringify(v.step)}`, code: "usage", usage: USAGE_USAGE }, { json });
91
95
  const w = await reach(USAGE, json);
92
96
  if (w === null || !("run" in w))
93
97
  return 1;