@mason-data/cli 0.7.0 → 0.9.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -1,3 +1,17 @@
1
+ /**
2
+ * WHAT A STATEMENT IS, from Postgres 18's own parse tree (parser.ts), decided by node kind, never by text:
3
+ *
4
+ * - a SELECT that reads any table outside pg_catalog / information_schema: DuckDB's (`duckdb`), planned by the
5
+ * catalog Postgres and rewritten (src/duck/plan.ts) — the text here is only what the plan is made from;
6
+ * - a CATALOG statement (one that reads only `pg_catalog` / `information_schema`, or no table at all: `SELECT
7
+ * version()`, `SELECT 1`): forwarded by the server to the Postgres inside the process (catalog.ts), which answers it
8
+ * as Postgres;
9
+ * - a SESSION statement (`SET`, `SHOW`, `BEGIN` / `COMMIT`, `DISCARD`, `DEALLOCATE`): the server's;
10
+ * - an EXPLAIN: the statement it explains, classified as it would be alone (src/api/postgres/explain.ts);
11
+ * - anything else: REFUSED BY NAME (`Unsupported`), with its position in the statement, a SQLSTATE and the way
12
+ * out, never passed through on a guess. The switch has no default branch that accepts.
13
+ */
14
+ import { gapMark } from "../../duck/gaps.js";
1
15
  /** A statement, or a part of one, this listener does not answer: named, placed, with a SQLSTATE and the way out. */
2
16
  export class Unsupported extends Error {
3
17
  /** 0-based offset into the statement's text, or null when the whole statement is meant */
@@ -5,11 +19,14 @@ export class Unsupported extends Error {
5
19
  /** `0A000` feature not supported; `42601` syntax; `25006` read only; `22023` an invalid value */
6
20
  sqlstate;
7
21
  hint;
8
- constructor(message, offset, sqlstate = "0A000", hint) {
22
+ /** the registry entry that refused it (src/duck/gaps.ts): the request log's `gap`; null for any other refusal */
23
+ gap;
24
+ constructor(message, offset, sqlstate = "0A000", hint, gap = null) {
9
25
  super(message);
10
26
  this.offset = offset;
11
27
  this.sqlstate = sqlstate;
12
28
  this.hint = hint;
29
+ this.gap = gap;
13
30
  }
14
31
  }
15
32
  const kindOf = (n) => Object.keys(n)[0] ?? "";
@@ -38,8 +55,55 @@ function collect(n, kind, out = []) {
38
55
  }
39
56
  return out;
40
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"]);
41
97
  const INFORMATION_SCHEMA = "information_schema";
42
98
  const PG_CATALOG = "pg_catalog";
99
+ /** Postgres's IsTransactionExitStmt: what a failed block still accepts. `ROLLBACK TO` and `PREPARE TRANSACTION` are among
100
+ * them, each then refused here for being what it is, a savepoint or a prepared transaction, never for the block. */
101
+ export function endsFailedBlock(stmt) {
102
+ if (kindOf(stmt) !== "TransactionStmt")
103
+ return false;
104
+ const k = bodyOf(stmt).kind;
105
+ return k === "TRANS_STMT_COMMIT" || k === "TRANS_STMT_ROLLBACK" || k === "TRANS_STMT_ROLLBACK_TO" || k === "TRANS_STMT_PREPARE";
106
+ }
43
107
  // ------------------------------------------------------------------ entry
44
108
  /** A statement other than a SELECT or an EXPLAIN (`duckTranslate` takes those): the session's, or refused by name. */
45
109
  function sessionStatement(stmt) {
@@ -51,10 +115,10 @@ function sessionStatement(stmt) {
51
115
  const k = bodyOf(stmt).kind;
52
116
  const tag = k === "TRANS_STMT_BEGIN" ? "BEGIN" : k === "TRANS_STMT_START" ? "START TRANSACTION" : k === "TRANS_STMT_COMMIT" ? "COMMIT" : k === "TRANS_STMT_ROLLBACK" ? "ROLLBACK" : null;
53
117
  if (tag === null)
54
- throw new Unsupported(`${String(k).replace("TRANS_STMT_", "").toLowerCase()}: this connection is read-only and has no transactions; BEGIN, COMMIT and ROLLBACK are accepted and do nothing`, null);
118
+ throw new Unsupported(`${String(k).replace("TRANS_STMT_", "").toLowerCase()}: this connection is read-only and has no savepoints or prepared transactions; BEGIN, COMMIT and ROLLBACK are accepted, and a ROLLBACK undoes the block's SETs`, null);
55
119
  return { kind: "transaction", tag };
56
120
  }
57
- case "DiscardStmt": return { kind: "discard" };
121
+ case "DiscardStmt": return { kind: "discard", target: String(bodyOf(stmt).target ?? "DISCARD_ALL").replace(/^DISCARD_/, "") };
58
122
  case "DeallocateStmt": return { kind: "deallocate" };
59
123
  case "InsertStmt":
60
124
  case "UpdateStmt":
@@ -73,7 +137,7 @@ function sessionStatement(stmt) {
73
137
  case "CreateFunctionStmt":
74
138
  case "CreateSchemaStmt":
75
139
  case "VacuumStmt":
76
- throw new Unsupported(`${kind.replace(/Stmt$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}: this is a read-only copy of the database's tables; only SELECT, SET, SHOW and the transaction no-ops are accepted`, null, "25006");
140
+ throw new Unsupported(`${kind.replace(/Stmt$/, "").replace(/([a-z])([A-Z])/g, "$1 $2").toUpperCase()}: these are the database's tables, synced and read-only; only SELECT, SET, SHOW and the transaction no-ops are accepted`, null, "25006");
77
141
  case "PrepareStmt":
78
142
  case "ExecuteStmt": throw new Unsupported(`${kind === "PrepareStmt" ? "PREPARE" : "EXECUTE"} is not supported: use the protocol's prepared statements (every driver does)`, null);
79
143
  default:
@@ -90,6 +154,9 @@ function sessionStatement(stmt) {
90
154
  * (a review finding).
91
155
  */
92
156
  export function duckTranslate(stmt, text) {
157
+ const configured = setConfig(stmt);
158
+ if (configured !== null)
159
+ return configured;
93
160
  const kind = kindOf(stmt);
94
161
  // an EXPLAIN is the statement it explains (src/api/postgres/explain.ts, src/duck/explain.ts): its options read, then
95
162
  // that statement classified as it would be alone, by the server
@@ -105,10 +172,53 @@ export function duckTranslate(stmt, text) {
105
172
  const ctes = (s.withClause?.ctes ?? []).map((n) => bodyOf(n));
106
173
  const names = new Set(ctes.map((c) => String(c.ctename ?? "")));
107
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" };
108
177
  if (relations.every(isCatalogRelation))
109
178
  return { kind: "catalog" };
110
179
  return { kind: "duckdb", sql: text, names: pgColumnNames(s) };
111
180
  }
181
+ /**
182
+ * `set_config(name, value, is_local)` IS A SET, and a session's SET is the listener's: forwarded, it ran on the catalog's
183
+ * ONE connection, as its superuser, outside any transaction, so its value stayed for EVERY session — `set_config(
184
+ * 'search_path', 'zzz', false)` moved the catalog's resolution of `current_setting` and `version()` to Postgres's own,
185
+ * and a `myapp.x` one session set another read (a review finding of #46). So every call of it is the session's or
186
+ * refused, wherever it is in the statement (an EXPLAIN ANALYZE's included): alone in a SELECT of no table, its
187
+ * arguments literals (what a tool sends: pgAdmin's `bytea_output`, an ORM's `search_path`), it is `SET name = value`,
188
+ * or `SET LOCAL` under `is_local`, answered as the function's row; any other form is the `set-config-forms` gap. null:
189
+ * no call of it.
190
+ */
191
+ function setConfig(stmt) {
192
+ const named = (n) => {
193
+ const parts = strings(bodyOf(n).funcname);
194
+ return parts[parts.length - 1] === "set_config" && (parts.length === 1 || (parts.length === 2 && parts[0] === PG_CATALOG));
195
+ };
196
+ const calls = collect(stmt, "FuncCall").filter(named);
197
+ if (calls.length === 0)
198
+ return null;
199
+ const refuse = (at) => {
200
+ throw new Unsupported(`set_config() is answered by Mason only alone in a SELECT, with literal arguments (${gapMark("set-config-forms")})`, at, "0A000", "write SET name = 'value', or SELECT set_config('name', 'value', false) on its own", "set-config-forms");
201
+ };
202
+ const at = loc(calls[0]);
203
+ if (kindOf(stmt) !== "SelectStmt")
204
+ return refuse(at);
205
+ const s = bodyOf(stmt);
206
+ if (Object.keys(s).some((k) => k !== "targetList" && k !== "limitOption" && k !== "op") || s.op !== "SETOP_NONE")
207
+ return refuse(at);
208
+ const targets = (s.targetList ?? []);
209
+ const target = targets.length === 1 ? bodyOf(targets[0]) : null;
210
+ if (target?.val === undefined || kindOf(target.val) !== "FuncCall" || !named(target.val))
211
+ return refuse(at);
212
+ const call = bodyOf(target.val);
213
+ if (Object.keys(call).some((k) => k !== "funcname" && k !== "args" && k !== "funcformat" && k !== "location"))
214
+ return refuse(at);
215
+ const args = (call.args ?? []).map((a) => kindOf(a) === "A_Const" ? bodyOf(a) : null);
216
+ const text = (c) => typeof c?.sval?.sval === "string" ? c.sval.sval : null;
217
+ const [name, value, local] = [text(args[0] ?? null), text(args[1] ?? null), args[2]?.boolval];
218
+ if (args.length !== 3 || name === null || value === null || local === undefined)
219
+ return refuse(at);
220
+ return { kind: "set_config", name: name.toLowerCase(), value, local: local.boolval === true, column: target.name ?? "set_config" };
221
+ }
112
222
  /**
113
223
  * THE NAMES POSTGRES GIVES A SELECT'S RESULT COLUMNS (`FigureColname`, src/backend/parser/parse_target.c), where DuckDB
114
224
  * gives others (`count(*)` is `count_star()`, `sum(x)` is `sum(x)`; Postgres says `count`, `sum`): an alias as written,
@@ -212,12 +322,19 @@ function figureColname(n) {
212
322
  }
213
323
  function setStatement(s) {
214
324
  const name = String(s.name ?? "").toLowerCase();
215
- if (s.kind === "VAR_RESET" || s.kind === "VAR_SET_DEFAULT")
216
- return { kind: "set", name, value: null, local: s.is_local === true };
325
+ if (s.kind === "VAR_RESET")
326
+ return { kind: "set", name, value: null, local: false, tag: "RESET" };
327
+ if (s.kind === "VAR_SET_DEFAULT")
328
+ return { kind: "set", name, value: null, local: s.is_local === true, tag: "SET" };
217
329
  if (s.kind === "VAR_RESET_ALL")
218
- return { kind: "set", name: "all", value: null, local: false };
219
- if (s.kind === "VAR_SET_MULTI")
220
- return { kind: "set", name, value: "", local: s.is_local === true }; // SET SESSION CHARACTERISTICS …
330
+ return { kind: "set", name: "all", value: null, local: false, tag: "RESET" };
331
+ // SET TRANSACTION is for the transaction alone, whichever way it is spelt; SET SESSION CHARACTERISTICS is the session's
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
+ }
221
338
  if (s.kind !== "VAR_SET_VALUE")
222
339
  throw new Unsupported(`SET ${name}: ${String(s.kind)} is not supported`, s.location ?? null);
223
340
  const parts = (s.args ?? []).map((a) => {
@@ -230,7 +347,7 @@ function setStatement(s) {
230
347
  }
231
348
  throw new Unsupported(`SET ${name}: a value of kind ${kindOf(a)} is not supported (a literal is)`, loc(a));
232
349
  });
233
- return { kind: "set", name, value: parts.join(", "), local: s.is_local === true };
350
+ return { kind: "set", name, value: parts.join(", "), local: s.is_local === true, tag: "SET" };
234
351
  }
235
352
  function constText(c) {
236
353
  if (c.sval !== undefined)
@@ -8,17 +8,30 @@
8
8
  import * as crypto from "node:crypto";
9
9
  /** Fail closed on the storage side: never let one request bloat the ledger. */
10
10
  export const MAX_STORED_PAYLOAD_BYTES = 64 * 1024;
11
+ /** The SQLSTATE of a cancelled statement: a CancelRequest, `mason cancel`, or a statement_timeout (Postgres's 57014). */
12
+ export const CANCELLED_SQLSTATE = "57014";
13
+ /** The SQLSTATE classes whose fault is Mason's or the machine's: XX internal, 53 resources, 57 an operator's, 58 system. */
14
+ export const FAILED_CLASSES = ["XX", "53", "57", "58"];
15
+ export const OUTCOMES = ["ok", "refused", "failed", "cancelled"];
11
16
  /**
12
- * A Postgres-listener statement's outcome in a word, from the SQLSTATE it was sent: `failed` where the fault is Mason's
13
- * or the machine's (classes XX internal, 53 resources, 57 a timeout or a cancel, 58 system), `refused` for the
14
- * statement's own, `ok` for none. Read by the listener's refusal line (server.ts `refusalLine`) and the request log
15
- * (src/duck/requests.ts) alike.
17
+ * A Postgres-listener statement's outcome in a word, from the SQLSTATE it was sent: `cancelled` for 57014 (a cancel or a
18
+ * timeout), `failed` where the fault is Mason's or the machine's (FAILED_CLASSES), `refused` for the statement's own,
19
+ * `ok` for none. Read by the listener's refusal line (server.ts `refusalLine`) and the request log (src/duck/requests.ts)
20
+ * alike; the ledger's filter and summary read the SAME rule as SQL (`outcomeSql`), held equal by tests/duck-requests.test.ts.
16
21
  */
17
22
  export function outcomeOf(sqlstate) {
18
23
  if (sqlstate === null || sqlstate === undefined || sqlstate === "")
19
24
  return "ok";
20
- return /^(XX|53|57|58)/.test(sqlstate) ? "failed" : "refused";
25
+ if (sqlstate === CANCELLED_SQLSTATE)
26
+ return "cancelled";
27
+ return FAILED_CLASSES.some((c) => sqlstate.startsWith(c)) ? "failed" : "refused";
21
28
  }
29
+ /** `outcomeOf` as a SQL expression over a SQLSTATE column: the one rule, for the ledger's filters and summary. */
30
+ export function outcomeSql(column) {
31
+ return `CASE WHEN ${column} IS NULL OR ${column} = '' THEN 'ok' WHEN ${column} = '${CANCELLED_SQLSTATE}' THEN 'cancelled' ` +
32
+ `WHEN left(${column}, 2) IN (${FAILED_CLASSES.map((c) => `'${c}'`).join(", ")}) THEN 'failed' ELSE 'refused' END`;
33
+ }
34
+ export const statusOf = (o) => o === "ok" ? "completed" : o === "cancelled" ? "cancelled" : "failed";
22
35
  /**
23
36
  * The caller's identity WITHOUT the credential: a truncated SHA-256 of what was presented. Distinct callers stay
24
37
  * distinguishable and repeat callers stay correlatable, while the secret itself is never written down — the log must
@@ -56,11 +56,11 @@ export function xminBeforeHolds(current, watermark) {
56
56
  return current >= watermark && current - watermark < 2n ** 30n;
57
57
  }
58
58
  /**
59
- * The wait before the next poll: MASON_POLL_MS, or four times what the last poll took when that is longer, so a
60
- * source whose tables take a while to read spends at most a fifth of its time on polls. Never sooner than either
61
- * (the test server: a cap of a minute polled a 22 M-row production table every 74 s for 6.5 hours under
62
- * MASON_POLL_MS=3600000, and a poll of 20 s every 60 s is a third of the source's time): the database's load decides,
63
- * and a slow poll's freshness is said by its own timing line.
59
+ * The wait before the next poll: the table's interval (its sync:, src/lib/table-sync.ts), or four times what the last
60
+ * poll took when that is longer, so a table that takes a while to read costs at most a fifth of the source's time.
61
+ * Never sooner than either (the test server: a cap of a minute polled a 22 M-row production table every 74 s for 6.5
62
+ * hours under an hourly interval, and a poll of 20 s every 60 s is a third of the source's time): the database's load
63
+ * decides, and a slow poll's freshness is said by its own timing line.
64
64
  */
65
65
  export function nextPollDelayMs(pollMs, lastPollMs) {
66
66
  return Math.max(pollMs, 4 * lastPollMs);
@@ -17,26 +17,34 @@ 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
- import { projectSource, theProject } from "./project.js";
23
+ import { projectSource, startSettingRefusal, theProject } from "./project.js";
23
24
  import { runListenerSettings } from "./pg-tls.js";
24
25
  import { demoChangesRefusal, demoPort } from "./demo.js";
26
+ import { recordRefusal } from "./replication-mode.js";
27
+ import { shownPath } from "./sources.js";
25
28
  import { endText, failed, jsonText, nextBlock } from "./command.js";
29
+ import { historyDaysSetting } from "../duck/history.js";
30
+ import { memoryProblems } from "../duck/store.js";
26
31
  import { DEV } from "./registry.js";
32
+ import { UI } from "./ui.js";
27
33
  import { out as styleOut, stepRenderer } from "./style.js";
28
34
  import { postgresLink, withoutPlaceholders } from "./pg-url.js";
29
35
  const say = (m) => { process.stdout.write(`${m}\n`); };
30
- 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");
31
37
  const SUPERVISE = path.join(path.dirname(fileURLToPath(import.meta.url)), "supervise.ts");
32
38
  /** The commands to run next, as `mason start` prints them once the run is up: one a line, what each does beside it
33
39
  * (not `mason status | mason logs | mason stop` on one line). */
34
40
  export const NEXT_STEPS = [
41
+ // the web page first: a first-time user's most useful next step (the run serves it; mason ui opens it, signed in)
42
+ ["mason ui", UI.summary],
35
43
  ["mason tables", "the database's tables, and what you can add"],
36
- ["mason add <table>", "copy a table into Mason"],
44
+ ["mason add <table>", "sync a table into Mason"],
37
45
  ["mason user add <name>", "a user for a BI tool or your app: its password and URL, printed once"],
38
46
  ["mason status", "what Mason is doing"],
39
- ["mason logs", "its log: every copy, poll and statement"],
47
+ ["mason logs", "its log: every sync, poll and statement"],
40
48
  ["mason stop", "stop Mason; the next mason start resumes where it stopped"],
41
49
  ];
42
50
  /** The ports a demo project's database takes (its URL's port, read as the run reads it), for the API's choice to avoid;
@@ -68,10 +76,32 @@ export async function devInBackground(args, o) {
68
76
  say(nextBlock([["mason status", "what it is doing"], ["mason stop", "stop it"]]));
69
77
  return 0;
70
78
  }
71
- // a demo project with --replication (src/cli/demo.ts): refused here too, where it is read, before a run is spawned
72
- const demoRefused = demoChangesRefusal(t.project.config, o.changes ?? null);
79
+ // a demo project whose sources file says sync: replication (src/cli/demo.ts): refused here too, where it is read,
80
+ // before a run is spawned — the sources file as the run reads it (a file it cannot read is the run's to say, in its checklist)
81
+ const src = projectSource(t.project);
82
+ const file = "message" in src ? null : src;
83
+ const shown = file === null ? "" : shownPath(t.project.root, file.file);
84
+ const demoRefused = demoChangesRefusal(t.project.config, file, shown);
73
85
  if (demoRefused)
74
86
  return failed("start", demoRefused, { json: o.json });
87
+ // a setting a start refuses, here where the person (or --json) reads it, by the run's own list (src/cli/project.ts)
88
+ const setting = startSettingRefusal(t.project, process.env, { serve: false });
89
+ if (setting !== null)
90
+ return failed("start", setting, { json: o.json });
91
+ // the history's days (MASON_HISTORY_DAYS, a number of days; the old MASON_REQUEST_LOG_DAYS refused), by the run's own
92
+ // check (src/duck/history.ts), refused here as in the run
93
+ const days = historyDaysSetting(process.env);
94
+ if (typeof days !== "number")
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 });
101
+ // what the config records and the file does not say (src/cli/replication-mode.ts), in the run's order: settings first
102
+ const unsaid = file === null ? null : recordRefusal(t.project.config, file, process.env, shown, "mason start");
103
+ if (unsaid !== null)
104
+ return failed("start", unsaid, { json: o.json });
75
105
  // a MASON_PG_PORT that is no port, or is held, is refused HERE, where the person (or --json) reads it, before a run is
76
106
  // spawned; the run checks again for itself (choosePgPort, src/cli/ports.ts), since a port may be taken in between.
77
107
  // A demo project's database port (its URL's, src/cli/demo.ts demoPort) is avoided as the run avoids it
@@ -84,12 +114,13 @@ export async function devInBackground(args, o) {
84
114
  return failed("start", listen, { json: o.json });
85
115
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
86
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
87
- const logFile = path.join(home, "dev.log");
117
+ const logFile = logPath(home, "start");
88
118
  const fd = fs.openSync(logFile, "a", 0o600);
89
119
  fs.chmodSync(logFile, 0o600);
90
120
  const from = fs.fstatSync(fd).size;
91
121
  fs.writeSync(fd, `\n--- mason start ${new Date().toISOString()} ---\n`);
92
- // 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
93
124
  const run = [process.execPath, runnable(MAIN), "start", ...args.filter((a) => a !== "--json"), "--attach"];
94
125
  const child = spawn(process.execPath, [runnable(SUPERVISE), home, logFile, ...run], { detached: true, stdio: ["ignore", fd, fd], cwd: t.project.root, env: process.env });
95
126
  fs.closeSync(fd);
@@ -106,25 +137,15 @@ export async function devInBackground(args, o) {
106
137
  line(st.bold(`Starting Mason for ${t.project.config.name}`));
107
138
  const steps = stepRenderer({ line, live: !o.json && process.stdout.isTTY ? { write: (x) => { process.stdout.write(x); } } : null, style: st });
108
139
  let begun = false;
109
- let offset = from;
110
- 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 });
111
142
  let postgres = null;
112
143
  const since = []; // the run's lines after its last step: a failure's reasons
113
144
  try {
114
145
  for (;;) {
115
- const size = fs.statSync(logFile).size;
116
- if (size > offset) {
117
- const buf = Buffer.alloc(size - offset);
118
- const r = fs.openSync(logFile, "r");
119
- try {
120
- fs.readSync(r, buf, 0, buf.length, offset);
121
- }
122
- finally {
123
- fs.closeSync(r);
124
- }
125
- offset = size;
126
- const lines = (partial + buf.toString()).split("\n");
127
- 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
128
149
  for (const l of lines) {
129
150
  if (l.startsWith("--- mason start "))
130
151
  continue;
@@ -166,7 +187,7 @@ export async function devInBackground(args, o) {
166
187
  continue;
167
188
  }
168
189
  if (/^\s+Web UI\s+mason ui\b/.test(l)) {
169
- steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim("(the status page and the users, in your browser)")}`);
190
+ steps.print(` ${st.dim("Web UI".padEnd("Postgres API".length))} ${st.cyan("mason ui")} ${st.dim(`(${UI.summary})`)}`);
170
191
  continue;
171
192
  }
172
193
  // a demo project's line (src/cli/dev.ts: "Demo a new order lands every <n> s: …"), in the same column
@@ -183,7 +204,8 @@ export async function devInBackground(args, o) {
183
204
  if (exited !== null) {
184
205
  if (steps.open() !== null)
185
206
  steps.fail();
186
- if (partial)
207
+ const partial = reader.rest(); // the line it was writing, unended
208
+ if (partial.trim())
187
209
  since.push(partial.trim());
188
210
  // the run's own last words are the reason (a wrong password), already shown line by line to a person
189
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
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `mason cancel <session>`: stops the statement a session of the Postgres API is running, exactly as a client's own
3
+ * cancel (the protocol's CancelRequest) stops it — the listener's one path (src/api/postgres/server.ts `interrupt`),
4
+ * through the run's `POST /api/cancel` (src/cli/run-server.ts). The client is told 57014 "canceling statement due to
5
+ * user request", and the request log records it as cancelled. `mason logs --running` lists the sessions.
6
+ */
7
+ import { failed, jsonText, parseCommand, reportText } 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 { guarded, oneLine, runFailure, spanText } from "./commands.js";
12
+ const out = (m) => { process.stdout.write(`${m}\n`); };
13
+ export const CANCEL_USAGE = `usage: mason cancel <session> [--statement <n>] [--json]
14
+
15
+ Stops the statement a session of the Postgres API is running now, as the client's own cancel would: the client is
16
+ told "canceling statement due to user request" (SQLSTATE 57014), its session stays open, and the request log keeps
17
+ the statement as cancelled. mason logs --running lists the statements running now, with their sessions and numbers.
18
+
19
+ mason logs --running
20
+ mason cancel 12 --statement 340
21
+ mason cancel 12
22
+
23
+ --statement <n> only that statement (its number in mason logs --running): if it ended and the session runs
24
+ another, nothing is cancelled and the answer says so. Without it, whatever the session runs now
25
+ is stopped, as a client's own cancel stops it.
26
+ --json as data: { cancelled: { session, statement, user, applicationName, method, startedAt, runningMs,
27
+ rows, query, queryTruncated } (the statement it stopped), next }
28
+ `;
29
+ export const CANCEL = {
30
+ needs: "run",
31
+ name: "cancel",
32
+ summary: "stop the statement a session of the Postgres API is running, as the client's cancel would: mason cancel 12",
33
+ usage: CANCEL_USAGE,
34
+ options: { statement: { type: "string" }, json: { type: "boolean" } },
35
+ json: ["cancelled", "next"],
36
+ };
37
+ export async function cancel(argv) {
38
+ const f = parseCommand(CANCEL, argv);
39
+ if (typeof f === "number")
40
+ return f;
41
+ const json = f.values.json === true;
42
+ const running = ["mason logs --running", "the statements running now, with their sessions"];
43
+ if (f.positionals.length !== 1 || !/^[1-9]\d*$/.test(f.positionals[0])) {
44
+ return failed("cancel", { message: f.positionals.length === 0 ? "name the session: mason cancel <session>" : `${f.positionals.join(" ")} is not a session's number`,
45
+ code: "usage", fix: "mason logs --running lists each statement running with its session", next: [running], usage: CANCEL_USAGE }, { json });
46
+ }
47
+ const session = Number(f.positionals[0]);
48
+ const statement = f.values.statement;
49
+ if (statement !== undefined && (typeof statement !== "string" || !/^[1-9]\d*$/.test(statement))) {
50
+ return failed("cancel", { message: `--statement ${String(statement)} is not a statement's number`, code: "usage",
51
+ fix: "mason logs --running lists each statement running with its number", next: [running], usage: CANCEL_USAGE }, { json });
52
+ }
53
+ const w = await reach(CANCEL, json);
54
+ if (w === null || !("run" in w))
55
+ return 1;
56
+ const run = w.run;
57
+ return guarded("cancel", json, async () => {
58
+ const r = await callRun(run, "POST", "/api/cancel", { session, ...(statement !== undefined ? { statement: Number(statement) } : {}) });
59
+ if (r.status !== 200)
60
+ return failed("cancel", runFailure(r, [running]), { json });
61
+ const c = r.body.cancelled;
62
+ const next = [[`mason logs --requests --outcome cancelled -n 5`, "the statements cancelled, this one among them once it ended"], running];
63
+ if (json) {
64
+ out(jsonText(CANCEL, { cancelled: c }, next));
65
+ return 0;
66
+ }
67
+ out(reportText({
68
+ headline: `Cancelled session ${c.session}'s statement: it ends now, and its client gets a "canceling statement" error.`,
69
+ details: [["Statement", oneLine(c.query) || "(no statement)"], ["Client", `${c.applicationName || "(no application_name)"}${c.user ? ` (user ${c.user})` : ""}`],
70
+ ["Running", `${spanText(c.runningMs / 1000)}, ${c.rows.toLocaleString("en-US")} row${c.rows === 1 ? "" : "s"} sent`]],
71
+ next,
72
+ }, styleOut()));
73
+ return 0;
74
+ });
75
+ }