@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
@@ -0,0 +1,143 @@
1
+ /**
2
+ * THE PROJECT'S GENERATED TABLES (issue 124): a folder beside the sources file, one file per table, the project's to
3
+ * commit and review. The file's name is the table's: `requests.yml` is `public.requests`, `reports.daily.yml` is
4
+ * `reports.daily`.
5
+ *
6
+ * kind: scheduled what the table promises: `scheduled` (any SELECT, up to target_lag behind its base)
7
+ * target_lag: 5m how far behind it may be
8
+ * sql: | what it holds
9
+ * SELECT tenant_id, count(*) AS requests FROM events GROUP BY 1
10
+ * order_by: tenant_id optional: what follows ORDER BY, over its columns, one SQL string
11
+ * comment: Requests per tenant. optional: COMMENT ON TABLE
12
+ *
13
+ * Read here for its shape alone: each mistake at its key, with the reason and the way out, all of a file's at once. What
14
+ * its SQL means is the catalog's to say (src/duck/generated-plan.ts). A mistake never stops the run or another table,
15
+ * unlike a sources file's: a table already deployed keeps serving what it was built from (src/duck/generated.ts).
16
+ *
17
+ * `GENERATED_KEYS` is the one list of keys: a key the reader takes and the guide says (`mason help generated`), so neither
18
+ * can name one the other does not.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { parseDocument } from "yaml";
23
+ import { didYouMean } from "../lib/did-you-mean.js";
24
+ import { parseLag } from "../lib/table-sync.js";
25
+ import { shownPath, tableNameProblem } from "./sources.js";
26
+ /** Where a project's generated tables are: `<folder>/generated_tables/`, beside `sources/`. */
27
+ export const GENERATED_DIR = "generated_tables";
28
+ export const generatedDir = (folder) => path.join(folder, GENERATED_DIR);
29
+ /** Every key a file takes, and what it says, as a person reads it. */
30
+ export const GENERATED_KEYS = {
31
+ kind: "required: how the table stays current. scheduled: any SELECT, up to target_lag behind its base. live is not available yet",
32
+ sql: "required: the one SELECT that gives the table, over the tables the sources file syncs",
33
+ target_lag: "required for scheduled: how far behind its base it may be, like 30s, 5m or 1h (at least 1s, at most 24h). Mason looks at each table every 5s, so a table under 5s can be up to 5s behind",
34
+ order_by: "optional: the table's rows are written in this order, as what follows ORDER BY, over its columns, one SQL string",
35
+ comment: "optional: the table's comment, as COMMENT ON TABLE",
36
+ };
37
+ const KEYS = Object.keys(GENERATED_KEYS);
38
+ const LIVE_WHY = "A table kept current in its base's own write isn't built yet, and Mason never runs a table as another kind than its file says.";
39
+ const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
40
+ const refusal = (key, message, options = [], why = null) => ({ key, message, why, options });
41
+ /** The file's text read as a definition, or everything wrong with it. */
42
+ export function parseGeneratedText(text, name, file) {
43
+ const at = { name: `${name.schema}.${name.table}`, schema: name.schema, table: name.table, file };
44
+ const doc = parseDocument(text, { uniqueKeys: true });
45
+ if (doc.errors.length)
46
+ return { ...at, kind: null, refusals: [refusal(null, `it is not YAML: ${doc.errors[0].message.split("\n")[0]}`)] };
47
+ const v = doc.toJS();
48
+ if (!isPlain(v))
49
+ return { ...at, kind: null, refusals: [refusal(null, `it is ${v === null || v === undefined ? "empty" : "not a mapping"}`, ["write kind: scheduled, target_lag: 5m and sql: SELECT …"])] };
50
+ const refusals = [];
51
+ for (const k of Object.keys(v))
52
+ if (!KEYS.includes(k))
53
+ refusals.push(refusal(k, `is not a key${didYouMean(k, KEYS)}`, [`a generated table takes ${KEYS.join(", ")}`]));
54
+ const kind = typeof v.kind === "string" ? v.kind : null;
55
+ const lagWritten = v.target_lag !== undefined;
56
+ const lag = lagWritten ? parseLag(v.target_lag) : null;
57
+ if (v.kind === undefined || v.kind === null)
58
+ refusals.push(refusal("kind", "is missing", ["write kind: scheduled with a target_lag, e.g. target_lag: 5m"], "Mason never decides how a table stays current for you."));
59
+ else if (kind === "live") {
60
+ refusals.push(refusal("kind", "live isn't available yet", ["set kind: scheduled with a target_lag, e.g. target_lag: 5m"], LIVE_WHY));
61
+ if (lagWritten)
62
+ refusals.push(refusal("target_lag", "contradicts kind: live", ["remove target_lag", "or set kind: scheduled"], "A live table is as fresh as its base, so it has no lag to choose."));
63
+ }
64
+ else if (kind !== "scheduled")
65
+ refusals.push(refusal("kind", `${String(v.kind)} is not a kind${kind === null ? "" : didYouMean(kind, ["live", "scheduled"])}`, ["write kind: scheduled with a target_lag, e.g. target_lag: 5m"]));
66
+ else if (!lagWritten)
67
+ refusals.push(refusal("target_lag", "is missing", ["write how stale is acceptable, e.g. target_lag: 5m"], "A scheduled table may lag its base, so its file says by how much."));
68
+ if (lag !== null && "problem" in lag)
69
+ refusals.push(refusal("target_lag", lag.problem));
70
+ const text1 = (key) => {
71
+ const x = v[key];
72
+ if (x === undefined)
73
+ return null;
74
+ if (typeof x === "string" && x.trim() !== "")
75
+ return x;
76
+ refusals.push(refusal(key, key === "order_by" ? "is one SQL string, as after ORDER BY" : "is empty or not text", key === "order_by" ? ["write order_by: day, tenant_id: a list would split on the commas inside an expression"] : []));
77
+ return null;
78
+ };
79
+ const sql = text1("sql");
80
+ if (v.sql === undefined)
81
+ refusals.push(refusal("sql", "is missing", ["write sql: | and the SELECT that gives the table"]));
82
+ const orderBy = text1("order_by"), comment = text1("comment");
83
+ if (refusals.length > 0 || sql === null || lag === null || "problem" in lag)
84
+ return { ...at, kind, refusals };
85
+ return { ...at, sql, orderBy: orderBy?.trim() ?? null, targetLag: lag, comment };
86
+ }
87
+ /** a file's name as the table it names: `requests.yml`, `reports.daily.yml`; or why it names none */
88
+ function tableOfFile(file) {
89
+ const name = file.replace(/\.ya?ml$/, "");
90
+ const bad = tableNameProblem(name);
91
+ if (bad !== null)
92
+ return bad;
93
+ const dot = name.indexOf(".");
94
+ const schema = dot < 0 ? "public" : name.slice(0, dot), table = dot < 0 ? name : name.slice(dot + 1);
95
+ if (schema === "_mason" || schema === "information_schema" || schema.startsWith("pg_") || table.startsWith("pg_") || table.includes("__mason"))
96
+ return `${name} is a name Postgres or Mason keeps for itself`;
97
+ return { schema, table };
98
+ }
99
+ /** The folder as it is now: every definition in shape, and every file refused. A folder that is not there holds none. */
100
+ export function readGenerated(folder, root) {
101
+ const dir = generatedDir(folder);
102
+ let names;
103
+ try {
104
+ names = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f) && !f.startsWith(".")).sort();
105
+ }
106
+ catch (e) {
107
+ if (e.code === "ENOENT")
108
+ return { definitions: [], refused: [] };
109
+ throw e;
110
+ }
111
+ const out = { definitions: [], refused: [] };
112
+ const seen = new Map();
113
+ for (const name of names) {
114
+ const full = path.join(dir, name), shown = shownPath(root, full);
115
+ const stem = name.replace(/\.ya?ml$/, "");
116
+ const t = tableOfFile(name);
117
+ if (typeof t === "string") {
118
+ const dot = stem.indexOf(".");
119
+ out.refused.push({ name: dot < 0 ? `public.${stem}` : stem, schema: dot < 0 ? "public" : stem.slice(0, dot), table: dot < 0 ? stem : stem.slice(dot + 1), file: shown, kind: null, refusals: [refusal(null, t, ["rename the file: its name is the table's"])] });
120
+ continue;
121
+ }
122
+ const table = `${t.schema}.${t.table}`, before = seen.get(table);
123
+ if (before !== undefined) {
124
+ out.refused.push({ name: table, ...t, file: shown, kind: null, refusals: [refusal(null, `names the table ${table}, as ${before} does`, ["keep one"])] });
125
+ continue;
126
+ }
127
+ seen.set(table, shown);
128
+ let text;
129
+ try {
130
+ text = fs.readFileSync(full, "utf8");
131
+ }
132
+ catch (e) {
133
+ out.refused.push({ name: table, ...t, file: shown, kind: null, refusals: [refusal(null, `it cannot be read: ${e.message}`)] });
134
+ continue;
135
+ }
136
+ const read = parseGeneratedText(text, t, shown);
137
+ if ("refusals" in read)
138
+ out.refused.push(read);
139
+ else
140
+ out.definitions.push(read);
141
+ }
142
+ return out;
143
+ }
@@ -7,18 +7,99 @@
7
7
  *
8
8
  * A guide's name is never a command's (tests/cli-help.test.ts): one word never means two pages.
9
9
  */
10
- import { DEMO_ADDED_TABLES, DEMO_TABLES, DEMO_TABLE_TO_ADD } from "../demo/seed.js";
10
+ import { DEFAULT_MEMORY_RULE, memorySize, MEMORY_FLOOR_BYTES, OUTSIDE_RESERVE_BYTES } from "../duck/store.js";
11
+ import { DEMO_ADDED_TABLES, DEMO_GENERATED, DEMO_TABLES, DEMO_TABLE_TO_ADD } from "../demo/seed.js";
11
12
  /** The guides, by the name `mason help <guide>` takes. */
12
13
  export const GUIDES = {
13
14
  agent: "driving Mason from a coding agent: the commands in order, --json and its shapes, exit codes, failures",
14
15
  production: "going to production: the mason-data image, its settings, Docker Compose, logical replication, upgrades",
16
+ generated: "generated tables: a table declared in SQL, kept current by Mason, read by name",
15
17
  };
16
18
  /** A guide's text: built at run time from the registry's and the commands' own tables. */
17
19
  export async function guideText(guide) {
18
20
  if (guide === "agent")
19
21
  return agentGuide();
22
+ if (guide === "generated")
23
+ return generatedGuide();
20
24
  return productionGuide();
21
25
  }
26
+ /** THE GENERATED TABLES' PAGE: its keys are the reader's own table (src/cli/generated-files.ts `GENERATED_KEYS`), so it
27
+ * cannot name a key the file does not take. */
28
+ async function generatedGuide() {
29
+ const { GENERATED_DIR, GENERATED_KEYS } = await import("./generated-files.js");
30
+ const w = Math.max(...Object.keys(GENERATED_KEYS).map((k) => k.length));
31
+ const keys = Object.entries(GENERATED_KEYS).map(([k, what]) => ` ${k.padEnd(w)} ${what}`).join("\n");
32
+ return `GENERATED TABLES — a table declared in SQL, kept current by Mason, read by name
33
+
34
+ A statement that repeats expensive work on every read can read a table that holds its answer instead. One file per
35
+ table in mason/${GENERATED_DIR}/, committed beside the sources file. The file's name is the table's: requests.yml is
36
+ public.requests, reports.daily.yml is reports.daily.
37
+
38
+ kind: scheduled
39
+ target_lag: 5m
40
+ sql: |
41
+ SELECT tenant_id, date_trunc('day', created_at, 'UTC') AS day, count(*) AS orders
42
+ FROM orders
43
+ GROUP BY 1, 2
44
+ order_by: day, tenant_id
45
+
46
+ THE KEYS
47
+ ${keys}
48
+
49
+ HOW IT STAYS CURRENT
50
+ A scheduled table runs its whole SQL over one snapshot, into a table beside the old one, and swaps it in. The old
51
+ table serves until then. It runs again once a base has changed and target_lag comes due; nothing runs over idle
52
+ bases. A run shares the queue that first syncs use, one at a time, and reads nothing from your database.
53
+ A run that fails leaves the table serving what it held, and mason status says why.
54
+ A table may read another generated table. Parents are built first; a base's change re-runs the whole chain above
55
+ it over one snapshot, and the chain swaps in at once. A table that reads itself, through others or not, is refused.
56
+ A parent whose run failed, or that was refused before it was ever built, holds back its children, and mason status
57
+ says why; a parent already built whose file is then refused keeps being re-run from what it was built from, and
58
+ carries its children. A table's lag is measured against the base, across the chain: the oldest change below it not
59
+ yet in it. So a child's target_lag can't be shorter than its parent's, and is refused if it is.
60
+ mason wait <table> waits until a table holds every change synced when the wait began.
61
+
62
+ WHAT IS REFUSED, WITH THE WAY OUT
63
+ A file with a mistake, or SQL Mason cannot store, is refused by file, key and reason. Nothing else stops, and a
64
+ table already built keeps serving and being re-run from what it was built with. A table has no session, so SQL that
65
+ reads a time zone or a setting is refused: name the zone (t AT TIME ZONE 'UTC', date_trunc('day', t, 'UTC')).
66
+ A daily table in one zone doesn't serve a dashboard in another.
67
+ A numeric column is stored exactly, with Postgres's digits, wherever its types fix the scale: one passed through,
68
+ its min or max, a sum (of a bigint too), and + - * of those, negated, abs, mod, round(x, n) and trunc(x, n) (n
69
+ below 0 too), floor, ceil, a cast to numeric(p,s) (of a JSON number's text too), and a CASE, GREATEST, LEAST or
70
+ COALESCE whose values have one scale, up to 18 decimals; a sign is stored too. Refused, each with its reason and
71
+ the way out: a division (avg is one: store it as a double, or divide where you read), a variance or stddev, which
72
+ Postgres computes exactly at decimals it picks per value, rounded, cast to numeric(p,s) or not (store it as a
73
+ double), a sqrt, exp, ln, log or power, whose decimals Postgres picks per value, rounded, cast to numeric(p,s) or
74
+ not (store it as a double), whatever the decimals of what it is computed from, a JSON number's text
75
+ cast to numeric with no scale (cast it to numeric(p,s)), more than 18 decimals, at the end or on the way
76
+ (round it, or give the values fewer decimals first), a sum whose decimals the rows decide (cast the values to one
77
+ scale first), a CASE, GREATEST, LEAST or COALESCE of values with differing decimals (cast it to numeric(p,s)), and a
78
+ numeric cast from a double (store the double it is). Each keeps its reason read back from its text (::text,
79
+ ::varchar, || '', a jsonb's ->>, a WITH column), cast to numeric(p,s) or not; only a stored column's or a JSON
80
+ number's text is a text's. Each is said as Postgres types the value (sqrt or avg of a
81
+ double is a double, so cast to numeric it is a cast from a double) wherever it sits in the column (under an
82
+ operator, a coalesce, a round, a cast or an aggregate, or in a scalar subquery, a WITH query, a subquery in FROM or
83
+ a UNION's arm), its way out your own SQL with the cast moved, the aggregate kept: sum(sqrt(x)::double precision),
84
+ or the WITH query, subquery or UNION the cast belongs in, given whole. A run that fails (a value
85
+ past 38 digits, bigint out of range, a division by zero) says Postgres's error and SQLSTATE, and is tried again.
86
+ An enum and a citext are stored as themselves:
87
+ an enum sorts in its declared order, a citext compares without case.
88
+ Not available yet: kind: live, and a domain or a composite type's column.
89
+
90
+ WHO READS IT
91
+ A role with tables: all reads it. A role with a list of tables reads it once it names it, as mason/roles.yml names
92
+ any table. It inherits no limits from the tables it reads: carry a tenant column and filter it in the role's where:.
93
+
94
+ SEEING IT
95
+ mason start reads the folder, says what is refused, builds what is new, and keeps looking
96
+ mason status each table's kind, method, state and lag
97
+ mason status --json the same as generatedTables, with each refusal's key, reason and options
98
+ mason logs each run: its rows and seconds, or why it failed
99
+
100
+ EXPLAIN says how a generated table is kept current on its first line, as it does for any table.
101
+ `;
102
+ }
22
103
  /**
23
104
  * THE AGENT'S PAGE (two readers: a person wants feedback and short, scannable responses; an agent wants clear,
24
105
  * unambiguous context and machine-readable formats; both want the next steps): what a
@@ -29,17 +110,29 @@ export async function guideText(guide) {
29
110
  async function agentGuide() {
30
111
  // imported here, not at the top: the registry imports this file for the HELP usage
31
112
  const { STEPS } = await import("./registry.js");
32
- const { FAILURE_CODES } = await import("./command.js");
113
+ const { EXIT_CODES, FAILURE_CODES, TIMEOUT_COMMANDS } = await import("./command.js");
114
+ const { GENERATED_DIR } = await import("./generated-files.js");
33
115
  const w = Math.max(...STEPS.flatMap(([, cs]) => cs.map((c) => c.name.length))) + "mason --json".length;
34
116
  const commands = STEPS.map(([step, cs]) => ` ${step}\n${cs.map((c) => ` ${`mason ${c.name}${Object.hasOwn(c.options, "json") ? " --json" : ""}`.padEnd(w)} ${c.summary}`).join("\n")}`).join("\n");
35
- const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(12)} ${what}`).join("\n");
117
+ const codes = Object.entries(FAILURE_CODES).map(([code, what]) => ` ${code.padEnd(13)} ${what}`).join("\n");
36
118
  return `DRIVING MASON FROM A CODING AGENT — the same commands a person runs, answered as data
37
119
 
38
- Every command prints for a person, or with --json one JSON object for a program. One prints text alone: mason help
39
- (this reference); mason mcp's stdout is the MCP protocol's, so it takes no --json and says a failure to start on
40
- stderr; mason logs --json is the log's entries ({ at, text }), or with --requests the request log, --events the sync's
41
- events, --running the statements running now. Nothing a command prints
42
- refers to a file this package does not ship.
120
+ Every command prints for a person, or with --json one JSON object for a program. mason mcp's stdout is the MCP
121
+ protocol's, so it takes no --json and says a failure to start on stderr; mason logs --json is the log's entries
122
+ ({ at, text }), or with --requests the request log, --events the sync's events, --running the statements running now.
123
+ mason version --json is { version, next }. Nothing a command prints refers to a file this package does not ship.
124
+
125
+ EVERY COMMAND'S SPEC, AS DATA: mason <command> --help --json (or mason help <command> --json) is { command: <spec>,
126
+ next }; mason help --json is { version, commands: [<spec>…], guides: [{ name, summary }], failureCodes: [{ code,
127
+ meaning }], next }. A <spec> is read from the same description --help prints, so the two cannot drift:
128
+ name, summary, usage usage is the text --help prints
129
+ forms [{ synopsis, positionals }]: each form of the synopsis, and its words that are no option
130
+ options [{ name, short, type, multiple, default, value }]: every option, --help too; type
131
+ "string" or "boolean"; short, default and value (a string option's placeholder) null if none
132
+ json the top-level keys of its --json answer, next last; null when it takes no --json
133
+ needs "project", "run" or null
134
+ stdout "protocol" for mason mcp, else null
135
+ exitCodes [{ code, meaning }]: the exit codes below it can exit with
43
136
 
44
137
  AN AGENT WITH MCP (Claude Code, Claude Desktop, any MCP client) adds Mason as an MCP server first, then asks with
45
138
  its tools rather than through a shell: tables (each synced table's rows and how current it is), describe, query (rows with
@@ -59,10 +152,8 @@ ${commands}
59
152
 
60
153
  A FAILURE is { "error": { "code", "message", "why", "fix" }, "next": […] } on stdout ("why" and "fix" where there is
61
154
  more to say than the message), and the exit code says it — the same code exits the same wherever it is found:
62
- 0 done
63
- 1 failed, with the reason
64
- 2 the command line is wrong (without --json the usage follows)
65
- 124 ran out of time: mason wait
155
+ ${Object.entries(EXIT_CODES).map(([code, meaning]) => ` ${code} ${code === "124" ? `${meaning}: ${TIMEOUT_COMMANDS}` : meaning}`).join("\n")}
156
+ A wrong command line's next steps end with mason <command> --help, and its fix says what to change.
66
157
  The codes:
67
158
  ${codes}
68
159
 
@@ -116,6 +207,8 @@ ${codes}
116
207
  With no database to hand, in a new folder: mason init demo --json && mason start --json — a demo database Mason
117
208
  hosts itself (${DEMO_TABLES.join(", ")}), changed every few seconds, polled every 5 s (its sync: poll 5s). Init adds
118
209
  ${DEMO_ADDED_TABLES.join(", ")}; ${DEMO_TABLE_TO_ADD} is left to add: mason add ${DEMO_TABLE_TO_ADD} --json && mason wait --json, after mason start.
210
+ It also writes a generated table, ${DEMO_GENERATED.table} (mason/${GENERATED_DIR}/${DEMO_GENERATED.file}: orders and revenue per
211
+ day and status, over the synced orders): mason wait ${DEMO_GENERATED.table} --json, after mason start.
119
212
  `;
120
213
  }
121
214
  const REPO = "https://github.com/embeddable-hq/mason-data/blob/main";
@@ -157,20 +250,27 @@ names the tables the server syncs, and its url: names the variable that holds th
157
250
  DATABASE_URL the database to sync from (the variable the sources file's url: names)
158
251
  MASON_API_KEY what the users' passwords are made with (openssl rand -hex 24)
159
252
  MASON_PROJECT your mason/ folder, mounted (default ./mason)
253
+ MASON_PROJECT_READONLY=1 when that folder is deployed as committed: every edit beside the server is refused
160
254
  MASON_DATA the volume whose .mason/ holds the synced data and the state (default .)
161
255
  MASON_PG_HOST=0.0.0.0 and TLS: MASON_PG_TLS_CERT / MASON_PG_TLS_KEY, or MASON_PG_SELF_SIGNED_TLS=1, or
162
256
  MASON_PG_PLAIN=1 behind a TLS proxy of your own
163
257
  Docker Compose: ${REPO}/deploy/mason-data/README.md. One container per volume, never two: the synced data lets one process
164
258
  write its file, so run one replica, with no autoscaling, and stop the old container before the new one starts.
259
+ THE WEB PAGE stays on the server's loopback, so publish nothing for it: mason ui --ssh <user@server> on your machine
260
+ opens it through your own ssh (--mason "docker exec -i <container> mason" for a container), until Ctrl-C.
165
261
  GET /healthz on MASON_HEALTH_PORT (4140) answers 503 while it starts and 200 once it serves; the first start takes
166
262
  about 15 seconds before that, then syncs the tables. GET /metrics on the same port is for Prometheus: numbers only
167
263
  (tables by phase, lag, ages, sizes, statements), never a table's or a user's name, so it needs no secret.
168
- MEMORY: the engine may use half the container's memory limit by default (else half the machine's RAM); the other
169
- half is the page cache, Node and the catalog (~820 MB). Give the container enough that the page cache holds the
264
+ MEMORY: by default the engine may use ${DEFAULT_MEMORY_RULE};
265
+ the rest is the page cache, Node and the catalog (~820 MB).
266
+ Give it ${memorySize(MEMORY_FLOOR_BYTES)} or more (Mason itself was measured using up to 2.3 GiB beside the engine, and the kernel
267
+ killed it in less): under it, with MASON_DUCKDB_MEMORY unset, a start goes on and warns, in the log, mason status and
268
+ mason doctor, that the system may stop it. Set, it is your word at any size, and mason doctor says what it leaves. Give the container enough that the page cache holds the
170
269
  columns your dashboards read: a query over a column it does not hold runs at the disk's speed. The benchmark's synced data
171
270
  was about a fifth of its tables in Postgres (10.1 GiB to 1.8 GiB, on a 15 GiB machine); mason status says yours.
172
- To set it, MASON_DUCKDB_MEMORY=4GB with the container's limit about twice that. A statement past it fails alone
173
- (53200) and the run goes on; a value near the container's limit is an OOM kill.
271
+ To set it, MASON_DUCKDB_MEMORY=4GB with the container's limit about twice that, never under it plus ${memorySize(OUTSIDE_RESERVE_BYTES)}.
272
+ A statement past it fails alone (53200) and the run goes on; a value near the container's limit is an OOM kill.
273
+ What spills past memory takes at most half of what is free on the disk (MASON_SPILL_LIMIT sets it); a statement past that fails alone (53400).
174
274
  CPU: memory first, then cores. The benchmark's machine (4 vCPU Graviton3, 15 GiB) answered 32 clients at 10 M rows,
175
275
  12 statements a second at a p95 of 3.5 s, as many a second as at 8 clients: more clients waited longer. A first sync's
176
276
  readers take about a core each while it runs (MASON_COPY_PARALLEL, default 3). The image is arm64 (the measured