@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,143 @@
|
|
|
1
|
+
import { parseSql, PostgresSyntaxError } from "./parser.js";
|
|
2
|
+
import { Unsupported } from "./translate.js";
|
|
3
|
+
const bodyOf = (n) => Object.values(n)[0];
|
|
4
|
+
/** Postgres's defGetBoolean: no value is true; true / false / on / off / 1 / 0 */
|
|
5
|
+
function booleanOf(name, arg, at) {
|
|
6
|
+
if (arg === undefined)
|
|
7
|
+
return true;
|
|
8
|
+
const kind = Object.keys(arg)[0];
|
|
9
|
+
if (kind === "Integer") {
|
|
10
|
+
const v = Number(bodyOf(arg).ival ?? 0);
|
|
11
|
+
if (v === 0 || v === 1)
|
|
12
|
+
return v === 1;
|
|
13
|
+
}
|
|
14
|
+
if (kind === "String" || kind === "Boolean") {
|
|
15
|
+
const v = kind === "Boolean" ? String(bodyOf(arg).boolval ?? false) : String(bodyOf(arg).sval ?? "").toLowerCase();
|
|
16
|
+
if (["true", "on"].includes(v))
|
|
17
|
+
return true;
|
|
18
|
+
if (["false", "off"].includes(v))
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
throw new Unsupported(`${name} requires a Boolean value`, at, "42601"); // defGetBoolean: a syntax error
|
|
22
|
+
}
|
|
23
|
+
/** Postgres's defGetString: an option's value as its text (an integer's digits, a float's as written, a Boolean's
|
|
24
|
+
* true / false); no value at all is its 42601. Compared case-sensitively after, as explain.c does (`'JSON'` is not json). */
|
|
25
|
+
function stringOf(name, arg, at) {
|
|
26
|
+
if (arg === undefined)
|
|
27
|
+
throw new Unsupported(`${name} requires a parameter`, at, "42601");
|
|
28
|
+
const kind = Object.keys(arg)[0];
|
|
29
|
+
const b = bodyOf(arg);
|
|
30
|
+
if (kind === "Integer")
|
|
31
|
+
return String(b.ival ?? 0); // a 0 is no field at all in the parse tree
|
|
32
|
+
if (kind === "Float")
|
|
33
|
+
return String(b.fval ?? "");
|
|
34
|
+
if (kind === "Boolean")
|
|
35
|
+
return String(b.boolval ?? false);
|
|
36
|
+
return String(b.sval ?? "");
|
|
37
|
+
}
|
|
38
|
+
/** an EXPLAIN's options as Postgres reads them (explain.c ParseExplainOptionList), its errors in its words */
|
|
39
|
+
export function explainOptions(options) {
|
|
40
|
+
const o = { analyze: false, verbose: false, costs: true, timing: true, summary: false, format: "text", ignored: [] };
|
|
41
|
+
let timingSet = false, summarySet = false, wal = false, serialize = false;
|
|
42
|
+
for (const d of options) {
|
|
43
|
+
const b = bodyOf(d);
|
|
44
|
+
const name = String(b.defname ?? "");
|
|
45
|
+
const at = typeof b.location === "number" ? b.location : null;
|
|
46
|
+
switch (name) {
|
|
47
|
+
case "analyze":
|
|
48
|
+
o.analyze = booleanOf("analyze", b.arg, at);
|
|
49
|
+
break;
|
|
50
|
+
case "verbose":
|
|
51
|
+
o.verbose = booleanOf("verbose", b.arg, at);
|
|
52
|
+
break;
|
|
53
|
+
case "costs":
|
|
54
|
+
o.costs = booleanOf("costs", b.arg, at);
|
|
55
|
+
break;
|
|
56
|
+
case "timing":
|
|
57
|
+
o.timing = booleanOf("timing", b.arg, at);
|
|
58
|
+
timingSet = true;
|
|
59
|
+
break;
|
|
60
|
+
case "summary":
|
|
61
|
+
o.summary = booleanOf("summary", b.arg, at);
|
|
62
|
+
summarySet = true;
|
|
63
|
+
break;
|
|
64
|
+
case "buffers":
|
|
65
|
+
case "settings":
|
|
66
|
+
case "memory":
|
|
67
|
+
if (booleanOf(name, b.arg, at))
|
|
68
|
+
o.ignored.push(name.toUpperCase());
|
|
69
|
+
break;
|
|
70
|
+
case "wal":
|
|
71
|
+
wal = booleanOf(name, b.arg, at);
|
|
72
|
+
if (wal)
|
|
73
|
+
o.ignored.push("WAL");
|
|
74
|
+
break;
|
|
75
|
+
case "serialize": {
|
|
76
|
+
// SERIALIZE alone is TEXT; else OFF, NONE, TEXT or BINARY, and nothing else (no Boolean: Postgres 17 refuses `on`)
|
|
77
|
+
const v = b.arg === undefined ? "text" : stringOf(name, b.arg, at);
|
|
78
|
+
if (!["none", "off", "text", "binary"].includes(v))
|
|
79
|
+
throw new Unsupported(`unrecognized value for EXPLAIN option "serialize": "${v}"`, at, "22023");
|
|
80
|
+
serialize = v === "text" || v === "binary";
|
|
81
|
+
if (serialize)
|
|
82
|
+
o.ignored.push("SERIALIZE");
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "generic_plan":
|
|
86
|
+
if (booleanOf(name, b.arg, at))
|
|
87
|
+
throw new Unsupported(`EXPLAIN (GENERIC_PLAN) is not supported by Mason (Mason gap explain-option)`, at, "0A000", "bind the parameters' values and EXPLAIN that: DuckDB plans the statement with them");
|
|
88
|
+
break;
|
|
89
|
+
case "format": {
|
|
90
|
+
const v = stringOf(name, b.arg, at);
|
|
91
|
+
if (v === "text" || v === "json") {
|
|
92
|
+
o.format = v;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
if (v === "xml" || v === "yaml")
|
|
96
|
+
throw new Unsupported(`EXPLAIN (FORMAT ${v.toUpperCase()}) is not supported by Mason (Mason gap explain-option)`, at, "0A000", "write FORMAT TEXT or FORMAT JSON");
|
|
97
|
+
throw new Unsupported(`unrecognized value for EXPLAIN option "format": "${v}"`, at, "22023");
|
|
98
|
+
}
|
|
99
|
+
default: throw new Unsupported(`unrecognized EXPLAIN option "${name}"`, at, "42601");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (wal && !o.analyze)
|
|
103
|
+
throw new Unsupported("EXPLAIN option WAL requires ANALYZE", null, "22023");
|
|
104
|
+
if (timingSet && o.timing && !o.analyze)
|
|
105
|
+
throw new Unsupported("EXPLAIN option TIMING requires ANALYZE", null, "22023");
|
|
106
|
+
if (serialize && !o.analyze)
|
|
107
|
+
throw new Unsupported("EXPLAIN option SERIALIZE requires ANALYZE", null, "22023");
|
|
108
|
+
if (!summarySet)
|
|
109
|
+
o.summary = o.analyze;
|
|
110
|
+
if (!o.analyze)
|
|
111
|
+
o.timing = false;
|
|
112
|
+
return o;
|
|
113
|
+
}
|
|
114
|
+
/** a tree without its offsets into the text: every field @pgsql/types declares as one (an IN list's bounds too) */
|
|
115
|
+
const OFFSETS = new Set(["location", "name_location", "stmt_location", "stmt_len", "list_start", "list_end", "rexpr_list_start", "rexpr_list_end"]);
|
|
116
|
+
const stripped = (n) => JSON.stringify(n, (k, v) => OFFSETS.has(k) ? undefined : v);
|
|
117
|
+
/**
|
|
118
|
+
* The statement an EXPLAIN explains, as its own text: the slice of the EXPLAIN's text that parses, alone, to the tree
|
|
119
|
+
* the EXPLAIN holds (its first start that does, tried at each `SELECT`, `WITH`, `VALUES`, `TABLE` or `(` after the
|
|
120
|
+
* keyword) — so it is analysed, planned and refused as the statement itself is. Null where no slice does (a Mason gap:
|
|
121
|
+
* the caller refuses it).
|
|
122
|
+
*/
|
|
123
|
+
export async function explainedText(text, query) {
|
|
124
|
+
const want = stripped(query);
|
|
125
|
+
const start = /^\s*explain\b/i.exec(text.replace(/\/\*[\s\S]*?\*\//g, (c) => " ".repeat(c.length)));
|
|
126
|
+
const from = start === null ? 0 : start[0].length;
|
|
127
|
+
for (const m of text.slice(from).matchAll(/\b(select|with|values|table)\b|\(/gi)) {
|
|
128
|
+
const at = from + m.index;
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = await parseSql(text.slice(at));
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
if (e instanceof PostgresSyntaxError)
|
|
135
|
+
continue;
|
|
136
|
+
throw e;
|
|
137
|
+
} // a slice that is no statement: the next start
|
|
138
|
+
const stmts = parsed.stmts ?? [];
|
|
139
|
+
if (stmts.length === 1 && stmts[0]?.stmt !== undefined && stripped(stmts[0].stmt) === want)
|
|
140
|
+
return { text: text.slice(at), offset: at };
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THE POSTGRES API LISTENS, READ IN ONE PLACE: `MASON_PG_PORT` (default 5433) and `MASON_PG_HOST`
|
|
3
|
+
* (default: the host its caller names, 127.0.0.1 for a run). `mason start` reads it to listen and to record where its run's listener is for
|
|
4
|
+
* `mason user` and `mason status` (src/cli/dev.ts); in the ClickHouse stack this was cut from, its Query API's
|
|
5
|
+
* listener read it too, and its service's config check, to refuse a bad port at the start instead of a child
|
|
6
|
+
* crash-looping — one reader, one default, so what the CLI prints is where the API listens (CLAUDE.md "Fix the
|
|
7
|
+
* class": a fact derived in several places is one module's).
|
|
8
|
+
*
|
|
9
|
+
* WHETHER IT IS TLS, READ HERE TOO (the DuckDB run once fixed its host to 127.0.0.1 and its TLS to none, and on
|
|
10
|
+
* the test server a TLS proxy had to be written beside Mason for a hosted BI tool): `MASON_PG_TLS_CERT` / `MASON_PG_TLS_KEY`
|
|
11
|
+
* (PEM files, both or neither) and the plain-listener rule (src/api/postgres/plain.ts) are `pgListenerSettings`'s, read
|
|
12
|
+
* by `mason start` and `mason serve` alike (src/cli/pg-tls.ts `runListenerSettings`) — `mason start --self-signed-tls` names its own files through
|
|
13
|
+
* the same two variables (src/cli/pg-tls.ts), never a second reader.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import { parsePort } from "../../lib/port.js";
|
|
17
|
+
import { plainListener } from "./plain.js";
|
|
18
|
+
export const DEFAULT_PG_PORT = 5433;
|
|
19
|
+
/** The listener's address from `env`, or the problem with its port (`parsePort`'s words). */
|
|
20
|
+
export function pgListenerAddress(env, apiHost) {
|
|
21
|
+
const port = parsePort("MASON_PG_PORT", env.MASON_PG_PORT, DEFAULT_PG_PORT);
|
|
22
|
+
if (port.problem !== undefined)
|
|
23
|
+
return { problem: port.problem };
|
|
24
|
+
return { host: env.MASON_PG_HOST || apiHost, port: port.port };
|
|
25
|
+
}
|
|
26
|
+
/** Where and how the listener listens, from `env` (`MASON_PG_HOST`, `MASON_PG_PORT`, `MASON_PG_TLS_CERT`,
|
|
27
|
+
* `MASON_PG_TLS_KEY`, `MASON_PG_PLAIN`), or why it cannot: a bad port, half a certificate, plain off loopback. Reads no
|
|
28
|
+
* file, so a start can be refused before anything is made. */
|
|
29
|
+
export function pgListenerSettings(env, apiHost) {
|
|
30
|
+
const address = pgListenerAddress(env, apiHost);
|
|
31
|
+
if ("problem" in address)
|
|
32
|
+
return { problem: address.problem, kind: "port" };
|
|
33
|
+
const cert = env.MASON_PG_TLS_CERT || undefined, key = env.MASON_PG_TLS_KEY || undefined;
|
|
34
|
+
if ((cert && !key) || (key && !cert))
|
|
35
|
+
return { problem: "MASON_PG_TLS_CERT and MASON_PG_TLS_KEY go together (PEM files): set both, or neither for a plain listener", kind: "tls_pair" };
|
|
36
|
+
const plain = plainListener(address.host, env.MASON_PG_PLAIN, Boolean(cert && key));
|
|
37
|
+
if (plain !== null)
|
|
38
|
+
return plain;
|
|
39
|
+
return { ...address, tls: cert && key ? { cert, key } : null };
|
|
40
|
+
}
|
|
41
|
+
/** The certificate and key the settings name, read; or why not. */
|
|
42
|
+
export function readPgTls(files) {
|
|
43
|
+
try {
|
|
44
|
+
return { cert: fs.readFileSync(files.cert), key: fs.readFileSync(files.key) };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return { problem: `cannot read MASON_PG_TLS_CERT / MASON_PG_TLS_KEY: ${err instanceof Error ? err.message : String(err)}`, kind: "tls_read" };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The Postgres API's address as a URL without credentials — `postgresql://<host>:<port>/<database>` — the form
|
|
52
|
+
* `mason start` records in the project's config and `mason status` prints; `mason user add` puts a user and its
|
|
53
|
+
* password in it (src/cli/pg-url.ts `connectionUrl`). A host of every interface (0.0.0.0, ::) is written as
|
|
54
|
+
* localhost: that is where a tool on the machine reaches it. A TLS listener's ends `?sslmode=require`, the libpq word every driver takes:
|
|
55
|
+
* it refuses a plain connection, so the URL a tool is handed says so.
|
|
56
|
+
*/
|
|
57
|
+
export function postgresApiUrl(address, database, o = {}) {
|
|
58
|
+
const host = address.host === "0.0.0.0" || address.host === "::" ? "localhost" : address.host;
|
|
59
|
+
return `postgresql://${host.includes(":") ? `[${host}]` : host}:${address.port}/${encodeURIComponent(database)}${o.tls ? "?sslmode=require" : ""}`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE POSTGRES PARSER, in a worker thread (src/api/postgres/parser.ts asks it, and says why it is recycled).
|
|
3
|
+
*
|
|
4
|
+
* `libpg-query` is Postgres 18's own parser compiled to WebAssembly: the same grammar a statement would meet on a real
|
|
5
|
+
* Postgres, so "does this parse" is never a question this repo answers for itself.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: a request is the SQL text; the reply is `{ ok: true, result }` (the parse tree) or
|
|
8
|
+
* `{ ok: false, message, cursorPosition }` (a syntax error, with the 0-based offset: libpg-query
|
|
9
|
+
* has already converted Postgres's 1-based cursor), or `{ ok: false, fatal: true, message }` when
|
|
10
|
+
* the parser itself could not start. After every reply the shared signal is set to 1 and notified.
|
|
11
|
+
*/
|
|
12
|
+
import { setFlagsFromString } from "node:v8";
|
|
13
|
+
import { workerData } from "node:worker_threads";
|
|
14
|
+
const { port, signal } = workerData;
|
|
15
|
+
const answered = () => { Atomics.store(signal, 0, 1); Atomics.notify(signal, 0); };
|
|
16
|
+
// NEVER OPTIMIZE THE PARSER'S WEBASSEMBLY. By default V8 re-compiles a hot WebAssembly function with its
|
|
17
|
+
// optimizing compiler in the background, and for this module that costs ~900 MB of resident memory, kept for
|
|
18
|
+
// the life of the process (the test server, node 24.21 on Linux: emit peaks at 995 MB with it and 107 MB
|
|
19
|
+
// without, `/sys/fs/cgroup/memory.peak` of the bootstrap container, which is limited to 1 GiB and killed it).
|
|
20
|
+
// A statement is short: the baseline code is fast enough.
|
|
21
|
+
//
|
|
22
|
+
// WHAT TURNS IT OFF IS MEASURED, NOT INFERRED, because the flags read at runtime differ from the ones read at
|
|
23
|
+
// start-up and differ between platforms. `--liftoff-only` on the command line works (93 MB) but is read once,
|
|
24
|
+
// when the engine starts, and set here does nothing (995 MB). `--wasm-tiering-budget=<max>` set here worked on
|
|
25
|
+
// macOS (the first fix, 117 MB) and does NOTHING on the test server's Linux build (997 MB as a CLI flag,
|
|
26
|
+
// 995 MB from here) — which is how a reload that had fitted stopped fitting once a few more parses were added.
|
|
27
|
+
// Each of `--no-wasm-dynamic-tiering` and `--no-wasm-tier-up` alone is still 995 MB; BOTH, set here before
|
|
28
|
+
// `libpg-query` compiles its module below, are 107 MB on the test server (three runs) — the two switches that
|
|
29
|
+
// `--liftoff-only` implies. Re-measure on the test server before changing this line: a wrong flag is silent.
|
|
30
|
+
setFlagsFromString("--no-wasm-dynamic-tiering --no-wasm-tier-up");
|
|
31
|
+
// THE PARSER'S OWN START-UP FAILURE IS THE REPLY. The caller is blocked in `Atomics.wait` and can observe nothing
|
|
32
|
+
// but the port and the signal, so a `libpg-query` that is not installed (no `npm ci`) or a WebAssembly that will not
|
|
33
|
+
// load is reported as a fatal reply carrying the real message — not as a 20 s silence guessed at from outside.
|
|
34
|
+
let parser;
|
|
35
|
+
try {
|
|
36
|
+
parser = await import("libpg-query");
|
|
37
|
+
await parser.loadModule();
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
const fatal = { ok: false, fatal: true, message: `the Postgres parser could not start: ${e.message}` };
|
|
41
|
+
port.on("message", () => { port.postMessage(fatal); answered(); });
|
|
42
|
+
port.postMessage(fatal);
|
|
43
|
+
answered();
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
port.on("message", (sql) => {
|
|
47
|
+
try {
|
|
48
|
+
const result = parser.parseSync(String(sql));
|
|
49
|
+
port.postMessage({ ok: true, result });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
const err = e instanceof parser.SqlError ? e : null;
|
|
53
|
+
port.postMessage({ ok: false, message: e.message, cursorPosition: err?.sqlDetails?.cursorPosition ?? null });
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
answered();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE POSTGRES PARSER FOR THE POSTGRES LISTENER: Postgres 18's own grammar (`libpg-query`, WebAssembly), in a worker, asked
|
|
3
|
+
* asynchronously. What a tool sends either parses here exactly as it would on a real Postgres, or fails with
|
|
4
|
+
* Postgres's own message at Postgres's own position (the Postgres API's first invariant, from the day it was planned).
|
|
5
|
+
*
|
|
6
|
+
* WHY A WORKER, AND WHY IT IS RECYCLED. The parser leaks ~110 KB of its WebAssembly memory on EVERY call (measured
|
|
7
|
+
* on 18.1.5, the version installed), and a listener serving a BI tool parses an unbounded number of
|
|
8
|
+
* distinct statements — so the worker is replaced after `RECYCLE_AFTER` parses, which bounds the leak to a few tens of
|
|
9
|
+
* megabytes. And V8 would re-compile the parser's WebAssembly with its optimising compiler for ~900 MB of resident
|
|
10
|
+
* memory, which parser-worker.ts turns off inside the worker: it sets the flags, loads the module, and
|
|
11
|
+
* answers each text on the port with the tree or the syntax error and its offset.
|
|
12
|
+
*
|
|
13
|
+
* NOTHING HERE IS IMPORTED AT STARTUP: the worker is started on the first statement, and a parser that cannot start
|
|
14
|
+
* fails THAT connection's statements with the reason (`libpg-query` is not installed), never the listener's start.
|
|
15
|
+
*/
|
|
16
|
+
import { MessageChannel, Worker } from "node:worker_threads";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { runnable } from "../../lib/runnable.js";
|
|
19
|
+
/** A Postgres syntax error: Postgres's own message, with the 0-based offset into the text. */
|
|
20
|
+
export class PostgresSyntaxError extends Error {
|
|
21
|
+
offset;
|
|
22
|
+
constructor(message, offset) { super(message); this.offset = offset; }
|
|
23
|
+
}
|
|
24
|
+
/** parses per worker before it is replaced (~110 KB leaked each: ~45 MB at most per worker's life) */
|
|
25
|
+
export const RECYCLE_AFTER = 400;
|
|
26
|
+
/** one parse's deadline, the worker's start (WASM instantiation, ~100 ms) included: past it the worker is dead */
|
|
27
|
+
const TIMEOUT_MS = 20_000;
|
|
28
|
+
class ParserWorker {
|
|
29
|
+
worker;
|
|
30
|
+
port;
|
|
31
|
+
/** replies arrive in the order the texts were sent: one queue */
|
|
32
|
+
queue = [];
|
|
33
|
+
sent = 0;
|
|
34
|
+
dead = null;
|
|
35
|
+
constructor() {
|
|
36
|
+
const { port1, port2 } = new MessageChannel();
|
|
37
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
38
|
+
const file = new URL("./parser-worker.ts", import.meta.url);
|
|
39
|
+
// `execArgv: []`: the worker must not inherit this process's Node flags — a process run as `node --input-type=module
|
|
40
|
+
// -e …` would hand it `--input-type`, which Node refuses for a file, and the worker would die before answering
|
|
41
|
+
this.worker = new Worker(runnable(fileURLToPath(file)), { workerData: { port: port2, signal }, transferList: [port2], execArgv: [], argv: [] });
|
|
42
|
+
this.worker.on("error", (e) => this.die(new Error(`the Postgres parser's worker failed: ${e.message}`)));
|
|
43
|
+
this.worker.on("exit", (code) => this.die(new Error(`the Postgres parser's worker exited with code ${code}`)));
|
|
44
|
+
this.worker.unref();
|
|
45
|
+
this.port = port1;
|
|
46
|
+
this.port.unref();
|
|
47
|
+
this.port.on("message", (reply) => {
|
|
48
|
+
if (!reply.ok && "fatal" in reply) {
|
|
49
|
+
this.die(new Error(reply.message));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const p = this.queue.shift();
|
|
53
|
+
if (p === undefined)
|
|
54
|
+
return; // the worker's own fatal reply sent before any request: handled above
|
|
55
|
+
clearTimeout(p.timer);
|
|
56
|
+
if (reply.ok)
|
|
57
|
+
p.resolve(reply.result);
|
|
58
|
+
else
|
|
59
|
+
p.reject(new PostgresSyntaxError(reply.message, reply.cursorPosition));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** everything pending fails with the reason, and so does everything after */
|
|
63
|
+
die(e) {
|
|
64
|
+
if (this.dead !== null)
|
|
65
|
+
return;
|
|
66
|
+
this.dead = e;
|
|
67
|
+
for (const p of this.queue.splice(0)) {
|
|
68
|
+
clearTimeout(p.timer);
|
|
69
|
+
p.reject(e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
get alive() { return this.dead === null; }
|
|
73
|
+
parse(sql) {
|
|
74
|
+
if (this.dead !== null)
|
|
75
|
+
return Promise.reject(this.dead);
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
this.die(new Error(`the Postgres parser did not answer within ${TIMEOUT_MS / 1000} s — its worker (src/api/postgres/parser-worker.ts) is dead or hung; its own error, if it printed one, is on stderr`));
|
|
79
|
+
void this.worker.terminate();
|
|
80
|
+
}, TIMEOUT_MS);
|
|
81
|
+
timer.unref();
|
|
82
|
+
this.queue.push({ resolve, reject, timer });
|
|
83
|
+
this.sent++;
|
|
84
|
+
this.port.postMessage(sql);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async stop() {
|
|
88
|
+
this.die(new Error("the Postgres parser was stopped"));
|
|
89
|
+
await this.worker.terminate();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
let current = null;
|
|
93
|
+
/** workers past their recycle count, kept until their last reply is in */
|
|
94
|
+
const retiring = new Set();
|
|
95
|
+
/** The parse tree of one text (libpg-query's `ParseResult`, Postgres 18's grammar), or a PostgresSyntaxError. */
|
|
96
|
+
export async function parseSql(sql) {
|
|
97
|
+
// an empty string is no statement to Postgres (EmptyQueryResponse; pgjdbc's isValid() sends one), where libpg_query
|
|
98
|
+
// throws "Query cannot be empty" — whitespace, a `;` or a comment alone already parse to no statement
|
|
99
|
+
if (sql === "")
|
|
100
|
+
return { version: 0, stmts: [] };
|
|
101
|
+
if (current === null || !current.alive || current.sent >= RECYCLE_AFTER) {
|
|
102
|
+
if (current !== null && current.alive) {
|
|
103
|
+
// let its queued replies land, then terminate it
|
|
104
|
+
const old = current;
|
|
105
|
+
retiring.add(old);
|
|
106
|
+
void old.parse("SELECT 1").then(() => undefined, () => undefined).finally(() => { retiring.delete(old); void old.stop(); });
|
|
107
|
+
}
|
|
108
|
+
current = new ParserWorker();
|
|
109
|
+
}
|
|
110
|
+
return current.parse(sql);
|
|
111
|
+
}
|
|
112
|
+
/** Stops the parser (tests, and the API's shutdown). */
|
|
113
|
+
export async function stopParser() {
|
|
114
|
+
const w = current;
|
|
115
|
+
current = null;
|
|
116
|
+
if (w !== null)
|
|
117
|
+
await w.stop();
|
|
118
|
+
for (const r of retiring)
|
|
119
|
+
await r.stop();
|
|
120
|
+
retiring.clear();
|
|
121
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PLAIN LISTENER OFF LOOPBACK IS A DECISION, NOT A DEFAULT: the token travels as the password in
|
|
3
|
+
* clear, so anyone on the network path reads it and queries as that token until it expires. On loopback nothing
|
|
4
|
+
* travels; elsewhere the operator says which it is — TLS (`MASON_PG_TLS_CERT` / `MASON_PG_TLS_KEY`), or
|
|
5
|
+
* `MASON_PG_PLAIN=1` for a port published on a private network (a compose or Kubernetes network, a loopback publish
|
|
6
|
+
* on the host). Read by the API's listener and by the service's config check alike, so the refusal is said at the
|
|
7
|
+
* start, never as a child crash-looping. docs/POSTGRES-API.md "Connecting".
|
|
8
|
+
*/
|
|
9
|
+
/** a host on which a plain listener crosses no network */
|
|
10
|
+
export function isLoopback(host) {
|
|
11
|
+
return host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host);
|
|
12
|
+
}
|
|
13
|
+
/** A problem as one line: what, why, the way out — the service's refusal and log. */
|
|
14
|
+
export function problemText(p) {
|
|
15
|
+
return `${p.problem}${p.why ? `: ${p.why}` : ""}${p.fix ? `. ${p.fix}` : ""}`;
|
|
16
|
+
}
|
|
17
|
+
/** The problem with listening plain on `host`, or null: TLS set, a loopback host, or `MASON_PG_PLAIN=1` is fine. */
|
|
18
|
+
export function plainListener(host, plain, tls) {
|
|
19
|
+
if (tls || isLoopback(host))
|
|
20
|
+
return null;
|
|
21
|
+
if (plain === "1" || plain === "true")
|
|
22
|
+
return null;
|
|
23
|
+
if (plain !== undefined && plain !== "" && plain !== "0" && plain !== "false")
|
|
24
|
+
return { problem: `MASON_PG_PLAIN=${plain} is not a setting: 1 (a plain listener on a private network) or unset`, kind: "plain_value" };
|
|
25
|
+
return { problem: `the Postgres API would listen PLAIN on ${host}, off loopback`, why: "a token travels as the password in clear on that network", kind: "plain",
|
|
26
|
+
fix: "Set MASON_PG_TLS_CERT and MASON_PG_TLS_KEY (PEM files) to require TLS, or MASON_PG_PLAIN=1 to say the port is published on a private network " +
|
|
27
|
+
"(a compose or Kubernetes network, a 127.0.0.1 publish on the host) — docs/POSTGRES-API.md \"Connecting\"" };
|
|
28
|
+
}
|
|
29
|
+
/** `plainListener` as one line, as a check at the start prints it. */
|
|
30
|
+
export function plainListenerProblem(host, plain, tls) {
|
|
31
|
+
const p = plainListener(host, plain, tls);
|
|
32
|
+
return p === null ? null : problemText(p);
|
|
33
|
+
}
|