@mason-data/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/cli/init.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `mason init [<postgres url>]` (a project directory is initiated as other CLIs do it, by `mason init`):
|
|
3
|
-
* makes the folder it is run in a Mason project. The database's URL comes from the argument, else
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* makes the folder it is run in a Mason project. The database's URL comes from the argument, else `MASON_DATABASE_URL`
|
|
4
|
+
* (Mason's own, for a database other than the app's), else `DATABASE_URL` (the variable web frameworks use), each from
|
|
5
|
+
* the folder's `.env.local`, then `.env`, then the shell (`appDatabaseUrl`) — or, when a sources file is here already (a
|
|
6
|
+
* clone), the variable its `url: ${…}` names, read the same way, so the database checked is the one the run copies from.
|
|
7
|
+
* The database is checked as `mason start` checks it (checkSource) and nothing is written when it fails, unless
|
|
8
|
+
* --skip-check says the database is not up yet.
|
|
7
9
|
*
|
|
8
10
|
* It writes `.mason/config.json` (0600: the project's name and Mason's key), the project's source file
|
|
9
|
-
* (`mason/sources/<database>.yml`, src/cli/sources.ts: `kind: postgres`, `url: ${DATABASE_URL}
|
|
10
|
-
* URL itself in `.env`, never in a committed file (the sources file is the
|
|
11
|
-
* of it): nothing more when it came from there, the line added when it came
|
|
12
|
-
* `.mason/` and that .env to the folder's .gitignore. It refuses a folder that
|
|
13
|
-
* — but for a project with no source file (one an older Mason made), whose
|
|
11
|
+
* (`mason/sources/<database>.yml`, src/cli/sources.ts: `kind: postgres`, `url: ${DATABASE_URL}` — or the variable the
|
|
12
|
+
* URL was read from, `tables: {}`, and `database: <name>` only under `--database`) — the URL itself in `.env`, never in a committed file (the sources file is the
|
|
13
|
+
* developer's to commit, so the URL stays out of it): nothing more when it came from there, the line added when it came
|
|
14
|
+
* from the argument or the shell — and adds `.mason/` and that .env to the folder's .gitignore. It refuses a folder that
|
|
15
|
+
* is in a project already (here or above) — but for a project with no source file (one an older Mason made), whose
|
|
16
|
+
* source file it writes, nothing else changed.
|
|
14
17
|
*
|
|
15
18
|
* `mason init demo` (src/cli/demo.ts) writes the same files for a database Mason hosts itself: the demo's URL, on a port
|
|
16
19
|
* free now, in `.env` (the one place the port is kept), three of the demo's four tables already added (the fourth,
|
|
@@ -28,46 +31,57 @@ import { version } from "./version.js";
|
|
|
28
31
|
import { parseVersion, PIN_FILE } from "../lib/project-version.js";
|
|
29
32
|
import { appDatabaseUrl, appEnvValue, appendEnvLine, ENV_FILE } from "../lib/dotenv.js";
|
|
30
33
|
import { DEMO_DIR, NAME_RE, STATE_DIR, brokenProblem, findProject, ignoresStateDir, nameFromFolder, newConfig, writeConfig } from "./project.js";
|
|
31
|
-
import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
|
|
34
|
+
import { DEMO_ADDED_TABLES, DEMO_ARG, DEMO_PORT, DEMO_SYNC, DEMO_TABLE_TO_ADD, demoUrl } from "./demo.js";
|
|
32
35
|
import { freePort } from "./ports.js";
|
|
36
|
+
import { databaseNameProblem, DEFAULT_DATABASE } from "../lib/database-name.js";
|
|
37
|
+
import { DEFAULT_SYNC, syncText } from "../lib/table-sync.js";
|
|
38
|
+
import { newRolesText, rolesFile } from "./roles.js";
|
|
33
39
|
import { newSourceText, OLD_TABLES_DIR, parseSourceText, shownPath, sourceFileNames, sourceNameFor, sourcesDir } from "./sources.js";
|
|
34
|
-
export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
|
|
40
|
+
export const INIT_USAGE = `usage: mason init [<postgres url> | demo] [--name <name>] [--database <name>] [--folder <dir>] [--remote] [--skip-check] [--json]
|
|
35
41
|
|
|
36
42
|
Makes this folder a Mason project — run it in your app's folder. It checks it can read the database (and writes
|
|
37
43
|
nothing when it cannot), then creates:
|
|
38
|
-
.mason/ this machine's: Mason's key and, once Mason runs, its
|
|
39
|
-
mason/sources/<db>.yml the database and the tables to
|
|
44
|
+
.mason/ this machine's: Mason's key and, once Mason runs, its synced data and state (gitignored)
|
|
45
|
+
mason/sources/<db>.yml the database and the tables to sync (mason add adds them) — commit it
|
|
46
|
+
mason/roles.yml who may read what: one role, everything (tables: all), the default of a user added
|
|
47
|
+
without --role, so every user reads every synced table until you narrow it — commit it
|
|
40
48
|
mason/mason.yml the Mason version the project is written for — commit it: production runs that version
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
With no URL it reads MASON_DATABASE_URL, else your app's DATABASE_URL, each from .env.local, then .env, then the
|
|
50
|
+
shell. The source file names the variable read, url: \${DATABASE_URL} — \${MASON_DATABASE_URL} when that is the one
|
|
51
|
+
read, or when a URL given here is another database than your app's DATABASE_URL: the URL itself is in .env (written
|
|
52
|
+
there when you give it here or it is only in the shell), never in a committed file. .mason/ and that .env go into
|
|
53
|
+
.gitignore. Every other mason command then works anywhere in this folder. A source file already here (a clone of the
|
|
54
|
+
project) is kept, and a bare mason init reads the variable it names; a roles file already here is kept. In a project
|
|
55
|
+
with no source file (an older Mason's), it writes that file alone, and the roles file when there is none.
|
|
44
56
|
mason init demo, in a new folder, makes a project on a demo database Mason hosts itself (a shop: customers, products,
|
|
45
57
|
orders, order_items): mason start makes it in .mason/demo/ and changes it every few seconds. Three of its tables are
|
|
46
|
-
added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its
|
|
58
|
+
added; the fourth is yours to add after mason start (mason add ${DEMO_TABLE_TO_ADD}), to watch its first sync begin.
|
|
47
59
|
|
|
48
|
-
mason init
|
|
60
|
+
mason init MASON_DATABASE_URL, else DATABASE_URL (.env or the shell)
|
|
49
61
|
mason init postgres://me:secret@localhost:5432/shop
|
|
50
62
|
mason init demo a demo database Mason hosts itself: nothing to install
|
|
51
63
|
mason init --folder db/mason --name shop-admin the project's files elsewhere; another name
|
|
52
64
|
mason init postgres://me:secret@db.example.com/shop --remote
|
|
53
65
|
|
|
54
|
-
--name <name> the project's name
|
|
66
|
+
--name <name> the project's name, which its replication slot is named for (default: this folder's)
|
|
67
|
+
--database <name> the database name a tool connects with, written as database: in the source file (default: none
|
|
68
|
+
written, so tools connect with ${DEFAULT_DATABASE}): your database's own, and a tool keeps the name it has
|
|
55
69
|
--folder <dir> where sources/ goes, relative to this folder (default mason; . for this folder itself)
|
|
56
70
|
--remote go ahead with a database on another machine (read the warnings mason init prints); not with demo
|
|
57
71
|
--skip-check write the project without connecting (the database is not up yet); mason start checks it; not with demo
|
|
58
72
|
--json as data: { name, root, folder, config, demo (true for the demo database's project, else false),
|
|
59
|
-
source: { name (the source's: its file's), file, url (as the file writes it),
|
|
60
|
-
written, its value masked, or null) },
|
|
61
|
-
warnings, next }
|
|
73
|
+
source: { name (the source's: its file's), file, url (as the file writes it), database (the name
|
|
74
|
+
a tool connects with, or null when the file has a mistake), env (the .env line written, its value masked, or null) }, roles: { file, written (false: one was here, and kept) },
|
|
75
|
+
database: { url (password masked), from, version, tables }, warnings, next }
|
|
62
76
|
`;
|
|
63
77
|
export const INIT = {
|
|
64
78
|
name: "init",
|
|
65
|
-
summary: "make this folder a Mason project on your Postgres: mason init (
|
|
79
|
+
summary: "make this folder a Mason project on your Postgres: mason init (its URL from .env), mason init postgres://… or demo",
|
|
66
80
|
usage: INIT_USAGE,
|
|
67
|
-
options: { name: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
|
|
68
|
-
json: ["name", "root", "folder", "config", "demo", "source", "database", "warnings", "next"],
|
|
81
|
+
options: { name: { type: "string" }, database: { type: "string" }, folder: { type: "string" }, remote: { type: "boolean" }, "skip-check": { type: "boolean" }, json: { type: "boolean" } },
|
|
82
|
+
json: ["name", "root", "folder", "config", "demo", "source", "roles", "database", "warnings", "next"],
|
|
69
83
|
};
|
|
70
|
-
/** Why `name` cannot be this project's, or null: not a name
|
|
84
|
+
/** Why `name` cannot be this project's, or null: not a name its replication slot takes. */
|
|
71
85
|
export function nameProblem(name) {
|
|
72
86
|
if (!NAME_RE.test(name))
|
|
73
87
|
return `${JSON.stringify(name)} is not a project name: lower-case letters, digits, - and _, starting with a letter or digit (--name sets it)`;
|
|
@@ -85,7 +99,7 @@ export function withIgnored(gitignore, files = []) {
|
|
|
85
99
|
let tail = "";
|
|
86
100
|
if (!ignoresStateDir(text)) {
|
|
87
101
|
added.push(`${STATE_DIR}/`);
|
|
88
|
-
tail += `# Mason: this machine's key,
|
|
102
|
+
tail += `# Mason: this machine's key, synced data and state (mason init)\n${STATE_DIR}/\n`;
|
|
89
103
|
}
|
|
90
104
|
for (const f of files)
|
|
91
105
|
if (!ignoresFile(text, f)) {
|
|
@@ -123,8 +137,13 @@ export function committedInGit(root, file) {
|
|
|
123
137
|
* when the folder's .env.local or .env gives it already. DATABASE_URL when the folder's files give it no other value
|
|
124
138
|
* (one only in the shell is written down: the next shell may not have it); else MASON_DATABASE_URL, so the app's own
|
|
125
139
|
* DATABASE_URL is never changed under it. A problem when both name other databases.
|
|
140
|
+
* A URL READ FROM A VARIABLE (`found`, a bare mason init's `appDatabaseUrl`) is kept in that variable: the one read is
|
|
141
|
+
* the one named, and nothing is written when a folder's file gave it — so a MASON_DATABASE_URL is never written again as
|
|
142
|
+
* DATABASE_URL; one only in the shell is written down under its own name.
|
|
126
143
|
*/
|
|
127
|
-
export function urlVariable(root, url) {
|
|
144
|
+
export function urlVariable(root, url, found = null) {
|
|
145
|
+
if (found !== null)
|
|
146
|
+
return { variable: found.variable, line: found.file === null ? `${found.variable}=${url}` : null };
|
|
128
147
|
for (const variable of ["DATABASE_URL", "MASON_DATABASE_URL"]) {
|
|
129
148
|
const inFiles = appEnvValue(root, variable, {});
|
|
130
149
|
if (inFiles === null)
|
|
@@ -132,10 +151,10 @@ export function urlVariable(root, url) {
|
|
|
132
151
|
if (inFiles.value === url)
|
|
133
152
|
return { variable, line: null };
|
|
134
153
|
}
|
|
135
|
-
return { problem: "this folder's .env names DATABASE_URL and MASON_DATABASE_URL for other databases: set one of them to the database Mason
|
|
154
|
+
return { problem: "this folder's .env names DATABASE_URL and MASON_DATABASE_URL for other databases: set one of them to the database Mason syncs from, then mason init again" };
|
|
136
155
|
}
|
|
137
156
|
/** `.env` with `line` added (made when there is none). */
|
|
138
|
-
const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason
|
|
157
|
+
const addEnvLine = (root, line) => appendEnvLine(root, line, "the database Mason syncs from (mason/sources/ names it; never commit this file)");
|
|
139
158
|
/** A `.env` line as it is said: its value's password masked. */
|
|
140
159
|
const maskedLine = (line) => { const eq = line.indexOf("="); return `${line.slice(0, eq)}=${maskedUrl(line.slice(eq + 1))}`; };
|
|
141
160
|
export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
@@ -193,40 +212,65 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
193
212
|
const named = nameProblem(name);
|
|
194
213
|
if (named)
|
|
195
214
|
return usage(named);
|
|
215
|
+
const connectName = f.values.database ?? null;
|
|
216
|
+
const badDatabase = connectName === null ? null : databaseNameProblem(connectName);
|
|
217
|
+
if (badDatabase !== null)
|
|
218
|
+
return usage(`--database: ${badDatabase}`);
|
|
219
|
+
// THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
|
|
220
|
+
const kept = sourceFileNames(folder)[0] ?? null;
|
|
221
|
+
if (demo && kept !== null) {
|
|
222
|
+
// a clone's sources file names the team's database: the demo never takes its place
|
|
223
|
+
return failed("init", { message: `${path.relative(root, path.join(sourcesDir(folder), kept))} is already here: mason init demo makes a project of its own`, code: "refused",
|
|
224
|
+
fix: "run it in a new folder (mkdir mason-demo && cd mason-demo && mason init demo), or mason init here for the database that file names" }, { json, io });
|
|
225
|
+
}
|
|
226
|
+
const keptPath = kept === null ? null : path.join(sourcesDir(folder), kept);
|
|
227
|
+
if (connectName !== null && keptPath !== null)
|
|
228
|
+
return usage(`--database writes a new source file, and ${path.relative(root, keptPath)} is here already`, `write database: ${connectName} in it`);
|
|
229
|
+
const keptFile = kept === null || keptPath === null ? null : parseSourceText(fs.readFileSync(keptPath, "utf8"), kept.replace(/\.ya?ml$/, ""), keptPath);
|
|
230
|
+
const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
|
|
196
231
|
// the demo's URL names a port free now, and is the one place the port is kept: mason start reads it from there
|
|
197
232
|
let url = demo ? demoUrl(await freePort(DEMO_PORT)) : f.positionals[0] ?? null;
|
|
198
233
|
let from = "";
|
|
199
234
|
let envFile = null; // the folder's file the URL was read from (.env.local or .env): ignored with .mason/
|
|
235
|
+
let envVariable = null; // the variable envFile holds the URL in, set with it: the warning names it
|
|
236
|
+
// the variable a bare mason init read the URL from: the one the sources file names (urlVariable)
|
|
237
|
+
let found = null;
|
|
200
238
|
if (url === null) {
|
|
201
|
-
|
|
239
|
+
// THE KEPT FILE DECIDES WHAT IS READ: the variable its url: names, as the run reads it (.env.local, .env, the shell),
|
|
240
|
+
// so the database init checks is the one mason start copies from; MASON_DATABASE_URL, then DATABASE_URL, only when
|
|
241
|
+
// no kept file names one (a clone naming DATABASE_URL was checked against a MASON_DATABASE_URL in the shell)
|
|
242
|
+
if (keptVar !== null && keptPath !== null) {
|
|
243
|
+
const v = appEnvValue(root, keptVar, deps.env ?? process.env);
|
|
244
|
+
const file = shownPath(root, keptPath);
|
|
245
|
+
if (v === null) {
|
|
246
|
+
return failed("init", { message: `which database? ${file} names url: \${${keptVar}}, and this folder has no ${keptVar}`, code: "usage",
|
|
247
|
+
why: `mason start reads the URL from ${keptVar}, so mason init checks that one`,
|
|
248
|
+
fix: `put it in this folder's .env as ${keptVar}=postgres://… (the variable ${file} names), or give its URL on the command line: mason init writes it there`,
|
|
249
|
+
next: [["mason init postgres://user:password@localhost:5432/your_app", `its URL, given: written to .env as ${keptVar}`], ["mason init", `again, once .env has ${keptVar}`]] }, { json, io });
|
|
250
|
+
}
|
|
251
|
+
found = { url: v.value, variable: keptVar, from: `${keptVar} in ${v.from}`, file: v.file };
|
|
252
|
+
}
|
|
253
|
+
else
|
|
254
|
+
found = appDatabaseUrl(root, deps.env ?? process.env);
|
|
202
255
|
if (!found) {
|
|
203
256
|
// the fixes ARE the next commands, so a coding agent reads them as such too
|
|
204
|
-
return failed("init", { message: "which database? none is named, and this folder has no DATABASE_URL", code: "usage",
|
|
205
|
-
fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually)",
|
|
206
|
-
next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has DATABASE_URL"],
|
|
257
|
+
return failed("init", { message: "which database? none is named, and this folder has no MASON_DATABASE_URL or DATABASE_URL", code: "usage",
|
|
258
|
+
fix: "give its URL on the command line, or put it in this folder's .env as DATABASE_URL=postgres://… (your app's, usually) or as MASON_DATABASE_URL=postgres://… (read first: a database other than your app's)",
|
|
259
|
+
next: [["mason init postgres://user:password@localhost:5432/your_app", "its URL, given"], ["mason init", "again, once .env has MASON_DATABASE_URL or DATABASE_URL"],
|
|
207
260
|
["mason init demo", "a demo database Mason makes itself: nothing to install"]] }, { json, io });
|
|
208
261
|
}
|
|
209
262
|
url = found.url;
|
|
210
|
-
from = ` (
|
|
263
|
+
from = ` (${found.from})`;
|
|
211
264
|
envFile = found.file;
|
|
265
|
+
envVariable = found.variable;
|
|
212
266
|
}
|
|
213
267
|
const parsed = parseSource(url);
|
|
214
268
|
if ("problem" in parsed)
|
|
215
269
|
return usage(parsed.problem);
|
|
216
270
|
const remote = f.values.remote === true || sourceless?.config.remote === true;
|
|
217
|
-
// THE SOURCE FILE: one already here (a clone of the project) is kept; else written, its URL a variable of .env
|
|
218
|
-
const kept = sourceFileNames(folder)[0] ?? null;
|
|
219
|
-
if (demo && kept !== null) {
|
|
220
|
-
// a clone's sources file names the team's database: the demo never takes its place
|
|
221
|
-
return failed("init", { message: `${path.relative(root, path.join(sourcesDir(folder), kept))} is already here: mason init demo makes a project of its own`, code: "refused",
|
|
222
|
-
fix: "run it in a new folder (mkdir mason-demo && cd mason-demo && mason init demo), or mason init here for the database that file names" }, { json, io });
|
|
223
|
-
}
|
|
224
|
-
const keptText = kept === null ? null : fs.readFileSync(path.join(sourcesDir(folder), kept), "utf8");
|
|
225
|
-
const keptFile = kept === null ? null : parseSourceText(keptText, kept.replace(/\.ya?ml$/, ""), path.join(sourcesDir(folder), kept));
|
|
226
|
-
const keptVar = keptFile !== null && !("problems" in keptFile) && "env" in keptFile.url ? keptFile.url.env : null;
|
|
227
271
|
// a kept file's variable gets this URL in .env when the folder's files give it none; one they give another value is said
|
|
228
272
|
const keptValue = keptVar === null ? null : appEnvValue(root, keptVar, {});
|
|
229
|
-
const where = kept === null ? urlVariable(root, url)
|
|
273
|
+
const where = kept === null ? urlVariable(root, url, found)
|
|
230
274
|
: { variable: keptVar ?? "", line: keptVar !== null && keptValue === null ? `${keptVar}=${url}` : null };
|
|
231
275
|
if ("problem" in where)
|
|
232
276
|
return failed("init", { message: where.problem, code: "refused" }, { json, io });
|
|
@@ -241,10 +285,12 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
241
285
|
line(st.bold(`Making ${name} a Mason project in ${root}`));
|
|
242
286
|
const notes = [];
|
|
243
287
|
if (keptValue !== null && keptValue.value !== url)
|
|
244
|
-
notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start
|
|
288
|
+
notes.push(`${kept} reads ${keptVar} from ${keptValue.from}, which is ${maskedUrl(keptValue.value)}: mason start syncs from that one, not ${db}`);
|
|
245
289
|
let database = { url: db, from: from ? from.slice(2, -1) : null, version: null, tables: null };
|
|
246
290
|
const rel = (p) => path.relative(root, p) || ".";
|
|
247
291
|
const sourceFile = kept !== null ? path.join(sourcesDir(folder), kept) : path.join(sourcesDir(folder), `${sourceNameFor(parsed.source.database)}.yml`);
|
|
292
|
+
const rolesPath = rolesFile(folder);
|
|
293
|
+
const rolesKept = fs.existsSync(rolesPath);
|
|
248
294
|
// the folder an older Mason listed the tables in: read by nothing (no backwards compatibility before v1.0), said
|
|
249
295
|
const oldTables = path.join(folder, OLD_TABLES_DIR);
|
|
250
296
|
if (fs.existsSync(oldTables))
|
|
@@ -252,7 +298,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
252
298
|
// A DATABASE ON ANOTHER MACHINE, NOT CONFIRMED: not a failed step (the check passed, or was skipped) but a stop before
|
|
253
299
|
// anything is written, said as one failure (remoteConfirmation) — the same words whether the check ran or not
|
|
254
300
|
const confirmRemote = () => failed("init", remoteConfirmation(parsed.source.host, { cmd: "mason init",
|
|
255
|
-
wayOut: "run the same mason init again with --remote: that says yes,
|
|
301
|
+
wayOut: "run the same mason init again with --remote: that says yes, sync from that machine",
|
|
256
302
|
next: [["mason init --remote <url>", "the same command, confirmed"]] }), { json, io });
|
|
257
303
|
try {
|
|
258
304
|
if (demo) {
|
|
@@ -288,27 +334,40 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
288
334
|
database = { ...database, version: checked.facts.versionText, tables: checked.facts.tables.length };
|
|
289
335
|
}
|
|
290
336
|
if (sourceless)
|
|
291
|
-
await steps.step(`Keeping ${STATE_DIR}/config.json`, () => undefined, () => `Kept ${STATE_DIR}/config.json: the project ${name}, its key and its
|
|
337
|
+
await steps.step(`Keeping ${STATE_DIR}/config.json`, () => undefined, () => `Kept ${STATE_DIR}/config.json: the project ${name}, its key and its synced data`);
|
|
292
338
|
else {
|
|
293
339
|
await steps.step(`Writing ${STATE_DIR}/config.json`, () => {
|
|
294
340
|
writeConfig(path.join(root, STATE_DIR), newConfig(name, path.relative(root, folder) || ".", remote, { demo }));
|
|
295
341
|
}, () => `Wrote ${STATE_DIR}/config.json: Mason's key (this machine's)`);
|
|
296
342
|
}
|
|
297
343
|
if (kept !== null) {
|
|
298
|
-
await steps.step(`Keeping ${rel(sourceFile)}`, () => undefined, () => `Kept ${rel(sourceFile)}: the database and the tables this project
|
|
344
|
+
await steps.step(`Keeping ${rel(sourceFile)}`, () => undefined, () => `Kept ${rel(sourceFile)}: the database and the tables this project syncs`);
|
|
299
345
|
if (keptFile !== null && "problems" in keptFile)
|
|
300
346
|
notes.push(`${rel(sourceFile)} has ${keptFile.problems.length === 1 ? "a mistake" : "mistakes"}, which mason start refuses: ${keptFile.problems.join("; ")}`);
|
|
301
347
|
}
|
|
302
348
|
else {
|
|
303
349
|
await steps.step(`Writing ${rel(sourceFile)}`, () => {
|
|
304
350
|
fs.mkdirSync(path.dirname(sourceFile), { recursive: true });
|
|
305
|
-
fs.writeFileSync(sourceFile, newSourceText(`\${${where.variable}}`, demo ? DEMO_ADDED_TABLES : []));
|
|
306
|
-
}, () => `Wrote ${rel(sourceFile)}: the database (url: \${${where.variable}})
|
|
351
|
+
fs.writeFileSync(sourceFile, newSourceText(`\${${where.variable}}`, demo ? DEMO_ADDED_TABLES : [], demo ? DEMO_SYNC : DEFAULT_SYNC, connectName));
|
|
352
|
+
}, () => `Wrote ${rel(sourceFile)}: the database (url: \${${where.variable}}), ${connectName === null ? "" : `database: ${connectName}, `}sync: ${syncText(demo ? DEMO_SYNC : DEFAULT_SYNC)} ` +
|
|
353
|
+
`(${demo ? "every table polled every 5 s" : "each table read once, then again only by mason sync <table>"}) and the tables to sync, ` +
|
|
354
|
+
`${demo ? `the demo's ${DEMO_ADDED_TABLES.length} added — mason add ${DEMO_TABLE_TO_ADD} adds the fourth` : "none yet (mason add)"} — commit it`);
|
|
355
|
+
}
|
|
356
|
+
// THE ROLES FILE (src/cli/roles.ts newRolesText): today's access said in a committed file — every user reads every
|
|
357
|
+
// copied table, as the default role everything — kept when one is here (a clone of the project)
|
|
358
|
+
if (rolesKept)
|
|
359
|
+
await steps.step(`Keeping ${rel(rolesPath)}`, () => undefined, () => `Kept ${rel(rolesPath)}: the roles and the default for a user added without --role`);
|
|
360
|
+
else {
|
|
361
|
+
await steps.step(`Writing ${rel(rolesPath)}`, () => {
|
|
362
|
+
fs.mkdirSync(path.dirname(rolesPath), { recursive: true });
|
|
363
|
+
fs.writeFileSync(rolesPath, newRolesText());
|
|
364
|
+
}, () => `Wrote ${rel(rolesPath)}: every user reads every synced table (default: everything, tables: all) — narrow it with a second role — commit it`);
|
|
307
365
|
}
|
|
308
366
|
if (where.line !== null) {
|
|
309
367
|
const envLine = where.line;
|
|
310
368
|
await steps.step(`Writing ${where.variable} to .env`, () => addEnvLine(root, envLine), () => `Wrote ${maskedLine(envLine)} to .env: the URL the source file names, kept out of git`);
|
|
311
369
|
envFile = ENV_FILE;
|
|
370
|
+
envVariable = where.variable;
|
|
312
371
|
}
|
|
313
372
|
// THE VERSION PIN (src/cli/pin.ts): what production's image is held to. A checkout's mason (`dev`) has none to write
|
|
314
373
|
const cli = deps.version ?? version();
|
|
@@ -329,7 +388,7 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
329
388
|
return after === null ? "already" : before === null ? "made" : after.added.join(" and ");
|
|
330
389
|
}, (r) => r === "already" ? `.gitignore already ignores ${ignored}` : r === "made" ? `Made .gitignore, ignoring ${ignored}` : `Added ${r} to .gitignore`);
|
|
331
390
|
if (envFile && committedInGit(root, envFile)) {
|
|
332
|
-
notes.push(`${envFile} holds the database URL (
|
|
391
|
+
notes.push(`${envFile} holds the database URL (${envVariable}) and is committed in git: git rm --cached ${envFile} takes it out of the next commit (the file stays); then change the password`);
|
|
333
392
|
}
|
|
334
393
|
}
|
|
335
394
|
catch (e) {
|
|
@@ -342,25 +401,27 @@ export async function init(argv, cwd = process.cwd(), deps = {}) {
|
|
|
342
401
|
why: parsed.source.remote ? undefined : `the database's URL is ${db}${from}` }, { json, io });
|
|
343
402
|
}
|
|
344
403
|
const next = demo ? [
|
|
345
|
-
["mason start", "the demo database and Mason, in the background: its tables
|
|
346
|
-
[`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its
|
|
347
|
-
["mason status", "the
|
|
404
|
+
["mason start", "the demo database and Mason, in the background: its tables synced, then kept current"],
|
|
405
|
+
[`mason add ${DEMO_TABLE_TO_ADD}`, "the demo's fourth table, once Mason is up: watch its first sync begin"],
|
|
406
|
+
["mason status", "the synced data keeping up with the demo's changes"],
|
|
348
407
|
["mason user add <name>", "a user for psql or a BI tool: its password and URL, printed once"],
|
|
349
408
|
] : [
|
|
350
409
|
["mason start", "Mason in the background, for this folder"],
|
|
351
410
|
["mason tables", "the database's tables and their columns"],
|
|
352
|
-
["mason add <table>", "
|
|
411
|
+
["mason add <table>", "sync a table into Mason"],
|
|
353
412
|
];
|
|
354
413
|
if (json) {
|
|
355
414
|
say(jsonText(INIT, { name, root, folder: path.relative(root, folder) || ".", config: path.join(root, STATE_DIR, "config.json"), demo,
|
|
356
415
|
source: { name: path.basename(sourceFile).replace(/\.ya?ml$/, ""), file: sourceFile, url: keptFile !== null && !("problems" in keptFile) ? ("env" in keptFile.url ? `\${${keptFile.url.env}}` : maskedUrl(keptFile.url.literal)) : `\${${where.variable}}`,
|
|
416
|
+
database: keptFile === null ? connectName ?? DEFAULT_DATABASE : "problems" in keptFile ? null : keptFile.database ?? DEFAULT_DATABASE,
|
|
357
417
|
env: where.line === null ? null : maskedLine(where.line) },
|
|
418
|
+
roles: { file: rolesPath, written: !rolesKept },
|
|
358
419
|
database, warnings: notes }, next));
|
|
359
420
|
return 0;
|
|
360
421
|
}
|
|
361
422
|
for (const n of notes)
|
|
362
423
|
say(` ! ${n}`);
|
|
363
|
-
say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; mason destroy removes it with the
|
|
424
|
+
say(endText({ tips: [demo ? `The demo database runs inside mason start, read by polling: a new order every few seconds; ${DEMO_TABLE_TO_ADD} is left for you to add; mason destroy removes it with the synced data`
|
|
364
425
|
: "Mason only reads your database: no setting, slot or publication is needed or made"], next }, st));
|
|
365
426
|
return 0;
|
|
366
427
|
}
|
package/dist/src/cli/machine.js
CHANGED
|
@@ -18,16 +18,16 @@ import { dropOwnReplication, dropStatements, madeReplication } from "./replicati
|
|
|
18
18
|
import { sourceFileNames } from "./sources.js";
|
|
19
19
|
export const DESTROY_USAGE = `usage: mason destroy [--json]
|
|
20
20
|
|
|
21
|
-
Removes this folder's project's
|
|
21
|
+
Removes this folder's project's synced data from this machine — its file — and Mason's state in its
|
|
22
22
|
.mason/ (the logs, the request log). Mason is stopped first if it runs. Your files (mason/sources/), .mason/config.json and
|
|
23
|
-
the users (mason user) stay: the next mason start
|
|
23
|
+
the users (mason user) stay: the next mason start syncs the tables again, and every tool connects as before.
|
|
24
24
|
|
|
25
25
|
mason destroy this folder's project
|
|
26
26
|
|
|
27
|
-
mason stop keeps the
|
|
27
|
+
mason stop keeps the synced data and the state, and the next mason start resumes in seconds; mason destroy frees the disk, and
|
|
28
28
|
the next mason start begins again. In replication mode the slot (and Mason's own publication) are dropped from the
|
|
29
29
|
database too: they hold its WAL for as long as they exist. A demo project's database (mason init demo, in .mason/demo/)
|
|
30
|
-
goes with the
|
|
30
|
+
goes with the synced data: the next mason start makes and seeds it again.
|
|
31
31
|
|
|
32
32
|
--json as data: { removed: [{ name, removed: [what], error }], next }
|
|
33
33
|
|
|
@@ -36,7 +36,7 @@ export const DESTROY_USAGE = `usage: mason destroy [--json]
|
|
|
36
36
|
export const DESTROY = {
|
|
37
37
|
needs: "project",
|
|
38
38
|
name: "destroy",
|
|
39
|
-
summary: "remove this project's
|
|
39
|
+
summary: "remove this project's synced data and Mason's state from this machine (your files and users stay)",
|
|
40
40
|
usage: DESTROY_USAGE,
|
|
41
41
|
options: { json: { type: "boolean" } },
|
|
42
42
|
json: ["removed", "next"],
|
|
@@ -71,11 +71,12 @@ export async function removeProject(p, o = {}) {
|
|
|
71
71
|
if (sup !== null)
|
|
72
72
|
removed.push(`stopped its supervisor (pid ${sup}), which was about to start it again`);
|
|
73
73
|
}
|
|
74
|
-
// REPLICATION
|
|
75
|
-
//
|
|
74
|
+
// REPLICATION made a publication and a slot on the database (src/cli/replication-mode.ts), recorded by the start or
|
|
75
|
+
// the run that made them (an older start recorded its mode, `changes`): the slot holds its WAL for as long as it
|
|
76
|
+
// exists, so it goes with the copy — and when it cannot, the rest goes and the failure says how
|
|
76
77
|
let replication = null;
|
|
77
78
|
let objects = "publication and replication slot";
|
|
78
|
-
if (p.config.changes === "replication") {
|
|
79
|
+
if ((p.config.replication ?? null) !== null || p.config.changes === "replication") {
|
|
79
80
|
// the slot, and the publication only when it is Mason's own: one the operator named is its owner's, left as it is
|
|
80
81
|
const src = projectSource(p);
|
|
81
82
|
// what the start that made them recorded (madeReplication); a project with no record names them by its one sources file
|
|
@@ -90,7 +91,7 @@ export async function removeProject(p, o = {}) {
|
|
|
90
91
|
// and the real slot goes on holding its WAL (the review of the sources file)
|
|
91
92
|
const from = p.config.sourceServer ?? null;
|
|
92
93
|
if (from !== null && from.database !== parsed.source.database) {
|
|
93
|
-
throw new Error(`the sources file names ${parsed.source.database} now, and the
|
|
94
|
+
throw new Error(`the sources file names ${parsed.source.database} now, and the synced data came from ${from.database}, where the slot is: ` +
|
|
94
95
|
`put that database's URL back in the sources file and run mason destroy again, or drop them on ${from.database} by hand`);
|
|
95
96
|
}
|
|
96
97
|
for (const d of await (o.dropReplication ?? dropOwnReplication)(parsed.source, own, from))
|
|
@@ -145,7 +146,7 @@ export async function destroy(argv, deps = {}) {
|
|
|
145
146
|
catch (e) {
|
|
146
147
|
result = { name, removed: [], error: e.message };
|
|
147
148
|
}
|
|
148
|
-
const next = result.error ? [] : [["mason start", "
|
|
149
|
+
const next = result.error ? [] : [["mason start", "syncs the tables again"]];
|
|
149
150
|
if (json) {
|
|
150
151
|
out(jsonText(DESTROY, { removed: [result] }, next));
|
|
151
152
|
return result.error ? 1 : 0;
|
|
@@ -156,7 +157,7 @@ export async function destroy(argv, deps = {}) {
|
|
|
156
157
|
headline: result.removed.length ? `Removed ${name}` : `Nothing was left of ${name} to remove`,
|
|
157
158
|
details: [...(result.removed.length ? [["removed", result.removed.join(", ")]] : []), ["kept", `your files in ${t.project.folder}, .mason/config.json and the users` +
|
|
158
159
|
// the demo database is state like the copy (src/cli/demo.ts): it goes, and config.demo stays to make it again
|
|
159
|
-
(t.project.config.demo === true ? ` — not the demo database, which went with the
|
|
160
|
+
(t.project.config.demo === true ? ` — not the demo database, which went with the synced data: the next mason start seeds it again` : "")]],
|
|
160
161
|
}), { next }));
|
|
161
162
|
return 0;
|
|
162
163
|
}
|
package/dist/src/cli/main.js
CHANGED
|
@@ -38,11 +38,11 @@ async function main(argv) {
|
|
|
38
38
|
dev: { message: "mason dev is now mason start", next: [["mason init <postgres url>", "once, in your app's folder"], ["mason start", "Mason in the background"]] },
|
|
39
39
|
docs: { message: "mason docs is now mason help", next: [["mason help agent", "driving Mason from a coding agent"], ["mason help production", "running Mason on a server"]] },
|
|
40
40
|
// named for what it does to the copy, never for the mechanism
|
|
41
|
-
poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "
|
|
42
|
-
purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its
|
|
41
|
+
poll: { message: "mason poll is now mason sync", next: [["mason sync <table>", "bring a synced table up to date now"], ["mason status", "each synced table, and which the sync leaves out"]] },
|
|
42
|
+
purge: { message: "mason purge is now mason destroy", next: [["mason destroy", "this folder's project: its synced data and state, never its files"]] },
|
|
43
43
|
// mason rm read as the opposite of mason add: it removed a project's copy and state, never a table;
|
|
44
44
|
// the opposite of mason add is mason remove, so both are offered
|
|
45
|
-
rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's
|
|
45
|
+
rm: { message: "mason rm is gone: mason remove takes a table out of the project, mason destroy removes the project's synced data", next: [["mason remove <table>", "a table, and its synced data"], ["mason destroy", "this folder's project: its synced data and state, never its files"]] },
|
|
46
46
|
// a user's password does not expire, so mason user is enough and mason connect was dropped
|
|
47
47
|
connect: { message: "mason connect is gone: mason user add <name> makes a user whose password does not expire, and prints the URL to paste", next: [["mason user add <name>", "a user for a BI tool or your app"], ["mason status", "where the Postgres API listens"]] },
|
|
48
48
|
// a project is its folder (mason list was dropped): nothing on the machine lists them
|
package/dist/src/cli/mcp.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `--url`, else `MASON_URL`), so it works the same beside `mason start` and against a `mason serve` elsewhere, and every
|
|
10
10
|
* statement is a row of the request log under `application_name` `mason-mcp/<client>`. Everything it reads comes over
|
|
11
11
|
* that wire: the tables and columns from the catalog (a real Postgres), the rows and how current each copy is from
|
|
12
|
-
* EXPLAIN (FORMAT JSON)'s `Mason.
|
|
12
|
+
* EXPLAIN (FORMAT JSON)'s `Mason.Tables` — the sync's own words, the same line `mason status` prints.
|
|
13
13
|
*
|
|
14
14
|
* STDOUT IS THE PROTOCOL'S: newline-delimited JSON-RPC 2.0 frames and nothing else (`CommandSpec.stdout`: no --json,
|
|
15
15
|
* every failure on stderr); what it says goes to stderr. A tool's failure is Mason's refusal (SQLSTATE, message,
|
|
@@ -29,15 +29,15 @@ import { PgConnection, PgError, pgIdent, pgLiteral, socketErrorText } from "../c
|
|
|
29
29
|
import { qualifiedName } from "../duck/explain.js";
|
|
30
30
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
31
31
|
/** The URL a client is shown in the help: a user's, as mason user add prints it. */
|
|
32
|
-
const EXAMPLE_URL = "postgres://claude:<password>@127.0.0.1:5433/
|
|
32
|
+
const EXAMPLE_URL = "postgres://claude:<password>@127.0.0.1:5433/mason";
|
|
33
33
|
export const MCP = {
|
|
34
34
|
name: "mcp",
|
|
35
|
-
summary: "an MCP server on stdio, for Claude Code, Claude Desktop and other agents: the
|
|
35
|
+
summary: "an MCP server on stdio, for Claude Code, Claude Desktop and other agents: the synced tables, queries, refusals",
|
|
36
36
|
usage: `usage: mason mcp [--url <postgres://user:password@host:port/database>]
|
|
37
37
|
|
|
38
38
|
An MCP (Model Context Protocol) server on stdin and stdout, for Claude Code, Claude Desktop and any MCP client.
|
|
39
39
|
Its tools say what an agent would otherwise guess: the tables Mason serves, each one's columns, rows and how
|
|
40
|
-
current
|
|
40
|
+
current each is, and each statement's answer with its Postgres types — or Mason's refusal, with what to write
|
|
41
41
|
instead. It connects to the Postgres API as a mason user, by the URL mason user add printed (MASON_URL, else
|
|
42
42
|
--url), so it works beside mason start or against a mason serve elsewhere, and every statement it runs is in
|
|
43
43
|
the request log under application_name mason-mcp/<the client's name>. It serves until the client closes stdin.
|
|
@@ -121,7 +121,7 @@ function connect(s, applicationName) {
|
|
|
121
121
|
applicationName, connectTimeoutMs: 10_000, textForms: [] });
|
|
122
122
|
}
|
|
123
123
|
/** Why a connection failed, in the failure shape: nothing listening, a password refused, a database it does not serve. */
|
|
124
|
-
function connectFailure(e, s) {
|
|
124
|
+
export function connectFailure(e, s) {
|
|
125
125
|
const where = `${s.host}:${s.port}`;
|
|
126
126
|
if (e instanceof PgError) {
|
|
127
127
|
const hint = e.fields.H;
|
|
@@ -131,7 +131,9 @@ function connectFailure(e, s) {
|
|
|
131
131
|
next: [["mason user list", "the users, beside the run"], ADD_STEP] };
|
|
132
132
|
}
|
|
133
133
|
return { message: `${where} refused the connection: ${e.fields.M ?? e.message}${e.code ? ` (${e.code})` : ""}`, code: "refused", why: hint ?? e.detail,
|
|
134
|
-
|
|
134
|
+
// a URL from before the name moved is the likely one: the hint is the listener's, and says the right name
|
|
135
|
+
fix: e.code === "3D000" ? "the last part of the URL is the database: change it to the one the hint names, or use the URL mason user add printed"
|
|
136
|
+
: "the URL mason user add printed", next: [HELP_STEP] };
|
|
135
137
|
}
|
|
136
138
|
const reason = e instanceof Error ? socketErrorText(e) : String(e);
|
|
137
139
|
const said = /ECONNREFUSED/.test(reason) ? `nothing is listening on ${where}` : reason.replace(/^connecting to \S+: /, "");
|
|
@@ -224,8 +226,9 @@ export const TOOLS = [
|
|
|
224
226
|
{
|
|
225
227
|
name: "tables",
|
|
226
228
|
title: "The tables Mason serves",
|
|
227
|
-
description: "The tables Mason serves, each
|
|
228
|
-
"and how
|
|
229
|
+
description: "The tables Mason serves, each synced from a table of the application's Postgres database: about how many rows each holds, " +
|
|
230
|
+
"and how it is kept current and when it was last synced (sync: off — read again only when someone runs mason sync — or polled " +
|
|
231
|
+
"every so often, or streamed by logical replication). A table last synced days ago answers as of then: say so with the answer. " +
|
|
229
232
|
"Start here: a statement can read only these, named as listed.",
|
|
230
233
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
231
234
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -233,7 +236,8 @@ export const TOOLS = [
|
|
|
233
236
|
{
|
|
234
237
|
name: "describe",
|
|
235
238
|
title: "One table's columns",
|
|
236
|
-
description: "One table's columns with their Postgres types, its primary key and foreign keys, about how many rows it holds
|
|
239
|
+
description: "One table's columns with their Postgres types, its primary key and foreign keys, about how many rows it holds, how it is kept current " +
|
|
240
|
+
"and when it was last synced.",
|
|
237
241
|
inputSchema: { type: "object", properties: { table: { type: "string", description: "the table as tables lists it (public.orders), or a bare name in public (orders)" } },
|
|
238
242
|
required: ["table"], additionalProperties: false },
|
|
239
243
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -254,8 +258,9 @@ export const TOOLS = [
|
|
|
254
258
|
{
|
|
255
259
|
name: "explain",
|
|
256
260
|
title: "How Mason answers a statement",
|
|
257
|
-
description: "How Mason answers one statement, as its EXPLAIN prints it: each
|
|
258
|
-
"the SQL Mason runs for it, and the plan. analyze: true runs the statement too and adds each step's rows and time."
|
|
261
|
+
description: "How Mason answers one statement, as its EXPLAIN prints it: each synced table the statement reads with its rows and how current it is, " +
|
|
262
|
+
"the SQL Mason runs for it, and the plan. analyze: true runs the statement too and adds each step's rows and time. " +
|
|
263
|
+
"For a statement that reads a table your role filters: no plan and no row counts, and analyze is refused.",
|
|
259
264
|
inputSchema: { type: "object", properties: {
|
|
260
265
|
sql: { type: "string", description: "one statement, in Postgres's SQL (without EXPLAIN)" },
|
|
261
266
|
analyze: { type: "boolean", default: false, description: "run it, and say each step's rows and time" },
|
|
@@ -269,13 +274,14 @@ export const RESOURCES = [
|
|
|
269
274
|
{ uri: "mason://help/agent", name: "agent", title: "Driving Mason from an agent", description: "mason help agent: the commands in order, their --json shapes, exit codes and failures", mimeType: "text/plain" },
|
|
270
275
|
{ uri: "mason://help/mcp", name: "mcp", title: "mason mcp", description: "mason help mcp: this server's tools, and how to add it to a client", mimeType: "text/plain" },
|
|
271
276
|
];
|
|
272
|
-
const INSTRUCTIONS = "Mason serves read-only
|
|
273
|
-
"Call tables first, describe a table before querying it, and write Postgres's SQL. Each table says how current
|
|
277
|
+
const INSTRUCTIONS = "Mason serves read-only tables synced from an application's Postgres database, for analytical SQL (scans and aggregates). " +
|
|
278
|
+
"Call tables first, describe a table before querying it, and write Postgres's SQL. Each table says how current it is. " +
|
|
274
279
|
"When a statement is refused, its hint says what to write instead.";
|
|
275
280
|
/** The relations Mason serves, as the catalog lists them (a catalog read: no row of the request log). */
|
|
276
281
|
async function relations(pg) {
|
|
277
282
|
const r = await pg.query(`SELECT n.nspname, c.relname, c.relkind FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
278
|
-
WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
|
|
283
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm') AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_%'
|
|
284
|
+
AND (pg_catalog.has_table_privilege(c.oid, 'SELECT') OR pg_catalog.has_any_column_privilege(c.oid, 'SELECT')) ORDER BY 1, 2`);
|
|
279
285
|
return r.rows.map(([schema, name, kind]) => ({ schema: schema, name: name, kind: kind === "v" || kind === "m" ? "view" : "table" }));
|
|
280
286
|
}
|
|
281
287
|
class McpServer {
|
|
@@ -494,7 +500,7 @@ class McpServer {
|
|
|
494
500
|
return { tables: all.map((t) => t.kind === "view" ? { table: qualifiedName(t), kind: "view" } : { table: qualifiedName(t), kind: "table", ...copyFacts(copies.get(qualifiedName(t))) }) };
|
|
495
501
|
}
|
|
496
502
|
/**
|
|
497
|
-
* HOW EACH COPY IS KEPT CURRENT, and its rows: EXPLAIN (FORMAT JSON)'s `Mason.
|
|
503
|
+
* HOW EACH COPY IS KEPT CURRENT, and its rows: EXPLAIN (FORMAT JSON)'s `Mason.Tables`, the sync's own words (what
|
|
498
504
|
* `mason status` prints), read over the wire so a remote mason serve answers as a local run does. One EXPLAIN a table
|
|
499
505
|
* (a union of them all failed whole for one table it could not plan, and left a refusal in the request log the agent
|
|
500
506
|
* never sent), several at once on connections of this call's. In a run the catalog lists only the tables copied
|
|
@@ -523,7 +529,7 @@ class McpServer {
|
|
|
523
529
|
catch (e) {
|
|
524
530
|
throw new BadAnswer(`EXPLAIN (FORMAT JSON) of ${key} is not JSON (${e.message}): ${text.slice(0, 200)}`);
|
|
525
531
|
}
|
|
526
|
-
const copy = doc[0]?.Mason?.
|
|
532
|
+
const copy = doc[0]?.Mason?.Tables?.find((c) => c.Table === key);
|
|
527
533
|
if (copy !== undefined)
|
|
528
534
|
out.set(key, copy);
|
|
529
535
|
};
|
|
@@ -556,7 +562,8 @@ class McpServer {
|
|
|
556
562
|
const rel = `${pgLiteral(qualifiedName(found))}::regclass`;
|
|
557
563
|
// pg_catalog.format_type, written out: these are the catalog's own OIDs (mason_wire_type names the wire's)
|
|
558
564
|
const columns = await pg.query(`SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod), a.attnotnull, pg_catalog.col_description(a.attrelid, a.attnum)
|
|
559
|
-
FROM pg_catalog.pg_attribute a WHERE a.attrelid = ${rel} AND a.attnum > 0 AND NOT a.attisdropped
|
|
565
|
+
FROM pg_catalog.pg_attribute a WHERE a.attrelid = ${rel} AND a.attnum > 0 AND NOT a.attisdropped
|
|
566
|
+
AND pg_catalog.has_column_privilege(a.attrelid, a.attnum, 'SELECT') ORDER BY a.attnum`);
|
|
560
567
|
const key = await pg.query(`SELECT a.attname FROM pg_catalog.pg_index i JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
|
|
561
568
|
WHERE i.indrelid = ${rel} AND i.indisprimary ORDER BY array_position(i.indkey::int2[], a.attnum)`);
|
|
562
569
|
const foreign = await pg.query(`SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint WHERE conrelid = ${rel} AND contype = 'f' ORDER BY conname`);
|
|
@@ -666,13 +673,15 @@ class ToolAnswerError extends ToolError {
|
|
|
666
673
|
}
|
|
667
674
|
/** A JSON-RPC error answer. */
|
|
668
675
|
function rpcError(id, code, message) { return { jsonrpc: "2.0", id, error: { code, message } }; }
|
|
669
|
-
/** A copy's facts as the tools say them: about how many rows, how it is kept current,
|
|
676
|
+
/** A copy's facts as the tools say them: about how many rows, how it is kept current, what keeps it current now, and —
|
|
677
|
+
* polled or off — its sync and when it was last synced (a copy's age is never hidden: issue 33). */
|
|
670
678
|
function copyFacts(c) {
|
|
671
679
|
if (c === undefined)
|
|
672
|
-
return { rows_about: null, kept: "how it is kept current is unknown: Mason has
|
|
680
|
+
return { rows_about: null, kept: "how it is kept current is unknown: Mason has not synced it" };
|
|
673
681
|
if ("error" in c)
|
|
674
682
|
return { rows_about: null, kept: `how it is kept current could not be read: ${c.error}` };
|
|
675
|
-
return { rows_about: c["Estimated Rows"], kept: c["Kept Current"], kept_current_by: c["Kept Current By"]
|
|
683
|
+
return { rows_about: c["Estimated Rows"] ?? null, kept: c["Kept Current"], kept_current_by: c["Kept Current By"],
|
|
684
|
+
...(c.Sync !== undefined ? { sync: c.Sync, last_synced: c["Last Synced"] ?? null } : {}) };
|
|
676
685
|
}
|
|
677
686
|
/** A refusal as the model reads it: Postgres's fields, as psql prints them, and the same as structured content.
|
|
678
687
|
* `shift`: the characters this server put before the statement (EXPLAIN's), taken off its position. */
|