@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
@@ -55,6 +55,45 @@ function collect(n, kind, out = []) {
55
55
  }
56
56
  return out;
57
57
  }
58
+ /**
59
+ * Whether a statement of no table has a construct that can order text, read off the raw tree: a sort (ORDER BY, a
60
+ * window's, an aggregate's or WITHIN GROUP's), <, >, <=, >= (BETWEEN, ANY / ALL and a subquery's among them),
61
+ * GREATEST / LEAST, min / max, upper / lower / initcap (which map case, issue 406), and a COLLATE (`'a' = 'A' COLLATE mason_ci`). One without (`SELECT 1`, `SELECT version()`, `current_setting(…)`, `'a' = 'A'`) is the
62
+ * catalog's without asking its analysis, which costs a statement several milliseconds (`ordersText`, issue 122): a
63
+ * statement it says may order is asked, so a false "may" costs time, never an answer.
64
+ */
65
+ export function mayOrderText(n) {
66
+ if (Array.isArray(n))
67
+ return n.some(mayOrderText);
68
+ if (n === null || typeof n !== "object")
69
+ return false;
70
+ const named = (x) => collect(x ?? [], "String").map((s) => String(bodyOf(s).sval ?? ""));
71
+ for (const [k, v] of Object.entries(n)) {
72
+ if ((k === "sortClause" || k === "orderClause" || k === "agg_order") && Array.isArray(v) && v.length > 0)
73
+ return true;
74
+ if (k === "MinMaxExpr")
75
+ return true;
76
+ // a COLLATE names a collation the source declared, whose rules the catalog's copy of it does not hold: a
77
+ // nondeterministic one equates otherwise, and the synced data's plan refuses it by name (`ordersText` asks)
78
+ if (k === "CollateClause")
79
+ return true;
80
+ if ((k === "A_Expr" || k === "SubLink") && v !== null && typeof v === "object") {
81
+ const b = v;
82
+ if (typeof b.kind === "string" && b.kind.includes("BETWEEN"))
83
+ return true;
84
+ if (named(b.name ?? b.operName).some((o) => ORDERING_OPERATORS.has(o)))
85
+ return true;
86
+ }
87
+ // min / max order text; upper / lower / initcap map its case, which the catalog does by its own rules (issue 406)
88
+ if (k === "FuncCall" && v !== null && typeof v === "object" && COLLATED_CALLS.has((named(v.funcname).at(-1) ?? "").toLowerCase()))
89
+ return true;
90
+ if (mayOrderText(v))
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+ const ORDERING_OPERATORS = new Set(["<", ">", "<=", ">="]);
96
+ const COLLATED_CALLS = new Set(["min", "max", "upper", "lower", "initcap"]);
58
97
  const INFORMATION_SCHEMA = "information_schema";
59
98
  const PG_CATALOG = "pg_catalog";
60
99
  /** Postgres's IsTransactionExitStmt: what a failed block still accepts. `ROLLBACK TO` and `PREPARE TRANSACTION` are among
@@ -133,6 +172,8 @@ export function duckTranslate(stmt, text) {
133
172
  const ctes = (s.withClause?.ctes ?? []).map((n) => bodyOf(n));
134
173
  const names = new Set(ctes.map((c) => String(c.ctename ?? "")));
135
174
  const relations = collect(s, "RangeVar").map((n) => bodyOf(n)).filter((r) => r.schemaname !== undefined || !names.has(String(r.relname ?? "")));
175
+ if (relations.length === 0)
176
+ return mayOrderText(s) ? { kind: "catalog", fromless: { names: pgColumnNames(s) } } : { kind: "catalog" };
136
177
  if (relations.every(isCatalogRelation))
137
178
  return { kind: "catalog" };
138
179
  return { kind: "duckdb", sql: text, names: pgColumnNames(s) };
@@ -288,8 +329,12 @@ function setStatement(s) {
288
329
  if (s.kind === "VAR_RESET_ALL")
289
330
  return { kind: "set", name: "all", value: null, local: false, tag: "RESET" };
290
331
  // SET TRANSACTION is for the transaction alone, whichever way it is spelt; SET SESSION CHARACTERISTICS is the session's
291
- if (s.kind === "VAR_SET_MULTI")
292
- return { kind: "set", name, value: "", local: name === "transaction", tag: "SET" };
332
+ // its value is the isolation level it names, '' for none (`READ ONLY` alone): the listener remembers the level
333
+ if (s.kind === "VAR_SET_MULTI") {
334
+ const level = (s.args ?? []).map((a) => a.DefElem).find((d) => d?.defname === "transaction_isolation");
335
+ const value = level?.arg !== undefined && kindOf(level.arg) === "A_Const" ? constText(bodyOf(level.arg)) : "";
336
+ return { kind: "set", name, value, local: name === "transaction", tag: "SET" };
337
+ }
293
338
  if (s.kind !== "VAR_SET_VALUE")
294
339
  throw new Unsupported(`SET ${name}: ${String(s.kind)} is not supported`, s.location ?? null);
295
340
  const parts = (s.args ?? []).map((a) => {
@@ -17,6 +17,7 @@ import * as path from "node:path";
17
17
  import { spawn } from "node:child_process";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { runnable } from "../lib/runnable.js";
20
+ import { LogReader, logPath } from "../lib/log-read.js";
20
21
  import { READY, STEP_MARK } from "./dev.js";
21
22
  import { liveRun } from "./runs.js";
22
23
  import { projectSource, startSettingRefusal, theProject } from "./project.js";
@@ -26,12 +27,13 @@ import { recordRefusal } from "./replication-mode.js";
26
27
  import { shownPath } from "./sources.js";
27
28
  import { endText, failed, jsonText, nextBlock } from "./command.js";
28
29
  import { historyDaysSetting } from "../duck/history.js";
30
+ import { memoryProblems } from "../duck/store.js";
29
31
  import { DEV } from "./registry.js";
30
32
  import { UI } from "./ui.js";
31
33
  import { out as styleOut, stepRenderer } from "./style.js";
32
34
  import { postgresLink, withoutPlaceholders } from "./pg-url.js";
33
35
  const say = (m) => { process.stdout.write(`${m}\n`); };
34
- const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "main.ts");
36
+ const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), "mason.cjs");
35
37
  const SUPERVISE = path.join(path.dirname(fileURLToPath(import.meta.url)), "supervise.ts");
36
38
  /** The commands to run next, as `mason start` prints them once the run is up: one a line, what each does beside it
37
39
  * (not `mason status | mason logs | mason stop` on one line). */
@@ -91,6 +93,11 @@ export async function devInBackground(args, o) {
91
93
  const days = historyDaysSetting(process.env);
92
94
  if (typeof days !== "number")
93
95
  return failed("start", { code: "refused", ...days }, { json: o.json });
96
+ // MASON_DUCKDB_MEMORY not an amount (src/duck/store.ts memoryProblems), by the run's own check, refused here as in the
97
+ // run, before anything is spawned or opened; a size under the floor only warns, in the run (memoryWarning)
98
+ const memory = memoryProblems(process.env)[0];
99
+ if (memory !== undefined)
100
+ return failed("start", { code: "refused", ...memory }, { json: o.json });
94
101
  // what the config records and the file does not say (src/cli/replication-mode.ts), in the run's order: settings first
95
102
  const unsaid = file === null ? null : recordRefusal(t.project.config, file, process.env, shown, "mason start");
96
103
  if (unsaid !== null)
@@ -107,12 +114,13 @@ export async function devInBackground(args, o) {
107
114
  return failed("start", listen, { json: o.json });
108
115
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
109
116
  // the run's terminal: its setup URL carries the run's token and it prints the API key, so only the developer reads it
110
- const logFile = path.join(home, "dev.log");
117
+ const logFile = logPath(home, "start");
111
118
  const fd = fs.openSync(logFile, "a", 0o600);
112
119
  fs.chmodSync(logFile, 0o600);
113
120
  const from = fs.fstatSync(fd).size;
114
121
  fs.writeSync(fd, `\n--- mason start ${new Date().toISOString()} ---\n`);
115
- // the run is started IN the project's root, so it finds the same project
122
+ // the run is started IN the project's root, so it finds the same project. Its env carries the size this process
123
+ // set (src/cli/pool-size.cjs), and the supervisor between them inherits it too: it never uses that many
116
124
  const run = [process.execPath, runnable(MAIN), "start", ...args.filter((a) => a !== "--json"), "--attach"];
117
125
  const child = spawn(process.execPath, [runnable(SUPERVISE), home, logFile, ...run], { detached: true, stdio: ["ignore", fd, fd], cwd: t.project.root, env: process.env });
118
126
  fs.closeSync(fd);
@@ -129,25 +137,15 @@ export async function devInBackground(args, o) {
129
137
  line(st.bold(`Starting Mason for ${t.project.config.name}`));
130
138
  const steps = stepRenderer({ line, live: !o.json && process.stdout.isTTY ? { write: (x) => { process.stdout.write(x); } } : null, style: st });
131
139
  let begun = false;
132
- let offset = from;
133
- let partial = "";
140
+ // the run's terminal from this start on, through the one reader: a line is had whole, masked (src/lib/log-read.ts)
141
+ const reader = new LogReader(logFile, { from });
134
142
  let postgres = null;
135
143
  const since = []; // the run's lines after its last step: a failure's reasons
136
144
  try {
137
145
  for (;;) {
138
- const size = fs.statSync(logFile).size;
139
- if (size > offset) {
140
- const buf = Buffer.alloc(size - offset);
141
- const r = fs.openSync(logFile, "r");
142
- try {
143
- fs.readSync(r, buf, 0, buf.length, offset);
144
- }
145
- finally {
146
- fs.closeSync(r);
147
- }
148
- offset = size;
149
- const lines = (partial + buf.toString()).split("\n");
150
- partial = lines.pop() ?? "";
146
+ for (let text = reader.read(); text !== null; text = reader.read()) {
147
+ const lines = text.split("\n");
148
+ lines.pop(); // every line read ends in its newline
151
149
  for (const l of lines) {
152
150
  if (l.startsWith("--- mason start "))
153
151
  continue;
@@ -206,7 +204,8 @@ export async function devInBackground(args, o) {
206
204
  if (exited !== null) {
207
205
  if (steps.open() !== null)
208
206
  steps.fail();
209
- if (partial)
207
+ const partial = reader.rest(); // the line it was writing, unended
208
+ if (partial.trim())
210
209
  since.push(partial.trim());
211
210
  // the run's own last words are the reason (a wrong password), already shown line by line to a person
212
211
  return failed("start", { message: `the run stopped before it was ready (exit ${exited})`, code: "failed", why: o.json && since.length ? since.join("\n") : undefined,
@@ -0,0 +1,101 @@
1
+ /**
2
+ * THE BRIDGE'S STDIN, READ ON A THREAD OF ITS OWN, AND ITS WATCHDOG (src/cli/ui-ssh.ts `bridge`, `StdinByWorker`).
3
+ *
4
+ * `mason ui --bridge` ends when its stdin ends: the laptop's ssh gone. Heard on the main thread, that end waits on the
5
+ * main event loop, and a loop that is blocked (a synchronous call that never returns, a hung native call, a busy loop)
6
+ * never hears it: the process would live on, holding the run's tunnel, with nobody left who can end it. So this thread
7
+ * owns fd 0, on an event loop of its own (Node's docs, worker_threads: "each Worker has its own event loop"), forwards
8
+ * its bytes to the main thread, and on its end arms a timer here; if the process is still alive when it fires, the main
9
+ * thread did not end it, so this one does, by SIGKILL to the process (`process.kill`, which a worker may call: a signal
10
+ * is delivered by the system, not by a loop, and SIGKILL cannot be caught or delayed by anything the process runs).
11
+ *
12
+ * THE FLOW CONTROL IS THIS THREAD'S, BY CREDIT: it hands the main thread at most `windowBytes` it has not acknowledged
13
+ * (`ack`: consumed, and FrameLink not paused), and stops reading at that bound (`stream.pause()`, so the pipe fills
14
+ * and the laptop's writes wait, as they did on `process.stdin`). Nothing is read into memory past it, whatever the main
15
+ * thread does. A stopped reader cannot hear stdin's end, so the main thread says it is alive (`beat`, every
16
+ * `BEAT_MS`): a main thread that is only waiting (on the run's page taking what was sent) keeps beating and the reader
17
+ * waits with it; one silent for `exitMs` with the window full is STUCK, and this thread then reads on and DROPS what it
18
+ * reads, so that the end is heard and the watchdog can fire. The stream it fed is then broken, which the main thread is
19
+ * told (`dropped`): if it ever runs again it ends the bridge, exit 1, never reads past the hole.
20
+ *
21
+ * fd 0 is opened as Node opens `process.stdin` for its kind (Node's docs, process.stdin, net.Socket `fd`, tty.ReadStream,
22
+ * fs.createReadStream `fd`): a pipe or a socket (ssh's, `docker exec -i`'s) as a `net.Socket`, a terminal (`ssh -t`) as a
23
+ * `tty.ReadStream`, anything else (a file, /dev/null) as a file stream, which always reaches its end. A read error is an
24
+ * end too (a terminal hung up reads EIO). A stdin that cannot be opened at all is said, and ends the bridge.
25
+ */
26
+ import * as fs from "node:fs";
27
+ import * as net from "node:net";
28
+ import * as tty from "node:tty";
29
+ import { parentPort, workerData } from "node:worker_threads";
30
+ const { exitMs, windowBytes } = workerData;
31
+ if (parentPort === null)
32
+ throw new Error("src/cli/bridge-stdin.ts is a worker thread's script, started by src/cli/ui-ssh.ts");
33
+ const port = parentPort;
34
+ let over = false;
35
+ /** Bytes handed to the main thread that it has not acknowledged. */
36
+ let owed = 0;
37
+ let lastHeard = Date.now();
38
+ let dropping = false;
39
+ /** The end, once: said to the main thread, then the watchdog armed. */
40
+ function finish(why, failed = false) {
41
+ if (over)
42
+ return;
43
+ over = true;
44
+ port.postMessage({ end: why, failed });
45
+ setTimeout(() => {
46
+ try {
47
+ fs.writeSync(2, `mason ui --bridge: its stdin closed ${exitMs / 1000} s ago and it had not ended (its main thread is blocked): ended now, and the tunnel with it\n`);
48
+ }
49
+ catch (e) {
50
+ void e; /* stderr gone too, as the laptop's ssh is: the kill below is what ends the process */
51
+ }
52
+ process.kill(process.pid, "SIGKILL");
53
+ }, exitMs);
54
+ }
55
+ function open() {
56
+ if (tty.isatty(0))
57
+ return new tty.ReadStream(0);
58
+ const kind = fs.fstatSync(0);
59
+ if (kind.isFIFO() || kind.isSocket())
60
+ return new net.Socket({ fd: 0, readable: true, writable: false });
61
+ return fs.createReadStream("", { fd: 0, autoClose: false });
62
+ }
63
+ let input = null;
64
+ try {
65
+ input = open();
66
+ }
67
+ catch (e) {
68
+ finish(`its stdin cannot be read: ${e.message}`, true);
69
+ }
70
+ if (input !== null) {
71
+ const stream = input;
72
+ stream.on("data", (b) => {
73
+ if (dropping)
74
+ return;
75
+ const copy = Uint8Array.from(b);
76
+ port.postMessage({ data: copy }, [copy.buffer]);
77
+ owed += b.length;
78
+ if (owed >= windowBytes)
79
+ stream.pause();
80
+ });
81
+ stream.once("end", () => { finish(null); });
82
+ stream.once("error", (e) => { finish(`its stdin: ${e.message}`); });
83
+ stream.once("close", () => { finish(null); });
84
+ port.on("message", (m) => {
85
+ lastHeard = Date.now();
86
+ if (!("ack" in m) || dropping)
87
+ return;
88
+ owed -= m.ack;
89
+ if (owed < windowBytes && stream.isPaused())
90
+ stream.resume();
91
+ });
92
+ // a main thread silent for the bound while the window is full is stuck: read on, dropping, so the end is heard
93
+ const look = setInterval(() => {
94
+ if (dropping || over || owed < windowBytes || Date.now() - lastHeard < exitMs)
95
+ return;
96
+ dropping = true;
97
+ clearInterval(look);
98
+ port.postMessage({ dropped: owed });
99
+ stream.resume();
100
+ }, Math.min(250, exitMs));
101
+ }