@mason-data/cli 0.7.0 → 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 (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  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 +1173 -207
  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/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. package/package.json +10 -3
@@ -42,9 +42,20 @@ export function readRestarts(home) {
42
42
  const v = JSON.parse(fs.readFileSync(file, "utf8"));
43
43
  return Array.isArray(v.restarts) ? v.restarts : [];
44
44
  }
45
+ export function restartsFact(home, read = () => readRestarts(home)) {
46
+ let r;
47
+ try {
48
+ r = read();
49
+ }
50
+ catch (e) {
51
+ return { error: `${path.join(home, RESTARTS_FILE)} could not be read: ${e.message}` };
52
+ }
53
+ const last = r[r.length - 1];
54
+ return { count: r.length, last: last === undefined ? null : { at: last.at, why: last.reason, upSeconds: last.upSeconds } };
55
+ }
45
56
  /** Why a run died, in the words a person reads: V8's own fatal line when it printed one */
46
57
  export function crashReason(code, signal, fatal) {
47
- // Mason's own fatal line (src/cli/dev.ts: the copy's thread stopped) is said as Mason said it
58
+ // Mason's own fatal line (src/cli/dev.ts: the engine's thread stopped) is said as Mason said it
48
59
  if (fatal !== null)
49
60
  return /heap out of memory/i.test(fatal) && !fatal.startsWith("Mason: ") ? "it ran out of JavaScript heap (V8: heap out of memory)"
50
61
  : fatal.startsWith("Mason: ") ? fatal.slice("Mason: ".length) : `V8: ${fatal}`;
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `mason sync <table>…`: Mason's poll of exactly these tables, now — named for what it does to the copy
3
- * (made current, incrementally), never for the mechanism (`poll` was). The passes poll every added table but one over
4
- * the poll's row limit (src/duck/sync.ts POLL_ROW_LIMIT: each poll scans its table whole on the database), which is
5
- * copied and served as it was copied; this reads what changed in it since, when asked. The run's `POST /api/sync`
6
- * (src/cli/run-server.ts) answers it, through the sync's own poll (`Sync.pollNow`).
3
+ * (made current, incrementally), never for the mechanism (`poll` was). A table whose sources-file entry says `sync:
4
+ * off` (the default: src/lib/table-sync.ts) is read again only so, and one set to `poll <interval>` is polled now
5
+ * whatever its interval says, its next poll an interval from this one. A table whose sync is `replication` is not
6
+ * polled: its answer is where the stream is, beside the polled ones'. The run's `POST /api/sync` (src/cli/run-server.ts) answers it, through the sync's own
7
+ * poll (`Sync.pollNow`).
7
8
  */
8
9
  import { failed, jsonText, parseCommand, reportText } from "./command.js";
9
10
  import { out as styleOut } from "./style.js";
@@ -11,30 +12,55 @@ import { callRun } from "./control.js";
11
12
  import { reach } from "./state.js";
12
13
  import { findTable, guarded, readState, tableArg } from "./commands.js";
13
14
  import { didYouMean } from "../lib/did-you-mean.js";
14
- import { POLL_ROW_LIMIT } from "../duck/sync.js";
15
+ import { syncedText, syncWords } 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
18
  export const SYNC_USAGE = `usage: mason sync <table>… [--json]
18
19
 
19
- Makes Mason's copy of these tables current now: it reads what changed in your database since Mason last read them —
20
- the rows inserted or updated, and the ones deleted. Mason syncs every added table by itself every few seconds, but
21
- a table over ${n(POLL_ROW_LIMIT)} rows is copied and served without that (each sync scans a table whole on your
22
- database): mason status says which, and this syncs one when you need it current. mason start --replication keeps
23
- every table current from the database's change log, whatever its size.
20
+ Brings these synced tables up to date now: it reads what changed in your database since Mason last read them —
21
+ the rows inserted or updated, and the ones deleted — in one transaction, as a poll does (each one scans its table
22
+ whole on your database). After its first sync, a table is read again as its sync: in the sources file says: off
23
+ (the default) only when you run this — before a report, from cron, at the end of a deploy — and poll <interval> by
24
+ itself every interval, which this does now too, whatever the interval says. mason status says how each table is
25
+ kept current and when it was last synced. A table whose sync: is replication is not polled: it answers where the
26
+ stream of the database's change log is, beside the tables it polls.
24
27
 
25
28
  mason sync orders
26
29
  mason sync orders public.events
27
30
 
28
- --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }], next }
31
+ --json as data: { syncedAt, tables: [{ table, changed, deleted, rows, ms }] (the tables polled), streamed (the
32
+ tables whose sync: is replication, not polled: [{ table, position, state, why }]), next }
29
33
  `;
30
34
  export const SYNC = {
31
35
  needs: "run",
32
36
  name: "sync",
33
- summary: "make the copy of these tables current now: mason sync orders (a table over the sync's row limit is synced only so)",
37
+ summary: "bring these synced tables up to date now: mason sync orders (a table whose sync is off is read again only so)",
34
38
  usage: SYNC_USAGE,
35
39
  options: { json: { type: "boolean" } },
36
- json: ["syncedAt", "tables", "next"],
40
+ json: ["syncedAt", "tables", "streamed", "next"],
37
41
  };
42
+ /**
43
+ * WHAT A SYNC DID, as `mason sync` prints it from `POST /api/sync`'s answer (the web page's Sync button says the same,
44
+ * src/web/pages/tables.js; tests/web-page.test.ts holds the two equal): the rows written and deleted, a line per table,
45
+ * and what happens next to each table synced (`syncedTips`: an `off` one stays as of this sync). A table whose sync is
46
+ * replication was not polled: its line says where the stream is (`streamed`), and a sync of those alone has nothing to sync.
47
+ */
48
+ export function syncedReport(b, picked, tables) {
49
+ const streamed = b.streamed ?? [];
50
+ const streamedRows = streamed.map((t) => [t.table, `${t.state}${t.why ? `: ${t.why}` : ""} · the stream has applied the database's changes through ${t.position}`]);
51
+ if (b.tables.length === 0 && streamed.length > 0) {
52
+ return { headline: `Nothing to sync: logical replication keeps ${picked.join(", ")} current.`, details: streamedRows, tips: [] };
53
+ }
54
+ const changed = b.tables.reduce((a, t) => a + t.changed, 0), deleted = b.tables.reduce((a, t) => a + t.deleted, 0);
55
+ const streamedNames = new Set(streamed.map((t) => t.table));
56
+ const polled = picked.filter((t) => !streamedNames.has(t));
57
+ return {
58
+ headline: `Synced ${polled.join(", ")}: ${n(changed)} row(s) written, ${n(deleted)} deleted${streamedRows.length ? `; logical replication keeps ${streamedRows.map((r) => r[0]).join(", ")} current` : ""}.`,
59
+ details: [...b.tables.map((t) => [t.table, syncedText(t)]), ...streamedRows],
60
+ // what happens next to each: an `off` table stays as of now, a polled one is polled again after its interval
61
+ tips: syncedTips(tables),
62
+ };
63
+ }
38
64
  export async function sync(argv) {
39
65
  const f = parseCommand(SYNC, argv);
40
66
  if (typeof f === "number")
@@ -62,7 +88,7 @@ export async function sync(argv) {
62
88
  if ("message" in db && db.code === "usage")
63
89
  return failed("sync", db, { json });
64
90
  if (!("message" in db)) {
65
- return failed("sync", { message: `${db.table} is not added, so Mason has no copy of it to sync`, code: "not_found", fix: `mason add ${tableArg(db.table)}`,
91
+ return failed("sync", { message: `${db.table} is not added, so Mason has not synced it`, code: "not_found", fix: `mason add ${tableArg(db.table)}`,
66
92
  next: [["mason status", "the added tables"]] }, { json });
67
93
  }
68
94
  return failed("sync", { message: `there is no added table ${JSON.stringify(name)}${didYouMean(name, added.flatMap((x) => [x.table, tableArg(x.table)]))}`, code: "not_found",
@@ -73,22 +99,26 @@ export async function sync(argv) {
73
99
  if (r.status !== 200) {
74
100
  const code = typeof r.body.code === "string" ? r.body.code : r.status === 404 ? "not_found" : "failed";
75
101
  return failed("sync", { message: `${picked.join(", ")} ${picked.length === 1 ? "was" : "were"} not synced`, code, why: String(r.body.error),
76
- next: [["mason status", "each table's copy"], ["mason logs", "the run's log"]] }, { json });
102
+ next: [["mason status", "each synced table"], ["mason logs", "the run's log"]] }, { json });
77
103
  }
78
104
  const b = r.body;
79
- const unsynced = added.filter((t) => picked.includes(t.table) && t.unsynced);
80
- const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "each table's copy, and which the sync leaves out"]];
105
+ const next = [[`mason sync ${names.join(" ")}`, "sync again, whenever you need it current"], ["mason status", "how each table is kept current, and when it was last synced"]];
81
106
  if (json) {
82
- out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables }, next));
107
+ out(jsonText(SYNC, { syncedAt: b.at, tables: b.tables, ...(b.streamed ? { streamed: b.streamed } : {}) }, next));
83
108
  return 0;
84
109
  }
85
- const changed = b.tables.reduce((a, t) => a + t.changed, 0), deleted = b.tables.reduce((a, t) => a + t.deleted, 0);
86
- out(reportText({
87
- headline: `Synced ${picked.join(", ")}: ${n(changed)} row(s) written, ${n(deleted)} deleted.`,
88
- details: b.tables.map((t) => [t.table, `${n(t.changed)} changed, ${n(t.deleted)} deleted, ${n(t.rows)} rows now · ${n(t.ms)} ms`]),
89
- tips: unsynced.map((t) => `${t.table} is ${t.unsynced.reason}: it stays as of this sync until the next mason sync (mason start --replication keeps every table current)`),
90
- next,
91
- }, styleOut()));
110
+ out(reportText({ ...syncedReport(b, picked, added.filter((t) => picked.includes(t.table))), next }, styleOut()));
92
111
  return 0;
93
112
  });
94
113
  }
114
+ /** What happens to each table synced after now, by its sync (src/duck/sync.ts KeptCurrent): an `off` table stays as of
115
+ * this sync until the next one, a polled one is polled again after its interval. */
116
+ export function syncedTips(tables) {
117
+ return tables.flatMap((t) => {
118
+ const k = t.kept;
119
+ if (k?.by !== "poll")
120
+ return [];
121
+ return [k.sync.mode === "off" ? `${t.table}'s sync is off: it stays as of this sync until the next mason sync ${tableArg(t.table)}`
122
+ : `${t.table} is ${syncWords(k.sync)}: its next poll is an interval from this one`];
123
+ });
124
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * THE TUNNEL OF `mason share`: a client of a free service that forwards raw TCP, run as a child process and pointed at the
3
+ * share's socket (src/cli/share-run.ts). What crosses is TLS between the BI tool and Mason, so a service that only
4
+ * forwards reads nothing; a hostile one could sit in the handshake (docs/POSTGRES-API.md says so). Which one is `--via`:
5
+ * Pinggy over the `ssh` every machine has, or `bore`, a binary to install. The address is the client's OUTPUT, a format
6
+ * Mason does not own, so reading it is held three ways: a time limit (`timeoutMs`), a shape it must have
7
+ * (`tunnelAddress`), and a probe of the address it gave (`probeShare`): the share's own certificate must answer there.
8
+ *
9
+ * `ssh` NEVER PROMPTS, because nothing stands behind a prompt (an agent, a pipe, a terminal that went on to something
10
+ * else): it runs in a session of its own with no terminal (`detached`), stdin a pipe this process holds. The first
11
+ * connection's host key is taken and kept in `.mason/share-known-hosts` (`accept-new`: a key that CHANGES later fails, said
12
+ * by the file's name), never the developer's own `~/.ssh`; their keys, agent and config are left out (`-F`, no
13
+ * `PubkeyAuthentication`), so nothing can ask for a passphrase or a touch and nothing of theirs is offered to a
14
+ * service. Pinggy's free route asks for a password that is empty (its docs: "just press enter"), so a password
15
+ * question is answered by `true`, which prints nothing (`SSH_ASKPASS`, forced): that is the one answer any question gets.
16
+ *
17
+ * A PINGGY TOKEN NEVER REACHES A COMMAND LINE: `ps` shows every process's arguments to every user, and the service takes the
18
+ * token as the ssh user. So the user is written in a config file of ours, 0600 in `.mason/`, named by `-F`, and
19
+ * removed as soon as the tunnel is up (ssh has read it), or when it fails. `MASON_PINGGY_TOKEN` is read from the
20
+ * shell or `.env`, never a flag, and is masked in whatever of the client's output a failure repeats.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import * as fs from "node:fs";
24
+ import * as net from "node:net";
25
+ import * as path from "node:path";
26
+ import * as tls from "node:tls";
27
+ import { stripVTControlCharacters } from "node:util";
28
+ export const TUNNELS = ["pinggy", "bore"];
29
+ /** A free tunnel's limit where its client does not say one: Pinggy's, measured in docs/DESIGN.md "mason share". */
30
+ const FREE_MINUTES = 60;
31
+ /** How much of the client's output is kept to read an address from, and how many lines a failure repeats. */
32
+ const KEPT_BYTES = 64 * 1024;
33
+ const SAID_LINES = 3;
34
+ const STOP_GRACE_MS = 2_000;
35
+ /** How long the address is given to answer: long enough for a relay on another continent, short enough to wait for. */
36
+ export const PROBE_MS = 8_000;
37
+ /** The ssh user and host: a free tunnel, or a token's (`<token>+tcp@`, Pinggy's own form). */
38
+ const pinggyTarget = (token) => token === null ? { user: "tcp", host: "free.pinggy.io" } : { user: `${token}+tcp`, host: "pro.pinggy.io" };
39
+ /** A token as it may stand in a config file: anything else would be a line of it. */
40
+ export const TOKEN_RE = /^[A-Za-z0-9._~-]+$/;
41
+ const quoted = (file) => `"${file.replaceAll("%", "%%")}"`;
42
+ /** The ssh config of a tunnel (the header says why each line): every option here, so the command line carries none. */
43
+ export function pinggyConfig(o) {
44
+ const t = pinggyTarget(o.token);
45
+ return `Host mason-share
46
+ HostName ${t.host}
47
+ Port 443
48
+ User ${t.user}
49
+ StrictHostKeyChecking accept-new
50
+ UserKnownHostsFile ${quoted(o.knownHosts)}
51
+ GlobalKnownHostsFile none
52
+ PubkeyAuthentication no
53
+ IdentityAgent none
54
+ NumberOfPasswordPrompts 1
55
+ ExitOnForwardFailure yes
56
+ ServerAliveInterval 30
57
+ ServerAliveCountMax 3
58
+ ConnectTimeout 15
59
+ ConnectionAttempts 1
60
+ RequestTTY no
61
+ LogLevel ERROR
62
+ `;
63
+ }
64
+ export function tunnelPlan(via, o) {
65
+ if (via === "bore") {
66
+ return { via, bin: "bore", args: ["local", String(o.port), "--local-host", "127.0.0.1", "--to", "bore.pub"], env: process.env, install: "cargo install bore-cli, or brew install bore-cli",
67
+ reconnects: false, free: false, token: null, file: null };
68
+ }
69
+ const file = path.join(o.home, `share-tunnel-${process.pid}.ssh`);
70
+ return { via, bin: "ssh", args: ["-F", file, "-T", `-R0:127.0.0.1:${o.port}`, "mason-share"],
71
+ env: { ...process.env, SSH_ASKPASS: "true", SSH_ASKPASS_REQUIRE: "force", DISPLAY: process.env.DISPLAY ?? ":0" }, install: "the OpenSSH client (it comes with macOS and most Linux; apt install openssh-client)",
72
+ reconnects: o.token !== null, free: o.token === null, token: o.token,
73
+ file: { path: file, text: pinggyConfig({ token: o.token, knownHosts: path.join(o.home, "share-known-hosts") }) } };
74
+ }
75
+ /** A `share-tunnel-<pid>.ssh` a `kill -9` left behind (it may hold a token): removed unless its process is alive. */
76
+ export function sweepTunnelFiles(home, isAlive) {
77
+ for (const name of fs.readdirSync(home)) {
78
+ const m = /^share-tunnel-(\d+)\.ssh$/.exec(name);
79
+ if (m !== null && !isAlive(Number(m[1])))
80
+ fs.rmSync(path.join(home, name), { force: true });
81
+ }
82
+ }
83
+ const PINGGY_ADDRESS = /\btcp:\/\/([a-z0-9][a-z0-9.-]*):(\d{1,5})(?![\d.])/i;
84
+ const BORE_ADDRESS = /\blistening at ([a-z0-9][a-z0-9.-]*):(\d{1,5})(?![\d.])/i;
85
+ /** The public address in what a client printed so far, or null: a shape, and a real port. */
86
+ export function tunnelAddress(via, output) {
87
+ const m = (via === "pinggy" ? PINGGY_ADDRESS : BORE_ADDRESS).exec(stripVTControlCharacters(output));
88
+ if (m === null)
89
+ return null;
90
+ const port = Number(m[2]);
91
+ return port >= 1 && port <= 65_535 ? { host: m[1].toLowerCase(), port } : null;
92
+ }
93
+ /** When a free tunnel ends: what its client says, else its known limit; null when it has none. */
94
+ export function tunnelEnds(plan, output, now) {
95
+ if (!plan.free)
96
+ return null;
97
+ const said = /expires?\s+in\s+(\d+)\s*min/i.exec(stripVTControlCharacters(output));
98
+ return new Date(now + (said === null ? FREE_MINUTES : Number(said[1])) * 60_000).toISOString();
99
+ }
100
+ /** The last lines a client printed, on one line, its token masked: what a failure repeats. */
101
+ export function lastLines(output, token) {
102
+ const lines = stripVTControlCharacters(output).split(/[\r\n]+/).map((l) => l.trim()).filter((l) => l !== "");
103
+ const text = lines.slice(-SAID_LINES).join(" / ");
104
+ return token === null ? text : text.replaceAll(token, "<token>");
105
+ }
106
+ /** Starts a tunnel client and reads its address: the tunnel, or why not. `onPid`: as soon as there is a process. */
107
+ export function startTunnel(plan, o) {
108
+ return new Promise((resolve) => {
109
+ const forget = () => { if (plan.file !== null)
110
+ fs.rmSync(plan.file.path, { force: true }); };
111
+ if (plan.file !== null)
112
+ fs.writeFileSync(plan.file.path, plan.file.text, { mode: 0o600 });
113
+ // detached: a session of its own, so no terminal to prompt on, and Ctrl-C reaches this process alone, which ends it.
114
+ // stdin a pipe held open: the end of stdin would end the session the service prints on
115
+ const child = spawn(plan.bin, plan.args, { stdio: ["pipe", "pipe", "pipe"], env: plan.env, detached: true });
116
+ let output = "", settled = false, finished = () => undefined;
117
+ const ended = new Promise((r) => { finished = r; });
118
+ const stop = () => {
119
+ if (child.exitCode !== null || child.signalCode !== null)
120
+ return;
121
+ child.kill("SIGTERM");
122
+ setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
123
+ child.kill("SIGKILL"); }, STOP_GRACE_MS).unref();
124
+ };
125
+ const settle = (v) => {
126
+ if (settled)
127
+ return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ forget();
131
+ resolve(v);
132
+ };
133
+ const timer = setTimeout(() => {
134
+ stop();
135
+ const said = lastLines(output, plan.token);
136
+ settle({ message: `the tunnel gave no address in ${Math.round(o.timeoutMs / 1000)} s`, code: "failed", why: said === "" ? `${plan.bin} printed nothing` : `${plan.bin} printed: ${said}`,
137
+ fix: `its output is the service's, and may have changed: this Mason cannot read it${plan.via === "pinggy" ? ", and --via bore is another tunnel" : ""}. Nothing was left open` });
138
+ }, o.timeoutMs);
139
+ const read = (b) => {
140
+ output = (output + b.toString("utf8")).slice(-KEPT_BYTES);
141
+ if (settled)
142
+ return;
143
+ const address = tunnelAddress(plan.via, output);
144
+ if (address === null)
145
+ return;
146
+ settle({ ...address, endsAt: tunnelEnds(plan, output, Date.now()), pid: child.pid, ended, stop });
147
+ };
148
+ child.stdout.on("data", read);
149
+ child.stderr.on("data", read);
150
+ // the end of stdin would be a command's end, and EPIPE from a client gone is that end, said by its exit
151
+ child.stdin.on("error", () => undefined);
152
+ child.on("spawn", () => o.onPid?.(child.pid));
153
+ child.on("error", (e) => {
154
+ if (e.code !== "ENOENT") {
155
+ settle({ message: `${plan.bin} could not be started: ${e.message}`, code: "failed" });
156
+ return;
157
+ }
158
+ settle({ message: `${plan.bin} is not on this machine's PATH, and --via ${plan.via} needs it`, code: "not_found", fix: `install ${plan.install}${plan.via === "pinggy" ? ", or use --via bore" : ", or use --via pinggy, which needs only ssh"}`,
159
+ next: [[`mason share --via ${plan.via === "pinggy" ? "bore" : "pinggy"}`, "the other tunnel"]] });
160
+ });
161
+ const over = (code, signal) => {
162
+ const said = lastLines(output, plan.token);
163
+ const how = signal !== null ? `it was ended (${signal})` : `${plan.bin} exited ${code}`;
164
+ finished(said === "" ? how : `${how}: ${said}`);
165
+ settle({ message: "the tunnel ended before it gave an address", code: "failed", why: said === "" ? how : `${how}: ${said}`,
166
+ fix: plan.via === "pinggy" && /REMOTE HOST IDENTIFICATION|Host key verification failed/i.test(said) ? "the service's host key changed: remove .mason/share-known-hosts if you expected that" : "is this machine online? Port 443 outbound is all it needs" });
167
+ };
168
+ // 'close' waits for its pipes, which a grandchild can hold for ever: 'exit' ends the wait
169
+ let code = null, signal = null;
170
+ child.on("exit", (c, s) => { code = c; signal = s; setTimeout(() => over(c, s), 200).unref(); });
171
+ child.on("close", () => over(code, signal));
172
+ });
173
+ }
174
+ /**
175
+ * WHETHER THE ADDRESS IS THIS SHARE: an SSLRequest and a handshake through the tunnel, its certificate read, no login.
176
+ * `other`: something else answered there, a wrong address or something in the way. `unknown`: nothing answered in time
177
+ * (a firewall may keep this machine from a port the BI tool can reach): no verdict, and said so.
178
+ */
179
+ export function probeShare(address, fingerprint, timeoutMs = PROBE_MS) {
180
+ return new Promise((resolve) => {
181
+ const socket = net.connect(address.port, address.host);
182
+ let secure = null;
183
+ // the TLS socket owns the connection once it wraps it: destroying the one under it segfaults Node (measured, 24.18)
184
+ const answer = (v) => { clearTimeout(timer); (secure ?? socket).destroy(); resolve(v); };
185
+ const timer = setTimeout(() => answer("unknown"), timeoutMs);
186
+ socket.once("error", () => answer("unknown"));
187
+ socket.once("close", () => answer("unknown"));
188
+ socket.once("connect", () => socket.write(Buffer.from([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f])));
189
+ socket.once("data", (b) => {
190
+ if (b[0] !== 0x53 /* S */) {
191
+ answer("other");
192
+ return;
193
+ }
194
+ socket.removeAllListeners("close");
195
+ secure = tls.connect({ socket, rejectUnauthorized: false, ALPNProtocols: ["postgresql"] });
196
+ secure.once("error", () => answer("unknown"));
197
+ secure.once("secureConnect", () => answer(secure?.getPeerX509Certificate()?.fingerprint256 === fingerprint ? "same" : "other"));
198
+ });
199
+ });
200
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): what `mason status` says,
3
- * refreshed while the tab is seen, and the users of `mason user`. 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
2
+ * `mason ui` — THE WEB PAGE OF THE RUNNING MASON, IN THE BROWSER (src/web/, docs/WEB-UI.md): the Overview (what `mason
3
+ * status` says), the Tables (`mason tables`, `add`, `remove`, `sync`), the Users (`mason user`) and Observability (`mason
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
4
5
  * it) and opens the browser. The reader is the person at this terminal, whom `mason user add` already shows a
5
6
  * password once; the token is never written anywhere else — not in `mason start`'s output (its log is a file), not in
6
7
  * `mason status`, which prints the URL without it.
@@ -16,12 +17,14 @@ const out = (m) => { process.stdout.write(`${m}\n`); };
16
17
  export const UI = {
17
18
  needs: "run",
18
19
  name: "ui",
19
- summary: "the status page and the users, in your browser",
20
+ summary: "the status, tables, users and statements, in your browser",
20
21
  usage: `usage: mason ui [--no-open] [--json]
21
22
 
22
- Opens the running Mason's web page in your browser: what mason status says (the database, each table's copy and
23
- its progress, the slow and refused statements, whether it is ready), refreshed every 2 s while the tab is open, and
24
- the users of mason user — add one (its password shown once, with the URL to paste) or remove one.
23
+ Opens the running Mason's web page in your browser, refreshed while the tab is open: the Overview (what mason status
24
+ says: whether the synced data is ready, the database, the sync, the statements and the recent errors); the Tables (add,
25
+ remove or sync one, as mason add, remove and sync do); the Users of mason user (add one, its password shown once with
26
+ the URL to paste, or remove one); and Observability: the statements running and that ran (mason logs --requests,
27
+ cancel one), usage over time (mason usage), and the sync's events (mason logs --events).
25
28
 
26
29
  The URL carries this run's token: the page trades it for a cookie at once and drops it from the address
27
30
  bar. It is printed for you alone, like a user's password: paste it nowhere else. Every mason start makes a new
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `mason usage`: what the run was and did over time — a sample a minute (src/duck/usage.ts) of the copy's size and
3
+ * rows, the copies failed, how old the stalest copy is, the statements and their duration, the stream's lag and the
4
+ * memory — and each copy's own size. The run's `GET /api/usage` (src/cli/run-server.ts) answers it; `--json` prints that
5
+ * answer as it is, so an agent and a chart read the same samples a person reads here.
6
+ */
7
+ import { failed, jsonText, parseCommand, endText } from "./command.js";
8
+ import { out as styleOut } from "./style.js";
9
+ import { callRun } from "./control.js";
10
+ import { reach } from "./state.js";
11
+ import { fileSize, guarded, runFailure } from "./commands.js";
12
+ import { timeOf } from "./request-filter.js";
13
+ import { duration, msText } from "./readiness.js";
14
+ import { SIZE_EVERY_MS } from "../duck/usage.js";
15
+ const out = (m) => { process.stdout.write(`${m}\n`); };
16
+ const n = (x) => x.toLocaleString("en-US");
17
+ export const USAGE_USAGE = `usage: mason usage [--since <when>] [--until <when>] [--step <span>] [--json]
18
+
19
+ What Mason was and did over time, from a sample it takes every minute and keeps MASON_HISTORY_DAYS days
20
+ (default 7): a line each span — the statements of the Postgres API (how many, how many were refused, failed or
21
+ cancelled — the errors column —, the completed ones' p95 duration, the rows sent), the synced data's size and rows, the
22
+ tables whose sync is failing, how old the stalest table was, the stream's lag (a table under sync: replication), and
23
+ the memory of the process and of the engine against its limit (MASON_DUCKDB_MEMORY). Then each synced table's own size, measured every
24
+ 10 minutes. A span sums its statements and shows each level at its highest. Read through the running Mason.
25
+
26
+ mason usage the last hour, 5 minutes a line
27
+ mason usage --since 7d --step 1h the last week, an hour a line
28
+ mason usage --since 2026-10-28T09:00:00Z --until 2026-10-28T10:00:00Z --step 0
29
+
30
+ --since <when> from when: an age (90s, 30m, 6h, 7d) or an ISO 8601 date or time with its zone; default 1h
31
+ --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 }
41
+ `;
42
+ export const USAGE = {
43
+ needs: "run",
44
+ name: "usage",
45
+ summary: "what Mason did over time: statements, the synced data's size and age, memory (a sample a minute; --since 7d --step 1h)",
46
+ usage: USAGE_USAGE,
47
+ options: { since: { type: "string" }, until: { type: "string" }, step: { type: "string" }, json: { type: "boolean" } },
48
+ json: ["since", "until", "stepSeconds", "sampleSeconds", "keptDays", "samples", "copySizes", "next"],
49
+ };
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. */
52
+ export function spanSeconds(text) {
53
+ if (text.trim() === "0")
54
+ return 0;
55
+ const m = /^\s*(\d+)\s*([smhd])\s*$/.exec(text);
56
+ return m ? Number(m[1]) * UNIT[m[2]] : null;
57
+ }
58
+ /** 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
+ export function sampleLine(s, withLag) {
60
+ const time = s.at.slice(0, 16).replace("T", " ");
61
+ const p95 = s.durationP95Ms === null ? "–" : msText(s.durationP95Ms);
62
+ const bad = s.statementsRefused + s.statementsFailed + s.statementsCancelled;
63
+ const age = s.oldestSyncSeconds === null ? "–" : duration(s.oldestSyncSeconds);
64
+ const lag = !withLag ? "" : ` ${(s.streamLagSeconds === null ? "–" : duration(s.streamLagSeconds)).padStart(9)}`;
65
+ const memory = `${fileSize(s.rssBytes)}${s.copyMemoryBytes === null ? "" : ` (engine ${fileSize(s.copyMemoryBytes)} of ${fileSize(s.copyMemoryLimitBytes)})`}`;
66
+ return `${time} ${n(s.statements).padStart(10)} ${(bad === 0 ? "" : n(bad)).padStart(7)} ${p95.padStart(10)} ${n(s.rowsSent).padStart(11)} ` +
67
+ `${(s.copyBytes === null ? "–" : fileSize(s.copyBytes)).padStart(10)} ${n(s.copyRows).padStart(13)} ${(s.tablesFailed === 0 ? "" : n(s.tablesFailed)).padStart(7)} ` +
68
+ `${age.padStart(10)}${lag} ${memory}`;
69
+ }
70
+ /** The heading over `sampleLine`'s columns. */
71
+ export function sampleHeading(withLag) {
72
+ return `${"time (UTC)".padEnd(16)} ${"statements".padStart(10)} ${"errors".padStart(7)} ${"p95".padStart(10)} ${"rows sent".padStart(11)} ` +
73
+ `${"size".padStart(10)} ${"rows".padStart(13)} ${"failing".padStart(7)} ${"oldest".padStart(10)}${withLag ? ` ${"lag".padStart(9)}` : ""} memory`;
74
+ }
75
+ export async function usage(argv, now = Date.now()) {
76
+ const f = parseCommand(USAGE, argv);
77
+ if (typeof f === "number")
78
+ return f;
79
+ const v = f.values;
80
+ const json = v.json === true;
81
+ if (f.positionals.length)
82
+ return failed("usage", { message: `it takes no ${f.positionals.length === 1 ? "argument" : "arguments"}: ${f.positionals.join(" ")}`, code: "usage", usage: USAGE_USAGE }, { json });
83
+ // the one time grammar (src/cli/request-filter.ts timeOf): an age, or an ISO 8601 date or time WITH its zone
84
+ const since = timeOf("--since", v.since ?? "1h", now), until = v.until === undefined ? new Date(now).toISOString() : timeOf("--until", v.until, now);
85
+ for (const t of [since, until])
86
+ if (typeof t !== "string")
87
+ return failed("usage", { ...t, usage: USAGE_USAGE }, { json });
88
+ const step = spanSeconds(v.step ?? "5m");
89
+ 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 });
91
+ const w = await reach(USAGE, json);
92
+ if (w === null || !("run" in w))
93
+ return 1;
94
+ const run = w.run;
95
+ return guarded("usage", json, async () => {
96
+ const q = new URLSearchParams({ since: since, until: until, ...(step === 0 ? {} : { step: String(step) }) });
97
+ const r = await callRun(run, "GET", `/api/usage?${q}`);
98
+ // the run's refusal (a step past the days kept, a since after until) in its own words and code
99
+ if (r.status !== 200)
100
+ return failed("usage", runFailure(r, [["mason usage --help", "the spans and times it takes"], ["mason logs", "the run's log"]]), { json });
101
+ const b = r.body;
102
+ const next = [["mason usage --since 7d --step 1h", "the week, an hour a line"], ["mason status", "where everything is now"]];
103
+ if (json) {
104
+ out(jsonText(USAGE, b, next));
105
+ return 0;
106
+ }
107
+ const st = styleOut();
108
+ const span = b.stepSeconds === null ? "every sample" : `${duration(b.stepSeconds)} a line`;
109
+ out(st.bold(`Usage from ${b.since.slice(0, 16).replace("T", " ")} to ${b.until.slice(0, 16).replace("T", " ")} UTC, ${span} (a sample every ${duration(b.sampleSeconds)}, kept ${b.keptDays} day${b.keptDays === 1 ? "" : "s"}):`));
110
+ if (!b.samples.length)
111
+ out(st.dim(" no sample in this time: Mason takes the first a minute after it starts"));
112
+ else {
113
+ const withLag = b.samples.some((s) => s.streamLagBytes !== null || s.streamLagSeconds !== null);
114
+ out(st.dim(sampleHeading(withLag)));
115
+ for (const s of b.samples)
116
+ out(sampleLine(s, withLag));
117
+ }
118
+ if (b.copySizes.length) {
119
+ out("");
120
+ out(st.bold(`Each synced table's own size (measured every ${duration(SIZE_EVERY_MS / 1000)}):`));
121
+ const wide = Math.max(...b.copySizes.map((c) => c.table.length));
122
+ for (const c of b.copySizes)
123
+ out(` ${c.table.padEnd(wide)} ${fileSize(c.bytes).padStart(10)} ${st.dim(`at ${c.at.slice(11, 16)} UTC`)}`);
124
+ }
125
+ out(endText({ next }, st));
126
+ return 0;
127
+ });
128
+ }