@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.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- 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
|
+
}
|
package/dist/src/cli/guides.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
169
|
-
|
|
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
|
|
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
|