@mason-data/cli 0.0.0-stage → 0.6.2
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/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason user add | list | remove` — NAMED, REVOCABLE LOGINS FOR THE POSTGRES API, WITH NO EXPIRY
|
|
3
|
+
* (docs/DESIGN.md "On a server", item 1 of the agreed plan; src/duck/users.ts holds the rules). A BI
|
|
4
|
+
* tool's datasource stores ONE password: a user's lasts until it is removed.
|
|
5
|
+
*
|
|
6
|
+
* It runs as a second process beside the run (or beside `mason serve`: `docker compose exec mason mason user add
|
|
7
|
+
* metabase`, the data folder's `.mason/`), so it needs the project, never the run: it edits `.mason/users.json` under
|
|
8
|
+
* the file's lock, and the run's listener reads the change at its next connection or statement. The password is said
|
|
9
|
+
* ONCE, by `add`, with the URL to paste (one URL builder, pg-url.ts `connectionUrl`) when the run is up and recorded
|
|
10
|
+
* where it listens. The web page's users (`mason ui`) are the same functions (`addUser`, `listUsers`, `removeUser`),
|
|
11
|
+
* called by the run's command server.
|
|
12
|
+
*/
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { failed, jsonText, parseCommand, reportText, withEnd } from "./command.js";
|
|
15
|
+
import { reach } from "./state.js";
|
|
16
|
+
import { connectionUrl, listenerTips, postgresLink, psqlLine } from "./pg-url.js";
|
|
17
|
+
import { changeUsers, keyFingerprint, newUserId, readUsers, underKey, userPassword, USER_NAME_RE, USER_NAME_RULE, USERS_FILE } from "../duck/users.js";
|
|
18
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
19
|
+
import { out as styleOut } from "./style.js";
|
|
20
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
21
|
+
export const USER = {
|
|
22
|
+
needs: "project",
|
|
23
|
+
name: "user",
|
|
24
|
+
summary: "the users the Postgres API takes: a name and a password that does not expire, for a BI tool or an app",
|
|
25
|
+
usage: `usage: mason user add <name> [--json]
|
|
26
|
+
mason user list [--json]
|
|
27
|
+
mason user remove <name> [--json]
|
|
28
|
+
|
|
29
|
+
A user of this project's Postgres API: a name and a password, as a BI tool's datasource form asks for them
|
|
30
|
+
(Metabase, Grafana, Superset, Power BI) or an app's DATABASE_URL carries them. Its password does not expire:
|
|
31
|
+
it connects until mason user remove, which refuses its next connection at once and
|
|
32
|
+
ends the sessions it holds at their next statement, without a restart. The password is printed once, by add.
|
|
33
|
+
The user name a tool sends must be the user's own.
|
|
34
|
+
|
|
35
|
+
The list is .mason/users.json (names and ids, no password), beside the running Mason, which reads it again by itself.
|
|
36
|
+
Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.
|
|
37
|
+
|
|
38
|
+
mason user add metabase a user for Metabase: its password and the URL to paste
|
|
39
|
+
mason user list
|
|
40
|
+
mason user remove metabase metabase connects no more
|
|
41
|
+
docker compose exec mason mason user add grafana beside mason serve, in its data folder
|
|
42
|
+
|
|
43
|
+
<name> ${USER_NAME_RULE}
|
|
44
|
+
--json as data: add { user (the name), id, addedAt, password, database, and once Mason runs url, host, port,
|
|
45
|
+
sslmode }; list { users: [{ name, id, addedAt, live }] }; remove { removed: { name, id, addedAt } }; next
|
|
46
|
+
`,
|
|
47
|
+
options: { json: { type: "boolean", default: false } },
|
|
48
|
+
json: ["user", "id", "addedAt", "password", "url", "host", "port", "database", "sslmode", "users", "removed", "next"],
|
|
49
|
+
};
|
|
50
|
+
/** The users of a project and why one is not live: added under an API key since rotated. */
|
|
51
|
+
const liveOf = (u, apiKey) => underKey(u, apiKey);
|
|
52
|
+
/** What to run after an add: with Mason up, the list and how to end it; with Mason stopped, mason start first. */
|
|
53
|
+
export const addedNext = (name, up) => up
|
|
54
|
+
? [["mason user list", "the users"], [`mason user remove ${name}`, "it connects no more"]]
|
|
55
|
+
: [["mason start", "start Mason: then mason status prints where the Postgres API listens"], ["mason user list", "the users"]];
|
|
56
|
+
/** What to run after a remove. */
|
|
57
|
+
export const REMOVED_NEXT = [["mason user list", "the users left"], ["mason user add <name>", "a new user, with a new password"]];
|
|
58
|
+
/** Why `name` is not one `mason user <verb>` takes (a usage failure), or null. */
|
|
59
|
+
export function userNameProblem(verb, name) {
|
|
60
|
+
if (name === undefined)
|
|
61
|
+
return `mason user ${verb} <name>: which user`;
|
|
62
|
+
if (!USER_NAME_RE.test(name))
|
|
63
|
+
return `${JSON.stringify(name)} is not a user name: ${USER_NAME_RULE}`;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const cantChange = (file, e) => ({ message: `the list of users could not be changed: ${e.message}`, code: "failed",
|
|
67
|
+
fix: `${file} is the list (mason user writes it): repair it, or move it aside, which removes every user` });
|
|
68
|
+
/** The users `file` lists, each with whether it connects under `apiKey`; or why the file cannot be read. */
|
|
69
|
+
export function listUsers(file, apiKey) {
|
|
70
|
+
const r = readUsers(file);
|
|
71
|
+
if ("problem" in r)
|
|
72
|
+
return { message: `the list of users cannot be read: ${r.problem}`, code: "bad_config", why: "no user connects while it cannot be read",
|
|
73
|
+
fix: `repair ${file}, or move it aside, which removes every user` };
|
|
74
|
+
return { users: r.users.map((u) => ({ name: u.name, id: u.id, addedAt: u.addedAt, live: liveOf(u, apiKey) })) };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* ADD A USER to `file` under its lock: a new id, its password (said here, once, and kept nowhere), and — `link`, where
|
|
78
|
+
* the run's Postgres API listens, when it is up — the URL to paste. A name taken by a live user is refused.
|
|
79
|
+
*/
|
|
80
|
+
export function addUser(file, apiKey, name, o) {
|
|
81
|
+
const usage = userNameProblem("add", name);
|
|
82
|
+
if (usage !== null)
|
|
83
|
+
return { message: usage, code: "usage" };
|
|
84
|
+
const fingerprint = keyFingerprint(apiKey);
|
|
85
|
+
let added;
|
|
86
|
+
let replaced = false;
|
|
87
|
+
let r;
|
|
88
|
+
try {
|
|
89
|
+
r = changeUsers(file, (users) => {
|
|
90
|
+
const was = users.find((u) => u.name === name);
|
|
91
|
+
// a user added under a key since rotated connects no more: its name is free, said
|
|
92
|
+
if (was !== undefined && was.key === fingerprint)
|
|
93
|
+
return { refused: "taken" };
|
|
94
|
+
replaced = was !== undefined;
|
|
95
|
+
added = { name, id: newUserId(), addedAt: new Date().toISOString(), key: fingerprint };
|
|
96
|
+
return [...users.filter((u) => u !== was), added];
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return cantChange(file, e);
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(r)) {
|
|
103
|
+
return { message: `there is already a user ${JSON.stringify(name)}`, code: "refused",
|
|
104
|
+
why: "a name names one user, so removing it means one thing; its password is not kept, so it cannot be printed again",
|
|
105
|
+
fix: `mason user remove ${name}, then mason user add ${name} (a new password; the old one stops connecting), or add another name (${name}-2) and remove ${name} once the tool uses it`,
|
|
106
|
+
next: [["mason user list", "the users"], [`mason user remove ${name}`, "then add it again, with a new password"]] };
|
|
107
|
+
}
|
|
108
|
+
const u = added;
|
|
109
|
+
const password = userPassword(apiKey, u.name, u.id);
|
|
110
|
+
const { link } = o;
|
|
111
|
+
return { user: u.name, id: u.id, addedAt: u.addedAt, password, database: link?.database ?? o.database,
|
|
112
|
+
...(link !== null ? { url: connectionUrl(link, u.name, password), host: link.host, port: link.port, sslmode: link.sslmode ?? "disable" } : {}), replaced };
|
|
113
|
+
}
|
|
114
|
+
/** REMOVE A USER from `file` under its lock: it connects no more. A name the file lacks is refused with the closest it has. */
|
|
115
|
+
export function removeUser(file, name) {
|
|
116
|
+
const usage = userNameProblem("remove", name);
|
|
117
|
+
if (usage !== null)
|
|
118
|
+
return { message: usage, code: "usage" };
|
|
119
|
+
let removed;
|
|
120
|
+
let live = [];
|
|
121
|
+
let r;
|
|
122
|
+
try {
|
|
123
|
+
r = changeUsers(file, (users) => {
|
|
124
|
+
removed = users.find((u) => u.name === name);
|
|
125
|
+
live = users.map((u) => u.name);
|
|
126
|
+
return removed === undefined ? { refused: "unknown" } : users.filter((u) => u !== removed);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return cantChange(file, e);
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(r)) {
|
|
133
|
+
return { message: `there is no user ${JSON.stringify(name)}${didYouMean(name, live)}`, code: "not_found",
|
|
134
|
+
why: live.length ? `the users: ${live.join(", ")}` : "this project has no users", next: [["mason user list", "the users"]] };
|
|
135
|
+
}
|
|
136
|
+
const gone = removed;
|
|
137
|
+
return { removed: { name: gone.name, id: gone.id, addedAt: gone.addedAt } };
|
|
138
|
+
}
|
|
139
|
+
// ------------------------------------------------------------------ the command
|
|
140
|
+
export async function user(argv) {
|
|
141
|
+
const f = parseCommand(USER, argv);
|
|
142
|
+
if (typeof f === "number")
|
|
143
|
+
return f;
|
|
144
|
+
const json = f.values.json === true;
|
|
145
|
+
const usage = (message) => failed("user", { message, code: "usage", usage: USER.usage }, { json });
|
|
146
|
+
const [verb, name, ...rest] = f.positionals;
|
|
147
|
+
if (verb === undefined)
|
|
148
|
+
return usage("add, list or remove: mason user add <name> makes a user");
|
|
149
|
+
if (!["add", "list", "remove"].includes(verb))
|
|
150
|
+
return usage(`there is no mason user ${verb}: add, list or remove${didYouMean(verb, ["add", "list", "remove"])}`);
|
|
151
|
+
if (verb === "list" ? name !== undefined : rest.length)
|
|
152
|
+
return usage(`mason user ${verb} takes ${verb === "list" ? "no name" : "one name"}: ${[name, ...rest].filter(Boolean).join(" ")} is too many`);
|
|
153
|
+
if (verb !== "list") {
|
|
154
|
+
const problem = userNameProblem(verb, name);
|
|
155
|
+
if (problem !== null)
|
|
156
|
+
return usage(problem);
|
|
157
|
+
}
|
|
158
|
+
const w = await reach(USER, json);
|
|
159
|
+
if (w === null || w.project === null)
|
|
160
|
+
return 1;
|
|
161
|
+
const project = w.project;
|
|
162
|
+
const file = path.join(project.dir, USERS_FILE);
|
|
163
|
+
const apiKey = project.config.apiKey;
|
|
164
|
+
if (verb === "list") {
|
|
165
|
+
const r = listUsers(file, apiKey);
|
|
166
|
+
if ("message" in r)
|
|
167
|
+
return failed("user", r, { json });
|
|
168
|
+
const next = [["mason user add <name>", "a user for a tool: its password and URL, printed once"], ...(r.users.length ? [["mason user remove <name>", "that user connects no more"]] : [])];
|
|
169
|
+
if (json) {
|
|
170
|
+
out(jsonText(USER, { users: r.users }, next));
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
const s = styleOut();
|
|
174
|
+
if (!r.users.length) {
|
|
175
|
+
out(withEnd(`${s.bold(`No users yet for ${project.config.name}.`)}`, { next }));
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
const width = Math.max(...r.users.map((u) => u.name.length));
|
|
179
|
+
const lines = r.users.map((u) => ` ${s.cyan(u.name.padEnd(width))} ${s.dim(`id ${u.id} · added ${u.addedAt.slice(0, 16).replace("T", " ")} UTC`)}` +
|
|
180
|
+
(u.live ? "" : ` ${s.yellow("does not connect: the API key was rotated since it was added")}`));
|
|
181
|
+
const rotated = r.users.filter((u) => !u.live);
|
|
182
|
+
out(withEnd(`${s.bold(`${r.users.length} user${r.users.length === 1 ? "" : "s"} of ${project.config.name}`)}\n${lines.join("\n")}`, { ...(rotated.length ? { tips: [`A user added under another API key does not connect: mason user remove ${rotated[0].name}, then mason user add ${rotated[0].name}.`] } : {}), next }));
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
if (verb === "remove") {
|
|
186
|
+
const r = removeUser(file, name);
|
|
187
|
+
if ("message" in r)
|
|
188
|
+
return failed("user", r, { json });
|
|
189
|
+
const gone = r.removed;
|
|
190
|
+
const next = REMOVED_NEXT;
|
|
191
|
+
if (json) {
|
|
192
|
+
out(jsonText(USER, r, next));
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
out(reportText({ headline: `The user ${gone.name} removed: it connects no more`,
|
|
196
|
+
details: [["user", gone.name], ["added", gone.addedAt], ["sessions", w.state === "up" ? "those it holds end at their next statement" : "none: Mason is not running"]],
|
|
197
|
+
tips: [`Adding ${gone.name} again makes a new password: the old one never connects again.`], next }));
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
// add
|
|
201
|
+
const run = w.state === "up" ? w.run : null;
|
|
202
|
+
const link = run ? postgresLink(run.postgresUrl) : null;
|
|
203
|
+
const r = addUser(file, apiKey, name, { link, database: project.config.name });
|
|
204
|
+
if ("message" in r)
|
|
205
|
+
return failed("user", r, { json });
|
|
206
|
+
const { replaced, ...added } = r;
|
|
207
|
+
const next = addedNext(r.user, link !== null);
|
|
208
|
+
if (json) {
|
|
209
|
+
out(jsonText(USER, added, next));
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
out(reportText({
|
|
213
|
+
headline: `The user ${r.user} added${replaced ? " (in place of one the API key's rotation had removed)" : ""}: its password is printed this once`,
|
|
214
|
+
details: [
|
|
215
|
+
...(r.url !== undefined ? [["url", r.url], ["psql", psqlLine(r.url)]] : []),
|
|
216
|
+
["user", r.user], ["password", r.password], ["database", r.database],
|
|
217
|
+
...(link !== null ? [["host", link.host], ["port", String(link.port)], ...(link.sslmode ? [["ssl mode", link.sslmode]] : [])] : []),
|
|
218
|
+
],
|
|
219
|
+
tips: [
|
|
220
|
+
"Paste it into the tool now: Mason keeps no copy of the password. Lost, remove the user and add it again.",
|
|
221
|
+
...(link === null ? ["Mason is not running: mason start, then mason status says the host and port this user connects to."] : []),
|
|
222
|
+
...listenerTips(run?.postgresListener ?? null),
|
|
223
|
+
"It never expires: mason user remove ends it, at once and for every session it holds.",
|
|
224
|
+
"Rotating the API key (apiKey in .mason/config.json, MASON_API_KEY under mason serve) removes every user at once.",
|
|
225
|
+
],
|
|
226
|
+
next,
|
|
227
|
+
}));
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's version when installed; else the image's (`MASON_VERSION`, stamped at build by images-publish.yml:
|
|
3
|
+
* `v0.6.1` for a release, said `0.6.1` as the package says it, or `main-<sha>`); a checkout runs as "dev" (and
|
|
4
|
+
* ClickHouse's `main` image).
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
export function version() {
|
|
10
|
+
// src/cli/ in a checkout (the repo's package.json, which is not this one), dist/src/cli/ in the package
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
for (const p of [path.join(here, "..", "..", "package.json"), path.join(here, "..", "..", "..", "package.json")]) {
|
|
13
|
+
if (!fs.existsSync(p))
|
|
14
|
+
continue;
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
16
|
+
if (pkg.name === "@mason-data/cli" && pkg.version)
|
|
17
|
+
return pkg.version;
|
|
18
|
+
}
|
|
19
|
+
const image = process.env.MASON_VERSION?.trim();
|
|
20
|
+
if (image)
|
|
21
|
+
return /^v\d+\.\d+\.\d+$/.test(image) ? image.slice(1) : image;
|
|
22
|
+
return "dev";
|
|
23
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSTGRES DECIDES, DUCKDB COMPUTES (docs/DESIGN.md). Before DuckDB answers a statement, the catalog Postgres
|
|
3
|
+
* (src/api/postgres/catalog.ts: Postgres 18 itself, holding every copied table under its source types, no rows) parses
|
|
4
|
+
* and analyses it, and that analysis is the answer's contract:
|
|
5
|
+
* - a statement Postgres REFUSES at analysis (an unknown column, a GROUP BY it would not take, `'99.99'` compared with
|
|
6
|
+
* an integer, ORDER BY outside a DISTINCT's list) is refused with Postgres's own error, word for word;
|
|
7
|
+
* - the result's column NAMES, type OIDs and typmods are Postgres's RowDescription, and the parameters' types its
|
|
8
|
+
* ParameterDescription;
|
|
9
|
+
* - the analysed tree (`debug_print_parse`, src/duck/pgtree.ts) gives the rewrite every expression's resolved type.
|
|
10
|
+
* The statement is analysed under the SESSION's search_path (an unqualified table is the one the session would read).
|
|
11
|
+
*
|
|
12
|
+
* ONE BATCH, SERIALISED (the catalog's queue): the logging is switched on, the statement is parsed, described and
|
|
13
|
+
* closed under a name of its own, and everything is set back, before the next session's call runs. Nothing of it is
|
|
14
|
+
* left in the sidecar between calls — another session's Sync can lose nothing (tests/postgres-api/server.test.ts "a
|
|
15
|
+
* catalog statement's portal, beside another session's").
|
|
16
|
+
*/
|
|
17
|
+
import { cstr, decodeBackend, frame, int16, int32 } from "../api/postgres/protocol.js";
|
|
18
|
+
import { parseTree, typesByLocation } from "./pgtree.js";
|
|
19
|
+
const NAME = "__mason_analysis";
|
|
20
|
+
/** the catalog's TimeZone, set back after a statement set the session's (src/api/postgres/catalog.ts sets it at start) */
|
|
21
|
+
const CATALOG_ZONE = "SET TimeZone = 'UTC'";
|
|
22
|
+
const query = (sql) => frame("Q", cstr(sql));
|
|
23
|
+
/** Asks the catalog Postgres to analyse `sql` (with these declared parameter OIDs, 0 = infer). */
|
|
24
|
+
export async function analyze(run, sql, paramOids, searchPath = "public", timeZone = "UTC") {
|
|
25
|
+
const bytes = Buffer.concat([
|
|
26
|
+
// the session's search_path, pg_catalog after it as the catalog's own setup has it (catalog.ts)
|
|
27
|
+
// …and its TimeZone: Postgres parses a literal under it ('2026-01-01 12:00'::timestamptz is that zone's noon), and
|
|
28
|
+
// the rewrite writes the value it parsed (src/duck/rewrite.ts `aConst`)
|
|
29
|
+
query(`SET search_path = ${searchPath}, pg_catalog; SET TimeZone = '${timeZone.replace(/'/g, "''")}'; SET client_min_messages = log; SET debug_print_parse = on; SET debug_pretty_print = off`),
|
|
30
|
+
frame("P", cstr(NAME), cstr(sql), int16(paramOids.length), ...paramOids.map(int32)),
|
|
31
|
+
frame("D", Buffer.from("S"), cstr(NAME)),
|
|
32
|
+
frame("C", Buffer.from("S"), cstr(NAME)),
|
|
33
|
+
frame("S"),
|
|
34
|
+
// the catalog's own TimeZone back (catalog.ts sets UTC at its start): RESET would be the server's boot default, the
|
|
35
|
+
// machine's zone, under which the catalog then answered what reads no table
|
|
36
|
+
query(`RESET debug_print_parse; RESET debug_pretty_print; RESET client_min_messages; ${CATALOG_ZONE}; SET search_path = public, pg_catalog`),
|
|
37
|
+
]);
|
|
38
|
+
const messages = decodeBackend(Buffer.from(await run(bytes)));
|
|
39
|
+
// the replies to the first Query end at its ReadyForQuery; the statement's run to the next one
|
|
40
|
+
const z = messages.findIndex((m) => m.type === "ReadyForQuery");
|
|
41
|
+
const end = messages.findIndex((m, i) => i > z && m.type === "ReadyForQuery");
|
|
42
|
+
if (z < 0 || end < 0)
|
|
43
|
+
throw new Error(`the catalog Postgres's analysis came back out of step (${messages.map((m) => m.type).join(", ")}): report it`);
|
|
44
|
+
const mine = messages.slice(z + 1, end);
|
|
45
|
+
const refused = mine.find((m) => m.type === "ErrorResponse");
|
|
46
|
+
if (refused?.type === "ErrorResponse")
|
|
47
|
+
return { refused: refused.fields };
|
|
48
|
+
const firstError = messages.find((m) => m.type === "ErrorResponse");
|
|
49
|
+
if (firstError?.type === "ErrorResponse")
|
|
50
|
+
throw new Error(`the catalog Postgres could not switch its analysis log on or off: ${firstError.fields.M}`);
|
|
51
|
+
const log = mine.find((m) => m.type === "NoticeResponse" && /^\{QUERY\b/.test(m.fields.D ?? ""));
|
|
52
|
+
if (log?.type !== "NoticeResponse")
|
|
53
|
+
throw new Error("the catalog Postgres logged no parse tree for the statement: report it");
|
|
54
|
+
const tree = parseTree(log.fields.D);
|
|
55
|
+
const pd = mine.find((m) => m.type === "ParameterDescription");
|
|
56
|
+
const rd = mine.find((m) => m.type === "RowDescription");
|
|
57
|
+
return {
|
|
58
|
+
params: pd?.type === "ParameterDescription" ? pd.oids : [],
|
|
59
|
+
fields: rd?.type === "RowDescription" ? rd.fields.map((f) => ({ name: f.name, oid: f.typeOid, typmod: f.typeModifier })) : null,
|
|
60
|
+
types: typesByLocation(tree),
|
|
61
|
+
tree,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** the types whose input DuckDB reads otherwise than Postgres, as the catalog Postgres writes their value back */
|
|
65
|
+
const PARAM_TYPES = {
|
|
66
|
+
16: "boolean", 21: "smallint", 23: "integer", 20: "bigint", 700: "real", 701: "double precision", 1700: "numeric",
|
|
67
|
+
2950: "uuid", 114: "json", 3802: "jsonb", 1083: "time", 1082: "date", 1114: "timestamp", 1184: "timestamptz", 1186: "interval", 869: "inet",
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* A statement's parameters as Postgres reads them: each value through its type's input function (the type Postgres
|
|
71
|
+
* inferred, or the one declared), under the session's TimeZone — so `'1.5'` for a smallint is Postgres's 22P02, and
|
|
72
|
+
* `'01/02/2026'` for a date is 2026-01-02. A value comes back as text DuckDB reads to the same value: a number, a
|
|
73
|
+
* boolean or a uuid canonical; a date as days and a timestamp as microseconds since 2000-01-01 (src/duck/rewrite.ts
|
|
74
|
+
* `datumText` writes them), an interval as its months, days and microseconds; `infinity` as itself.
|
|
75
|
+
*/
|
|
76
|
+
export async function readParameters(run, oids, values, timeZone) {
|
|
77
|
+
const quote = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
78
|
+
const items = values.map((v, i) => {
|
|
79
|
+
const oid = oids[i] ?? 0, type = PARAM_TYPES[oid];
|
|
80
|
+
if (v === null || type === undefined)
|
|
81
|
+
return null;
|
|
82
|
+
const x = `${quote(v)}::${type}`;
|
|
83
|
+
if (oid === 1082)
|
|
84
|
+
return `CASE WHEN isfinite(${x}) THEN (${x} - date '2000-01-01')::text ELSE ${x}::text END`;
|
|
85
|
+
if (oid === 1114 || oid === 1184)
|
|
86
|
+
return `CASE WHEN isfinite(${x}) THEN (extract(epoch FROM ${x}) * 1000000 - 946684800000000)::int8::text ELSE ${x}::text END`;
|
|
87
|
+
if (oid === 1186)
|
|
88
|
+
return `(extract(year FROM ${x}) * 12 + extract(month FROM ${x}))::text || ' ' || extract(day FROM ${x})::text || ' ' || ((extract(hour FROM ${x}) * 60 + extract(minute FROM ${x})) * 60000000 + extract(microseconds FROM ${x}))::int8::text`;
|
|
89
|
+
return `${x}::text`;
|
|
90
|
+
});
|
|
91
|
+
if (items.every((x) => x === null))
|
|
92
|
+
return { values: values.map(() => null) };
|
|
93
|
+
const sql = `SET TimeZone = ${quote(timeZone)}; SELECT ${items.map((x) => x ?? "NULL").join(", ")}; ${CATALOG_ZONE}`;
|
|
94
|
+
const messages = decodeBackend(Buffer.from(await run(query(sql))));
|
|
95
|
+
const refused = messages.find((m) => m.type === "ErrorResponse");
|
|
96
|
+
if (refused?.type === "ErrorResponse") {
|
|
97
|
+
// the value's own error (22P02, 22003, 22008 …) — and the zone set back, which the failed statement skipped
|
|
98
|
+
await run(query(CATALOG_ZONE));
|
|
99
|
+
return { refused: refused.fields };
|
|
100
|
+
}
|
|
101
|
+
const row = messages.find((m) => m.type === "DataRow");
|
|
102
|
+
if (row?.type !== "DataRow")
|
|
103
|
+
throw new Error("the catalog Postgres read the parameters and answered no row: report it");
|
|
104
|
+
return { values: items.map((x, i) => x === null ? null : { oid: oids[i], text: row.values[i].toString("utf8") }) };
|
|
105
|
+
}
|
|
106
|
+
/** One read of the catalog Postgres's own catalog (a signature from pg_proc, a type's name): its rows as text. */
|
|
107
|
+
export async function catalogRows(run, sql) {
|
|
108
|
+
const messages = decodeBackend(Buffer.from(await run(query(sql))));
|
|
109
|
+
const refused = messages.find((m) => m.type === "ErrorResponse");
|
|
110
|
+
if (refused?.type === "ErrorResponse")
|
|
111
|
+
throw new Error(`the catalog Postgres refused its own catalog read: ${refused.fields.M} (in: ${sql.slice(0, 200)})`);
|
|
112
|
+
return messages.flatMap((m) => m.type === "DataRow" ? [m.values.map((v) => v === null ? null : v.toString("utf8"))] : []);
|
|
113
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CANCEL TABLE (the executor worker: src/duck/executor.ts). A statement that calls a Mason function is
|
|
3
|
+
* cancelled through its SESSION as well as interrupted (src/duck/functions.ts "every call carries its session"): its
|
|
4
|
+
* next call reads this table and raises, and its result is closed only once its calls have stopped (store.ts `cleanUp`,
|
|
5
|
+
* the suite's hang). The calls run on the executor worker, which such a statement keeps busy between
|
|
6
|
+
* DuckDB's turns for seconds at a time, so a cancel cannot be a message to it: the listener (the main thread) writes
|
|
7
|
+
* the table directly, in memory both threads share, and the next call sees it at once.
|
|
8
|
+
*
|
|
9
|
+
* Two Int32 cells per session SLOT (a wire session's DuckDB connection holds one while it is open): `cancelled`, the
|
|
10
|
+
* highest statement number of the session cancelled so far, and `running`, the number of the statement its connection
|
|
11
|
+
* runs now (set by the worker once the one before it has been released). A call is cancelled when `cancelled >=
|
|
12
|
+
* running`: a cancel of a statement already past never reaches the next one, a cancel that lands before its statement
|
|
13
|
+
* starts still does, and a statement being drained after its cancel stays cancelled whatever the next one's number.
|
|
14
|
+
* Dependency-free: the listener's thread loads it without DuckDB.
|
|
15
|
+
*/
|
|
16
|
+
/** how many wire sessions may hold a DuckDB connection at once (a slot each) */
|
|
17
|
+
export const CANCEL_SLOTS = 4096;
|
|
18
|
+
/** a fresh table (the executor makes one and hands it to its worker) */
|
|
19
|
+
export function cancelTable() { return new Int32Array(new SharedArrayBuffer(CANCEL_SLOTS * 2 * 4)); }
|
|
20
|
+
const cancelledAt = (slot) => slot * 2;
|
|
21
|
+
const runningAt = (slot) => slot * 2 + 1;
|
|
22
|
+
/** a slot handed to a new session: nothing cancelled, nothing running */
|
|
23
|
+
export function resetSlot(t, slot) { Atomics.store(t, cancelledAt(slot), 0); Atomics.store(t, runningAt(slot), 0); }
|
|
24
|
+
/** statement `seq` of the session in `slot` is cancelled (and every one before it): monotonic, from either thread */
|
|
25
|
+
export function cancelStatement(t, slot, seq) {
|
|
26
|
+
for (let was = Atomics.load(t, cancelledAt(slot)); was < seq; was = Atomics.load(t, cancelledAt(slot))) {
|
|
27
|
+
if (Atomics.compareExchange(t, cancelledAt(slot), was, seq) === was)
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** statement `seq` is the one the session's connection runs now */
|
|
32
|
+
export function beginStatement(t, slot, seq) { Atomics.store(t, runningAt(slot), seq); }
|
|
33
|
+
/** whether the statement the session's connection runs now is cancelled */
|
|
34
|
+
export function isCancelled(t, slot) {
|
|
35
|
+
const running = Atomics.load(t, runningAt(slot));
|
|
36
|
+
return running !== 0 && Atomics.load(t, cancelledAt(slot)) >= running;
|
|
37
|
+
}
|
|
38
|
+
/** whether statement `seq` of the session in `slot` was cancelled (its cancel asked, whether or not a call saw it) */
|
|
39
|
+
export function statementCancelled(t, slot, seq) {
|
|
40
|
+
return seq !== 0 && Atomics.load(t, cancelledAt(slot)) >= seq;
|
|
41
|
+
}
|
|
42
|
+
/** the session variable's value for a slot, and back (`s<slot>`; null for what is no session's) */
|
|
43
|
+
export const slotName = (slot) => `s${slot}`;
|
|
44
|
+
export function slotOf(name) {
|
|
45
|
+
if (name === null || !/^s\d+$/.test(name))
|
|
46
|
+
return null;
|
|
47
|
+
const n = Number(name.slice(1));
|
|
48
|
+
return n < CANCEL_SLOTS ? n : null;
|
|
49
|
+
}
|