@mason-data/cli 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ again and remove `mason/models/`.
|
|
|
106
106
|
| `mason wait [--timeout <s>]` | Waits until every added table is synced and served, showing every added table on a line of its own: 100% for the finished ones, in the order they finished, and a bar for each still syncing (rows, %, rate, time left) at the bottom. Exit 0 ready, 1 a first sync failed (printed, with what to do), 124 timed out (default 600 s). |
|
|
107
107
|
| `mason user add\|list\|remove <name>` | The users of the Postgres API: a name and a password that does not expire, as a BI tool's datasource form asks for them or an app's `DATABASE_URL` carries them. `add` prints the password once with the URL; `remove` refuses the next connection at once and ends the sessions it holds at their next statement, without a restart. |
|
|
108
108
|
| `mason mcp [--url <url>]` | An MCP server on stdio for Claude Code, Claude Desktop and other agents (below): the tables Mason serves with each one's rows and how current it is, a table's columns, a statement's rows with their Postgres types or Mason's refusal with its way out, and EXPLAIN — connected as a `mason user` (`MASON_URL`, else `--url`), beside `mason start` or a `mason serve` elsewhere. |
|
|
109
|
-
| `mason ui` | Opens the running Mason's web page in your browser: the Overview (what `mason status` says), the Tables (add, remove or sync one), the Users of `mason user` (add one, its password shown once; disable, enable or remove one) and Observability (the statements, usage over time, the sync's events), refreshed while the tab is open. It prints the URL with the run's token, which the page trades for a cookie on the first visit; `--no-open` prints it only, `--json` is `{ url, port }`. `mason start`'s alone: `mason serve` serves no page. |
|
|
109
|
+
| `mason ui` | Opens the running Mason's web page in your browser: the Overview (what `mason status` says), the Tables (add, remove or sync one), the Users of `mason user` (add one, its password shown once; disable, enable or remove one) and Observability (the statements, usage over time, the sync's events), refreshed while the tab is open. It prints the URL with the run's token, which the page trades for a cookie on the first visit, and opens it in a browser on a terminal; `--no-open` (or output that is not a terminal) prints it only, `--json` is `{ url, port }`. `mason start`'s alone: `mason serve` serves no page. |
|
|
110
110
|
| `mason status` | What Mason is doing: the database and how its changes are read, the project folder, the synced data's file, the Postgres API and how it listens, each synced table, the statements of the last day and week (each outcome, the completed ones' average, p50 and p95 time, the rows and bytes sent), the ones answered slowly in the last day and the ones refused or failed, each with its reason. In replication mode, where the stream is. |
|
|
111
111
|
| `mason sync <table>…` | Brings these synced tables up to date now, by one poll each: a table whose `sync:` is `off` is kept current only so (from cron, before a report), a polled one is polled now as well as at its interval. In replication mode the stream keeps every table current, and it says where the stream is. |
|
|
112
112
|
| `mason logs [-n <lines>] [-f] [--start] [--requests [<filters>]] [--events [--failed]] [--running]` | Mason's log: every sync, poll and statement, with warnings and errors and their reasons; `-f` follows it, `--start` shows what `mason start` printed instead. `--requests` is the request log: every statement a tool sent, when, the outcome (ok, refused, failed, cancelled) and SQLSTATE, how long, the rows and bytes sent, the tool and the user; `--failed` only the ones not answered, `--outcome`, `--since` / `--until` (`2h`, `7d`, an ISO time), `--user` and `--client` narrow it, and `--before` reads the page before. `--events` is the sync's events: each first sync, poll, catch-up or stream that failed, the database out of reach, a table current again, each sync asked by hand (mason sync, the page's Sync now), and each restart of the run (`--failed` only the failures). `--running` lists the statements running now, by session. |
|
|
@@ -114,10 +114,15 @@ again and remove `mason/models/`.
|
|
|
114
114
|
| `mason usage [--since 7d] [--step 1h]` | What Mason did over time, from a sample a minute kept `MASON_HISTORY_DAYS` days: the statements and their p95, the rows and bytes sent, the synced data's size and rows, the stalest table, the stream's lag, the memory; then each synced table's own size. |
|
|
115
115
|
| `mason stop` | Stops Mason for this project. The synced data and the state stay: the next `mason start` resumes in seconds. |
|
|
116
116
|
| `mason destroy` | Removes this project's synced data (its file) and Mason's state in `.mason/`, stopping Mason first if it runs — and, when a table was replicated, its slot (and its publication, when Mason made it) on the database. Your files, `.mason/config.json` and the users stay: the next `mason start` syncs the tables again. |
|
|
117
|
-
| `mason serve` | Mason for a server or a container: `mason start --attach` configured by the environment alone, its state in one folder (a volume), its log on stdout, `GET /healthz`, `GET /metrics` (Prometheus, numbers only), a clean stop on SIGTERM. `mason serve --help` lists every setting with its default. |
|
|
117
|
+
| `mason serve` | Mason for a server or a container: `mason start --attach` configured by the environment alone, its state in one folder (a volume), its log on stdout, `GET /healthz`, `GET /metrics` (Prometheus, numbers only), a clean stop on SIGTERM. `mason serve --help` lists every setting with its default; `MASON_PROJECT_READONLY=1`, for a project folder deployed as committed, refuses every edit of its files there (`mason add`, `mason remove`, the page's Add and Remove), with "edit the files and deploy them". |
|
|
118
|
+
| `mason login` | Signs this machine in to a hosted Mason service (`MASON_CLOUD_URL`, else `https://app.masondata.ai`): it prints a page and a code (on stderr, at once, so a coding agent can pass them on), opens the page in a browser on a terminal, waits until the code is approved, then says who is signed in and their organisations. The login is kept in `~/.config/mason/credentials.json` (0600), never in a project; refused while `MASON_TOKEN` is set, which every command uses first. [`docs/HOSTED-API.md`](../docs/HOSTED-API.md) is the API. |
|
|
119
|
+
| `mason logout` | Revokes this machine's login to that service and removes it. When the service cannot be reached, the login is still removed, and it says it was NOT revoked and how to revoke it. |
|
|
120
|
+
| `mason replica create\|list\|status` | A hosted Mason service's replicas: `create <org>/<name> --region <region> --size <gb>` makes one for this project's database (its URL read by the variable the sources file names, never from the command line) and waits until it is ready; `list [<org>]` lists them; `status <org>/<name>` says how one is. [`docs/HOSTED-API.md`](../docs/HOSTED-API.md#replicas-mason-replica) is the API. |
|
|
121
|
+
| `mason token create\|list\|revoke` | A hosted Mason service's CI tokens: `create --replica <org>/<name>` makes one for a replica, printed once, for a deploy from CI (`MASON_TOKEN`); `list [<org>]` lists them; `revoke <id>` revokes one. |
|
|
122
|
+
| `mason deploy [--replica <org>/<name>] [--dry-run] [--timeout 10m]` | Sends this project's files (the YAML under `mason/`: never `.env` or `.mason/`) to a replica on the hosted service (`--replica`, else `MASON_REPLICA`), after checking the project as `mason start` does, without connecting to any database, then waits until the replica runs it (exit 0), Mason there refuses it (its failure printed as it came, exit 1), or the timeout (exit 124: the deploy goes on, its id said). `--dry-run` prints each file, its size and its sha256, and sends nothing. The login is `MASON_TOKEN`, else `mason login`'s. |
|
|
118
123
|
|
|
119
124
|
Every command documents itself: `mason --help` lists them, and `mason <command> --help` (or `mason help <command>`) says
|
|
120
|
-
how to use one, with examples
|
|
125
|
+
how to use one, with examples; `mason <command> --help --json` is its spec as data, and `mason help --json` every command's. Every command but `mcp` (whose stdout is the MCP protocol's) takes `--json`. A command means the project of the folder it
|
|
121
126
|
runs in, or the nearest one above it.
|
|
122
127
|
|
|
123
128
|
## What `mason start` does
|
|
@@ -156,7 +161,7 @@ there (`mason status` lists them); `MASON_STORE_JSON_PATHS=off` turns that off.
|
|
|
156
161
|
| Option | What it does |
|
|
157
162
|
|---|---|
|
|
158
163
|
| `--self-signed-tls` | The Postgres API requires TLS, from a certificate made for this project in `.mason/` (served again by every later start with the flag); clients connect with `sslmode=require`. |
|
|
159
|
-
| `--port <n>` | The port the `mason` commands
|
|
164
|
+
| `--ui-port <n>` | The port of the web page and the `mason` commands, on 127.0.0.1 (default 4039, or the next free one); `MASON_UI_PORT` on a server. `--port` is gone: it answers with the line to run and exit 2. |
|
|
160
165
|
| `--attach` | Stay in the foreground: the terminal shows Mason's progress, and Ctrl-C stops it. |
|
|
161
166
|
|
|
162
167
|
`--replication`, `--poll` and `--publication` are gone: each answers with the line to write at the top of the sources
|
|
@@ -178,7 +183,7 @@ what changed is written. A different database is refused: `mason destroy`, then
|
|
|
178
183
|
`mason help agent` is the page an agent reads first, offline: the commands in the order a project takes them, every
|
|
179
184
|
answer's shape, the exit codes and the failure codes. In short:
|
|
180
185
|
|
|
181
|
-
- Every command but `
|
|
186
|
+
- Every command but `mcp` takes `--json` and answers with **one JSON object on stdout**, always with
|
|
182
187
|
`next: [{ command, what }]` — the commands to run next, the same a person reads under `Next:`. The other keys are the
|
|
183
188
|
command's own; `mason help <command>` names them on its `--json` line. Lists are wrapped (`{ tables: […], next }`).
|
|
184
189
|
- A failure is `{ "error": { "code", "message", "why", "fix" }, "next": […] }` on stdout, and the **exit code** says it:
|
|
@@ -263,9 +268,15 @@ seeds it again.
|
|
|
263
268
|
`mason doctor` checks all of this and says what is missing.
|
|
264
269
|
|
|
265
270
|
- Node 22.13 or later. The SQL parser (`libpg-query`), the catalog (`PGlite`) and the engine (DuckDB) ship as
|
|
266
|
-
WebAssembly or prebuilt binaries: no native build, no Python or compiler needed.
|
|
267
|
-
|
|
268
|
-
|
|
271
|
+
WebAssembly or prebuilt binaries: no native build, no Python or compiler needed. Mason's CI and the `mason-data`
|
|
272
|
+
image run Node 26.
|
|
273
|
+
- On Node 24 Mason runs, but a bug in Node 24's V8 (nodejs/node#66366, fixed in Node 26) can now and then abort it
|
|
274
|
+
with `Check failed: jit_page_->allocations_.erase(addr) == 1`, as a process exits or when the catalog's worker stops
|
|
275
|
+
after idling. `mason start` starts a run that died after it was ready again (not under `--attach`), and `mason status`
|
|
276
|
+
shows the restart. To avoid it, use Node 26, or a 22.x release from 22.13.
|
|
277
|
+
- Memory: by default the engine may use half the container's memory limit, else of the machine's RAM, or what leaves
|
|
278
|
+
2.5 GiB beside it when that is less, never under 128 MiB (`MASON_DUCKDB_MEMORY=4GB` sets it; `mason doctor` says
|
|
279
|
+
it). Under 3 GiB, with it unset, Mason starts and warns that the system may stop it. Sizing a server:
|
|
269
280
|
[deploy/mason-data/README.md#memory](https://github.com/embeddable-hq/mason-data/blob/main/deploy/mason-data/README.md#memory).
|
|
270
281
|
- Postgres 13 or later. Each table needs a primary key, or a `key:` in the sources file (polling only).
|
|
271
282
|
|
package/dist/src/api/error.js
CHANGED
|
@@ -19,3 +19,7 @@ export class ApiError extends Error {
|
|
|
19
19
|
/** The code of a DISABLED `mason user`'s refused login or session (src/duck/users.ts): the listener answers it 28000, as
|
|
20
20
|
* Postgres answers a role without LOGIN, never 28P01 — its password was right. */
|
|
21
21
|
export const USER_DISABLED = "user_disabled";
|
|
22
|
+
/** The code of a `mason user`'s login or session refused for its ADDRESS (src/duck/users.ts `from`): the listener answers
|
|
23
|
+
* it 28000 before any password is checked, in the same words whether the user exists or not, as Postgres answers a
|
|
24
|
+
* connection no pg_hba.conf line takes. */
|
|
25
|
+
export const ADDRESS_NOT_ALLOWED = "address_not_allowed";
|
|
@@ -7,18 +7,65 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Messages in: `{ id, op: "init", statements, optional? }` (start PGlite and run the DDL; the reply names the refused
|
|
9
9
|
* statement, or the optional ones refused),
|
|
10
|
-
* `{ id, op: "raw", bytes }` (frontend protocol bytes in, backend bytes out
|
|
10
|
+
* `{ id, op: "raw", bytes, at? }` (frontend protocol bytes in, backend bytes out; `at`: the instant the FIRST message
|
|
11
|
+
* runs at, in microseconds since the epoch, `pinnedClock`), `{ id, op: "query", sql }` (rows as
|
|
11
12
|
* objects, for the server's own lookups and the tests), `{ id, op: "close" }`. Every reply carries the id:
|
|
12
13
|
* `{ id, ok: true, bytes | rows }` or `{ id, ok: false, message }`.
|
|
13
14
|
*/
|
|
14
15
|
import { parentPort } from "node:worker_threads";
|
|
15
16
|
import { PGlite } from "@electric-sql/pglite";
|
|
16
17
|
import { citext } from "@electric-sql/pglite/contrib/citext";
|
|
18
|
+
import { instantWallMs, nowMicros } from "../../lib/instant.js";
|
|
17
19
|
const port = parentPort;
|
|
18
20
|
if (port === null)
|
|
19
21
|
throw new Error("catalog-worker.ts runs on a worker thread only");
|
|
20
22
|
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
21
23
|
let db = null;
|
|
24
|
+
/**
|
|
25
|
+
* THE CATALOG'S CLOCK IS THE SESSION'S TRANSACTION'S (GitHub issue 253, review r3 of PR 333): `bytes`' first message
|
|
26
|
+
* (`asSession`'s, which begins the request's transaction) runs with the wall clock PGlite reads held at `at`, the
|
|
27
|
+
* session's instant, and the rest with the real one. Postgres's transaction start is the instant its first message
|
|
28
|
+
* arrived (`xactStartTimestamp`), and every reader of it — now(), CURRENT_TIMESTAMP and the other keywords, a
|
|
29
|
+
* relative word (`'now'`, `'today'`) however it is spelt, computed or bound — reads that one value, so the catalog's
|
|
30
|
+
* transaction instant IS the session's and no statement is rewritten for it. `statement_timestamp()` and
|
|
31
|
+
* `clock_timestamp()` read the real clock, as on Postgres. PGlite's wall clock is `Date.now()`
|
|
32
|
+
* (emscripten's `clock_time_get(CLOCK_REALTIME)`), on this thread alone; the requests are one at a time.
|
|
33
|
+
* The instant is MICROSECONDS, as Postgres's (src/lib/instant.ts, GitHub issues 372, 363): `at` is handed to PGlite
|
|
34
|
+
* as the value it reads back as exactly that microsecond (`instantWallMs`), and the real clock after it is this
|
|
35
|
+
* thread's microsecond clock (`nowMicros`), floored at `at`: the two threads' clocks may disagree inside a
|
|
36
|
+
* millisecond, and `statement_timestamp()` must never read earlier than the transaction's start, as on Postgres.
|
|
37
|
+
*/
|
|
38
|
+
async function pinnedClock(instance, bytes, at) {
|
|
39
|
+
// a frame: its type byte, then its length (its own four bytes included)
|
|
40
|
+
const first = 1 + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1);
|
|
41
|
+
const wall = Date.now;
|
|
42
|
+
const pinned = instantWallMs(at);
|
|
43
|
+
let head, tail;
|
|
44
|
+
try {
|
|
45
|
+
Date.now = () => pinned;
|
|
46
|
+
head = new Uint8Array(await instance.execProtocolRaw(bytes.subarray(0, first))); // a copy: PGlite may reuse its buffer
|
|
47
|
+
Date.now = () => instantWallMs(Math.max(at, nowMicros(wall)));
|
|
48
|
+
tail = await instance.execProtocolRaw(bytes.subarray(first));
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
Date.now = wall;
|
|
52
|
+
}
|
|
53
|
+
const out = new Uint8Array(head.length + tail.length);
|
|
54
|
+
out.set(head, 0);
|
|
55
|
+
out.set(tail, head.length);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** a request run at no instant of a session's (the DDL, a parameter's input function): the real clock, in microseconds */
|
|
59
|
+
async function microsClock(instance, bytes) {
|
|
60
|
+
const wall = Date.now;
|
|
61
|
+
try {
|
|
62
|
+
Date.now = () => instantWallMs(nowMicros(wall));
|
|
63
|
+
return await instance.execProtocolRaw(bytes);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
Date.now = wall;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
22
69
|
port.on("message", (m) => {
|
|
23
70
|
void (async () => {
|
|
24
71
|
try {
|
|
@@ -63,7 +110,7 @@ port.on("message", (m) => {
|
|
|
63
110
|
case "raw": {
|
|
64
111
|
if (db === null)
|
|
65
112
|
throw new Error("the catalog is not started");
|
|
66
|
-
const bytes = await db.
|
|
113
|
+
const bytes = m.at === undefined ? await microsClock(db, m.bytes) : await pinnedClock(db, m.bytes, m.at);
|
|
67
114
|
const copy = new Uint8Array(bytes); // PGlite may reuse its buffer: a copy of our own is transferred
|
|
68
115
|
port.postMessage({ id: m.id, ok: true, bytes: copy }, [copy.buffer]);
|
|
69
116
|
return;
|
|
@@ -38,7 +38,7 @@ import { runnable } from "../../lib/runnable.js";
|
|
|
38
38
|
import { NO_ROLE_CATALOG } from "../../duck/grants.js";
|
|
39
39
|
import { renderWhere, tableSql } from "../../duck/row-filter.js";
|
|
40
40
|
import { cstr, frame, splitFrames } from "./protocol.js";
|
|
41
|
-
import { PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
|
|
41
|
+
import { HELD_ROW_INPUT, HELD_TYPE_INPUT, madeTypeName, PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
|
|
42
42
|
/** Ten minutes: long enough to outlive a tool's startup burst and a person's pause between two queries. */
|
|
43
43
|
const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
44
44
|
/** A catalog statement's deadline (`MASON_PG_CATALOG_TIMEOUT_MS`): a tool's introspection answers in milliseconds; a
|
|
@@ -117,12 +117,19 @@ export function catalogDdl(schema) {
|
|
|
117
117
|
for (const name of [...new Set(schema.tables.map((t) => t.schema).filter((x) => x !== undefined && x !== "public"))]) {
|
|
118
118
|
statements.push(`CREATE SCHEMA IF NOT EXISTS ${ident(name)};`);
|
|
119
119
|
}
|
|
120
|
+
// THE SOURCE'S COLLATIONS (src/duck/collation.ts), before the tables: each the catalog lacks, declared under its name with
|
|
121
|
+
// the source's provider, locale and determinism (a locale PGlite cannot open declared on its root locale: what it
|
|
122
|
+
// MEANS is the source's row of its name, read by the resolver, never this one's), so a column's COLLATE and a
|
|
123
|
+
// statement's resolve as on the source. In pg_catalog, public, a table's schema, or a column's collation's schema
|
|
124
|
+
statements.push(...collationDdl(schema));
|
|
120
125
|
// a source enum a column has: the type, its labels in their declared order, before the tables —
|
|
121
126
|
// spelled as the source's format_type spells it (`mood`, `other.level`, `"Odd"`), its schema made first
|
|
122
127
|
const enums = new Map();
|
|
128
|
+
// one declaration a type, the FIRST column's: the synced tables come first, and src/duck/sync.ts hands a generated
|
|
129
|
+
// table's column its synced type's labels (one source of an enum's labels), so no later column's can replace them
|
|
123
130
|
for (const t of schema.tables)
|
|
124
131
|
for (const c of t.columns)
|
|
125
|
-
if (c.enumLabels !== undefined && c.spelled !== undefined)
|
|
132
|
+
if (c.enumLabels !== undefined && c.spelled !== undefined && !enums.has(c.spelled.replace(/\[\]$/, "")))
|
|
126
133
|
enums.set(c.spelled.replace(/\[\]$/, ""), c.enumLabels);
|
|
127
134
|
for (const [type, labels] of enums) {
|
|
128
135
|
const qualifier = typeNameParts(type).schema;
|
|
@@ -130,10 +137,22 @@ export function catalogDdl(schema) {
|
|
|
130
137
|
statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
|
|
131
138
|
statements.push(`CREATE TYPE ${type} AS ENUM (${labels.map(literal).join(", ")});`);
|
|
132
139
|
}
|
|
140
|
+
// a type a database made, held as its text: its name, the text's input and output, and nothing over it
|
|
141
|
+
const made = new Map(schema.tables.flatMap((t) => t.columns.filter((c) => c.made !== undefined).map((c) => [madeTypeName(c.spelled), c.made])));
|
|
142
|
+
[...made].forEach(([type, kind], i) => {
|
|
143
|
+
const input = kind === "row" ? HELD_ROW_INPUT : HELD_TYPE_INPUT;
|
|
144
|
+
const qualifier = typeNameParts(type).schema;
|
|
145
|
+
if (qualifier !== null && qualifier !== '"public"' && qualifier !== "public")
|
|
146
|
+
statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
|
|
147
|
+
statements.push(`CREATE TYPE ${type};`, `CREATE FUNCTION pg_catalog.${input}${i}(cstring) RETURNS ${type} LANGUAGE internal IMMUTABLE STRICT AS 'textin';`, `CREATE FUNCTION pg_catalog.mason_held_out${i}(${type}) RETURNS cstring LANGUAGE internal IMMUTABLE STRICT AS 'textout';`, `CREATE TYPE ${type} (INPUT = pg_catalog.${input}${i}, OUTPUT = pg_catalog.mason_held_out${i}, LIKE = text);`);
|
|
148
|
+
});
|
|
133
149
|
for (const table of schema.tables) {
|
|
134
150
|
const lines = [];
|
|
135
|
-
for (const c of table.columns)
|
|
136
|
-
|
|
151
|
+
for (const c of table.columns) {
|
|
152
|
+
// a column's own collation: the source's, so Postgres's analysis orders it as the source does
|
|
153
|
+
const collate = c.collation !== undefined ? ` COLLATE ${ident(c.collation.schema)}.${ident(c.collation.name)}` : "";
|
|
154
|
+
lines.push(`${ident(c.name)} ${c.made !== undefined ? `${madeTypeName(c.spelled)}${c.spelled.endsWith("[]") ? "[]" : ""}` : c.spelled ?? PG_API_TYPE_SPELLING[c.type]}${collate}${c.notNull === true ? " NOT NULL" : ""}`);
|
|
155
|
+
}
|
|
137
156
|
if (table.primaryKey.length > 0)
|
|
138
157
|
lines.push(`PRIMARY KEY (${table.primaryKey.map(ident).join(", ")})`);
|
|
139
158
|
statements.push(`CREATE TABLE ${qualified(table)} (\n ${lines.join(",\n ")}\n);`);
|
|
@@ -170,6 +189,38 @@ export function catalogDdl(schema) {
|
|
|
170
189
|
}
|
|
171
190
|
return [...statements, ...constraints, ...sourceTypeNames(schema)];
|
|
172
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* THE SOURCE'S COLLATIONS THE CATALOG LACKS, declared (one DO block: ~800 ICU collations in tens of milliseconds): each
|
|
194
|
+
* under its schema and name, by the source's provider, locale and determinism, else — a locale PGlite's ICU or libc
|
|
195
|
+
* cannot open — the same name on the root locale (ICU), C (libc) or C (builtin), which its analysis treats alike: the
|
|
196
|
+
* resolver reads what a collation means from the SOURCE's row of its name (src/duck/collation.ts), never this one. Only
|
|
197
|
+
* where a name can be met: pg_catalog, public, a table's schema, and a synced column's collation's own schema.
|
|
198
|
+
*/
|
|
199
|
+
export function collationDdl(schema) {
|
|
200
|
+
const used = schema.tables.flatMap((t) => t.columns.flatMap((c) => c.collation !== undefined ? [c.collation] : []));
|
|
201
|
+
const schemas = new Set(["pg_catalog", "public", ...schema.tables.map((t) => t.schema ?? "public"), ...used.map((c) => c.schema)]);
|
|
202
|
+
const declared = (schema.collations ?? []).filter((c) => c.provider !== "d" && schemas.has(c.schema));
|
|
203
|
+
if (declared.length === 0)
|
|
204
|
+
return [];
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const s of new Set(declared.map((c) => c.schema)))
|
|
207
|
+
if (s !== "pg_catalog" && s !== "public")
|
|
208
|
+
out.push(`CREATE SCHEMA IF NOT EXISTS ${ident(s)};`);
|
|
209
|
+
// ONE INSERT of the rows, never a CREATE COLLATION each: a source lists about 1,600 (every ICU locale and every libc
|
|
210
|
+
// one the OS has), and a CREATE each took the catalog's start past its deadline (about 18 ms each, measured; the
|
|
211
|
+
// INSERT, 260 ms for 1,600). The catalog only ANALYSES under them: what a statement orders by is the synced data's, so
|
|
212
|
+
// a locale PGlite has no data for is never opened. A name the catalog has already (`C`, `und-x-icu`) is the source's
|
|
213
|
+
// too; the OIDs are past any the catalog makes, so none collides
|
|
214
|
+
const rows = declared.map((c) => ({ schema: c.schema, name: c.name, provider: c.provider, collate: c.collate, locale: c.locale, deterministic: c.deterministic }));
|
|
215
|
+
out.push(`INSERT INTO pg_catalog.pg_collation (oid, collname, collnamespace, collowner, collprovider, collisdeterministic, collencoding, collcollate, collctype, colllocale)
|
|
216
|
+
SELECT (greatest((SELECT max(oid)::bigint FROM pg_catalog.pg_collation), 3000000000) + row_number() OVER ())::oid, c.name, n.oid, 10, c.provider, c.deterministic,
|
|
217
|
+
CASE WHEN c.provider = 'c' THEN 6 ELSE -1 END, CASE WHEN c.provider = 'c' THEN c."collate" END, CASE WHEN c.provider = 'c' THEN c."collate" END,
|
|
218
|
+
CASE WHEN c.provider <> 'c' THEN coalesce(c.locale, c."collate") END
|
|
219
|
+
FROM json_to_recordset(${literal(JSON.stringify(rows))}) AS c(schema text, name text, provider "char", "collate" text, locale text, deterministic boolean)
|
|
220
|
+
JOIN pg_catalog.pg_namespace n ON n.nspname = c.schema
|
|
221
|
+
WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_collation x WHERE x.collnamespace = n.oid AND x.collname = c.name AND x.collencoding IN (-1, 6));`);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
173
224
|
/**
|
|
174
225
|
* THE NAME OF A TYPE BY THE OID THE WIRE SENDS FOR IT. A column of a type the source made (an enum, a citext) is
|
|
175
226
|
* sent with the SOURCE's OID (src/duck/plan.ts `userTypes`: what the source's own clients cached), which in this
|
|
@@ -190,14 +241,19 @@ export function sourceTypeNames(schema) {
|
|
|
190
241
|
if (c.typeOid === undefined || c.spelled === undefined)
|
|
191
242
|
continue;
|
|
192
243
|
// an array column's own OID is its array type's; an element's array type is the element's spelling with []
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
244
|
+
const spelled = c.made !== undefined ? `${madeTypeName(c.spelled)}${c.spelled.endsWith("[]") ? "[]" : ""}` : c.spelled;
|
|
245
|
+
names.set(c.typeOid, spelled);
|
|
246
|
+
if (c.typeArrayOid !== undefined && !spelled.endsWith("[]"))
|
|
247
|
+
names.set(c.typeArrayOid, `${spelled}[]`);
|
|
196
248
|
}
|
|
197
249
|
}
|
|
198
250
|
// with no type the source made, every OID the wire sends is Postgres's: the wire's name is format_type's
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
// (format_type is declared all the same: a catalog statement's every unqualified call of it is spliced to this one,
|
|
252
|
+
// server.ts userSpliced, since the session's search_path puts pg_catalog first)
|
|
253
|
+
if (names.size === 0) {
|
|
254
|
+
return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`,
|
|
255
|
+
`CREATE OR REPLACE FUNCTION public.format_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
|
|
256
|
+
}
|
|
201
257
|
const values = [...names].map(([oid, spelled]) => `(${oid}::oid, ${literal(spelled)})`).join(", ");
|
|
202
258
|
return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
|
|
203
259
|
SELECT coalesce((SELECT pg_catalog.format_type(pg_catalog.to_regtype(v.spelled), $2) FROM (VALUES ${values}) AS v(source, spelled) WHERE v.source = $1),
|
|
@@ -374,11 +430,77 @@ export function unwrapRole(frames) {
|
|
|
374
430
|
const end = n >= 2 && inner[n - 1].type === "Z" && inner[n - 2].type === "C" ? n - 2 : n;
|
|
375
431
|
return { frames: inner.slice(0, end), setRoleRefused: null };
|
|
376
432
|
}
|
|
377
|
-
/** the
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
|
|
433
|
+
/** the prefix of the placeholders a session's request rides in (`asSession`): named anew by each process, so no name a
|
|
434
|
+
* tool writes is one of them — `current_setting('mason.session')` answered the carrier's JSON where Postgres (and
|
|
435
|
+
* `SHOW`, and the same beside a copied table) answers 42704 (a review finding of #46). A placeholder stays defined once
|
|
436
|
+
* SET, so the name, not a RESET, is what keeps it out of reach. */
|
|
437
|
+
const CARRIER = `mason_${randomBytes(8).toString("hex")}`;
|
|
438
|
+
/** the placeholder a session's settings ride in, for one request of its (`asSession`) */
|
|
439
|
+
export const SESSION_SETTINGS = `${CARRIER}.session`;
|
|
440
|
+
/** the catalog's own state, which its start sets and every request of a session leaves it in: UTC, `public` first (Mason's
|
|
441
|
+
* own reads that name no schema find its overrides), ISO with month-day-year */
|
|
442
|
+
export const CATALOG_BASE = { timeZone: "UTC", searchPath: "public, pg_catalog", dateStyle: "ISO, MDY" };
|
|
443
|
+
/**
|
|
444
|
+
* A search_path as SET writes it, read as Postgres's `SplitIdentifierString` reads the parameter's text: a list split at
|
|
445
|
+
* the commas outside double quotes, an unquoted name folded to lower case, a quoted one as written (`"$user"` kept); an
|
|
446
|
+
* empty list is `''`. Every name is written quoted, so no text of a session's reaches the SQL but as an identifier.
|
|
447
|
+
*/
|
|
448
|
+
export function searchPathSql(path) {
|
|
449
|
+
const names = [];
|
|
450
|
+
let i = 0;
|
|
451
|
+
while (i < path.length) {
|
|
452
|
+
while (i < path.length && /[\s,]/.test(path[i]))
|
|
453
|
+
i++;
|
|
454
|
+
if (i >= path.length)
|
|
455
|
+
break;
|
|
456
|
+
let name = "";
|
|
457
|
+
if (path[i] === '"') {
|
|
458
|
+
i++;
|
|
459
|
+
while (i < path.length) {
|
|
460
|
+
if (path[i] === '"') {
|
|
461
|
+
if (path[i + 1] === '"') {
|
|
462
|
+
name += '"';
|
|
463
|
+
i += 2;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
i++;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
name += path[i++];
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
while (i < path.length && path[i] !== ",")
|
|
474
|
+
name += path[i++];
|
|
475
|
+
name = name.trim().toLowerCase();
|
|
476
|
+
}
|
|
477
|
+
while (i < path.length && path[i] !== ",")
|
|
478
|
+
i++;
|
|
479
|
+
names.push(name);
|
|
480
|
+
}
|
|
481
|
+
return names.length === 0 ? "''" : names.map((n) => `"${n.replaceAll('"', '""')}"`).join(", ");
|
|
482
|
+
}
|
|
483
|
+
/** THE CATALOG'S ONE APPLIER of a session's state: its SETs, three statements (`local`: for the transaction they run
|
|
484
|
+
* in, as a lookup of Mason's sets them inside one batch) */
|
|
485
|
+
export function sessionSetSql(state, local = false) {
|
|
486
|
+
const set = local ? "SET LOCAL" : "SET";
|
|
487
|
+
return `${set} search_path = ${searchPathSql(state.searchPath)}; ${set} TimeZone = ${quoteLiteral(state.timeZone)}; ${set} DateStyle = ${quoteLiteral(state.dateStyle)}`;
|
|
488
|
+
}
|
|
489
|
+
/** the catalog's base set back (`CATALOG_BASE`), after a request that applied a session's state */
|
|
490
|
+
export const CATALOG_BASE_SQL = sessionSetSql(CATALOG_BASE);
|
|
491
|
+
/** how many CommandCompletes `sessionSetSql` answers: one a SET */
|
|
492
|
+
const SESSION_SETS = 3;
|
|
493
|
+
/**
|
|
494
|
+
* LATEST(x ORDER BY k…), THE AGGREGATE MASON ADDS: the x of the row its ORDER BY sorts last, by Postgres's sort rules.
|
|
495
|
+
* Declared in the catalog so that Postgres's analysis types and names it (an AGGREF of its argument's type), and by the
|
|
496
|
+
* oracle's real Postgres from this ONE definition, so the two compare like with like. Its transition keeps the latest
|
|
497
|
+
* row's value, a NULL included (not STRICT); over no rows it is NULL. In pg_catalog, so no search_path hides it. The
|
|
498
|
+
* engine never runs this: src/duck/rewrite.ts `latest` rewrites it, and refuses it without an ORDER BY (`latest-order`).
|
|
499
|
+
*/
|
|
500
|
+
export const LATEST_SQL = [
|
|
501
|
+
"CREATE OR REPLACE FUNCTION pg_catalog.mason_latest_step(anyelement, anyelement) RETURNS anyelement LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT $2 $$",
|
|
502
|
+
"CREATE OR REPLACE AGGREGATE pg_catalog.latest(anyelement) (SFUNC = pg_catalog.mason_latest_step, STYPE = anyelement)",
|
|
503
|
+
];
|
|
382
504
|
/**
|
|
383
505
|
* THE CATALOG'S `current_setting(name[, missing_ok])` IS THE SESSION'S (GitHub issue 40: after `SET TimeZone` it answered
|
|
384
506
|
* the catalog's UTC, where `SHOW TimeZone` answered the session's): the value the session holds under that name, from
|
|
@@ -397,30 +519,40 @@ export function currentSettingSql() {
|
|
|
397
519
|
];
|
|
398
520
|
}
|
|
399
521
|
/**
|
|
400
|
-
* A SESSION'S CATALOG REQUEST WITH ITS SETTINGS
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
522
|
+
* A SESSION'S CATALOG REQUEST UNDER ITS STATE AND WITH ITS SETTINGS, IN A TRANSACTION OF ITS OWN: `BEGIN`, the session's
|
|
523
|
+
* TimeZone, search_path and DateStyle (`sessionSetSql`) and `SET <SESSION_SETTINGS> = '<its settings as JSON>'` before
|
|
524
|
+
* the request's frames; `ROLLBACK` and the catalog's base (`CATALOG_BASE`) set back after, in ONE message, as `asRole`
|
|
525
|
+
* wraps a role (around it, when the session has one) — the catalog runs one request at a time and a message whole, so
|
|
526
|
+
* no other session's request reads these, and none of this session's runs without them. A Sync is added before the
|
|
527
|
+
* restore where the request has none (ignore_till_sync: a setting left set would be every session's). The `BEGIN`'s
|
|
528
|
+
* message is run at the session's transaction instant (`PgCatalog.run`'s `at`, catalog-worker.ts `pinnedClock`), so
|
|
529
|
+
* the transaction it begins starts at that instant and every reader of the transaction's start in the request —
|
|
530
|
+
* now(), the clock keywords, a relative word however it reaches a date or time — reads the session's (GitHub issue
|
|
531
|
+
* 253, review r3 of PR 333). The ROLLBACK undoes anything the request set, whether it succeeded or failed.
|
|
532
|
+
* `state` and `settings` are the listener's (`sessionState`, `sessionSetting`), never read here. The search_path is the
|
|
533
|
+
* session's as written, pg_catalog first unless it lists it, as on Postgres: the overrides of `version()`,
|
|
534
|
+
* `current_setting` and `format_type` are reached by the splice of every call of them (server.ts userSpliced), never by
|
|
535
|
+
* the order of the path.
|
|
407
536
|
*/
|
|
408
|
-
export function asSession(message, settings) {
|
|
537
|
+
export function asSession(message, state, settings) {
|
|
409
538
|
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
410
539
|
const frames = splitFrames(own);
|
|
411
540
|
const synced = frames.some((f) => f.type === "S") || frames.at(-1)?.type === "Q";
|
|
412
541
|
const json = JSON.stringify(Object.fromEntries(settings));
|
|
413
|
-
|
|
542
|
+
const before = `BEGIN; ${sessionSetSql(state)}; SET ${SESSION_SETTINGS} = ${quoteLiteral(json)}`;
|
|
543
|
+
const after = `ROLLBACK; RESET ${SESSION_SETTINGS}; ${CATALOG_BASE_SQL}`;
|
|
544
|
+
return Buffer.concat([frame("Q", cstr(before)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr(after))]);
|
|
414
545
|
}
|
|
415
|
-
/** The reply to an `asSession` message, less the
|
|
416
|
-
* ReadyForQuery first
|
|
546
|
+
/** The reply to an `asSession` message, less the applier's and the restore's own replies (a CommandComplete a SET and a
|
|
547
|
+
* ReadyForQuery, first and last). A SET the catalog refused is a Mason bug, said. */
|
|
417
548
|
export function unwrapSession(frames) {
|
|
418
|
-
const
|
|
549
|
+
const sets = SESSION_SETS + 2;
|
|
550
|
+
const run = (from) => frames.slice(from, from + sets).every((f) => f.type === "C") && frames[from + sets]?.type === "Z";
|
|
419
551
|
const n = frames.length;
|
|
420
|
-
if (
|
|
552
|
+
if (n < 2 * (sets + 1) || !run(0) || !run(n - sets - 1)) {
|
|
421
553
|
throw new Error(`the catalog Postgres did not take the session's settings around a request (${frames.map((f) => f.type).join(", ")}): report it`);
|
|
422
554
|
}
|
|
423
|
-
return frames.slice(
|
|
555
|
+
return frames.slice(sets + 1, n - sets - 1);
|
|
424
556
|
}
|
|
425
557
|
/**
|
|
426
558
|
* WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
|
|
@@ -529,12 +661,14 @@ export class PgCatalog {
|
|
|
529
661
|
return this.#db !== null && this.#db.alive;
|
|
530
662
|
}
|
|
531
663
|
/** Runs raw frontend protocol bytes — one message or several (Parse, Bind, Describe, Execute, Sync in one buffer
|
|
532
|
-
* works) — and returns the backend's raw reply, starting the instance when needed.
|
|
533
|
-
|
|
664
|
+
* works) — and returns the backend's raw reply, starting the instance when needed. `at`: the instant the first
|
|
665
|
+
* message runs at, the start of the transaction it begins (`asSession`; catalog-worker.ts `pinnedClock`), in
|
|
666
|
+
* MICROSECONDS since the epoch as Postgres's (src/lib/instant.ts), which the catalog reads back exactly. */
|
|
667
|
+
run(message, at) {
|
|
534
668
|
return this.#enqueue(async () => {
|
|
535
669
|
const db = await this.#ensureStarted();
|
|
536
670
|
const copy = new Uint8Array(message);
|
|
537
|
-
const reply = await this.#ask(db, { op: "raw", bytes: copy }, "a statement", [copy.buffer]);
|
|
671
|
+
const reply = await this.#ask(db, { op: "raw", bytes: copy, ...(at !== undefined ? { at } : {}) }, "a statement", [copy.buffer]);
|
|
538
672
|
this.#touch();
|
|
539
673
|
return reply.bytes ?? new Uint8Array(0);
|
|
540
674
|
});
|
|
@@ -613,7 +747,7 @@ export class PgCatalog {
|
|
|
613
747
|
const t0 = Date.now();
|
|
614
748
|
const db = new CatalogWorker();
|
|
615
749
|
try {
|
|
616
|
-
const started = await this.#ask(db, { op: "init", statements: [
|
|
750
|
+
const started = await this.#ask(db, { op: "init", statements: [CATALOG_BASE_SQL, ...catalogDdl(schema), ...catalogRoles(schema, roles), ...this.#setup],
|
|
617
751
|
optional: [...catalogDefaults(schema), ...catalogViews(schema, roles), ...this.#policies] }, "its start");
|
|
618
752
|
// each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
|
|
619
753
|
for (const r of started.refused ?? []) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import { parsePort } from "../../lib/port.js";
|
|
17
17
|
import { plainListener } from "./plain.js";
|
|
18
|
+
import { parseProxyFrom } from "./proxy.js";
|
|
18
19
|
export const DEFAULT_PG_PORT = 5433;
|
|
19
20
|
/** The listener's address from `env`, or the problem with its port (`parsePort`'s words). */
|
|
20
21
|
export function pgListenerAddress(env, apiHost) {
|
|
@@ -24,8 +25,8 @@ export function pgListenerAddress(env, apiHost) {
|
|
|
24
25
|
return { host: env.MASON_PG_HOST || apiHost, port: port.port };
|
|
25
26
|
}
|
|
26
27
|
/** 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
|
|
28
|
-
* file, so a start can be refused before anything is made. */
|
|
28
|
+
* `MASON_PG_TLS_KEY`, `MASON_PG_PLAIN`, `MASON_PG_PROXY_FROM`), or why it cannot: a bad port, half a certificate, plain
|
|
29
|
+
* off loopback, a proxy's address that is no address. Reads no file, so a start can be refused before anything is made. */
|
|
29
30
|
export function pgListenerSettings(env, apiHost) {
|
|
30
31
|
const address = pgListenerAddress(env, apiHost);
|
|
31
32
|
if ("problem" in address)
|
|
@@ -36,7 +37,14 @@ export function pgListenerSettings(env, apiHost) {
|
|
|
36
37
|
const plain = plainListener(address.host, env.MASON_PG_PLAIN, Boolean(cert && key));
|
|
37
38
|
if (plain !== null)
|
|
38
39
|
return plain;
|
|
39
|
-
|
|
40
|
+
const proxyFrom = parseProxyFrom(env.MASON_PG_PROXY_FROM);
|
|
41
|
+
if (proxyFrom !== null && "problem" in proxyFrom) {
|
|
42
|
+
return { problem: proxyFrom.problem, kind: "proxy_from",
|
|
43
|
+
why: "a PROXY header names the client every statement is recorded as, so it is believed only from the addresses this names",
|
|
44
|
+
fix: "Name the addresses of your TCP proxy or load balancer alone, comma-separated (MASON_PG_PROXY_FROM=10.0.0.0/16,10.1.0.0/16), " +
|
|
45
|
+
"or unset it to read no PROXY header — docs/POSTGRES-API.md \"Behind a TCP proxy\"" };
|
|
46
|
+
}
|
|
47
|
+
return { ...address, tls: cert && key ? { cert, key } : null, ...(proxyFrom !== null ? { proxyFrom } : {}) };
|
|
40
48
|
}
|
|
41
49
|
/** The certificate and key the settings name, read; or why not. */
|
|
42
50
|
export function readPgTls(files) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE POSTGRES API BEHIND A TCP PROXY (`MASON_PG_PROXY_FROM`; docs/POSTGRES-API.md "Behind a TCP proxy"). Behind a TCP
|
|
3
|
+
* load balancer or proxy (an AWS NLB, HAProxy, an edge of one's own) every connection's peer is the proxy, so the
|
|
4
|
+
* request log could not say who sent what. A proxy that speaks the PROXY protocol v2 (haproxy's proxy-protocol.txt,
|
|
5
|
+
* section 2.2) begins each connection with a binary header naming the client it carries. A header is a CLAIM about
|
|
6
|
+
* where a connection came from, so it is believed only from an address the operator named:
|
|
7
|
+
* - a connection from a named address MUST begin with a valid v2 header, or it is refused;
|
|
8
|
+
* - a connection from any other address is read as before, and a PROXY header from it is refused, never believed;
|
|
9
|
+
* - unset, nothing is named and nothing changes.
|
|
10
|
+
* The header is read whole before anything else on the connection is looked at (an SSLRequest, a direct TLS start, a
|
|
11
|
+
* startup packet, a CancelRequest), and bounded in size (`MAX_PROXY_BODY`) and in time (`PROXY_HEADER_MS`).
|
|
12
|
+
*
|
|
13
|
+
* THE CLIENT'S ADDRESS HAS ONE READER (`clientAddress`): the source a named proxy's header carries, else the socket's
|
|
14
|
+
* peer, a v4-mapped IPv6 address shown as IPv4 (`shownAddress`). The request log's `client_addr` and the session's log
|
|
15
|
+
* line read it, never the socket.
|
|
16
|
+
*/
|
|
17
|
+
import { ALL_END, cidrOf, covers, inRanges, ipv6Text, rangeOf, shownAddress, V4_END, V4_START } from "../../lib/cidr.js";
|
|
18
|
+
export { shownAddress };
|
|
19
|
+
/** the v2 header's first 12 bytes (section 2.2) */
|
|
20
|
+
export const PROXY_V2_SIGNATURE = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a]);
|
|
21
|
+
/** how a v1 (text) header begins */
|
|
22
|
+
const PROXY_V1_START = Buffer.from("PROXY ", "latin1");
|
|
23
|
+
/** The most a header may carry past its 16 fixed bytes: the largest address block of the spec (two UNIX paths, 216
|
|
24
|
+
* bytes), which holds an IPv6 pair (36) and 180 bytes of TLVs. A header that says more is refused. */
|
|
25
|
+
export const MAX_PROXY_BODY = 216;
|
|
26
|
+
/** How long a named proxy has to send its whole header once it has connected. */
|
|
27
|
+
export const PROXY_HEADER_MS = 5_000;
|
|
28
|
+
/** the setting, as every message names it */
|
|
29
|
+
export const PROXY_SETTING = "MASON_PG_PROXY_FROM";
|
|
30
|
+
/**
|
|
31
|
+
* The PROXY v2 header at the start of `b`, read as section 2.2 says: the signature, version 2, the LOCAL or PROXY
|
|
32
|
+
* command, TCP over IPv4 (0x11) or IPv6 (0x21) for a PROXY command, its length at most MAX_PROXY_BODY, and its TLVs
|
|
33
|
+
* skipped, each held inside the block. Asked again as bytes arrive: a header split across reads is `more` until it is
|
|
34
|
+
* whole, and a wrong byte is refused as soon as it arrives. A v1 (text) header is refused by name.
|
|
35
|
+
*/
|
|
36
|
+
export function readProxyHeader(b) {
|
|
37
|
+
for (let i = 0; i < Math.min(b.length, PROXY_V2_SIGNATURE.length); i++) {
|
|
38
|
+
if (b[i] === PROXY_V2_SIGNATURE[i])
|
|
39
|
+
continue;
|
|
40
|
+
const v1 = Math.min(b.length, PROXY_V1_START.length);
|
|
41
|
+
if (b.subarray(0, v1).every((c, j) => c === PROXY_V1_START[j])) {
|
|
42
|
+
return v1 < PROXY_V1_START.length ? { more: true } : { refused: "a PROXY protocol v1 (text) header: Mason reads version 2 alone (HAProxy's send-proxy-v2)" };
|
|
43
|
+
}
|
|
44
|
+
return { refused: "no PROXY protocol v2 header: its first 12 bytes are not the signature" };
|
|
45
|
+
}
|
|
46
|
+
if (b.length < 16)
|
|
47
|
+
return { more: true };
|
|
48
|
+
const version = b[12] >> 4, command = b[12] & 0x0f;
|
|
49
|
+
if (version !== 2)
|
|
50
|
+
return { refused: `a PROXY header of version ${version}: Mason reads version 2` };
|
|
51
|
+
if (command > 1)
|
|
52
|
+
return { refused: `a PROXY header with the command 0x${command.toString(16)}: only LOCAL (0x0) and PROXY (0x1) are defined` };
|
|
53
|
+
const family = b[13], length = (b[14] << 8) | b[15];
|
|
54
|
+
if (length > MAX_PROXY_BODY)
|
|
55
|
+
return { refused: `a PROXY header of ${length} bytes past its first 16: Mason reads at most ${MAX_PROXY_BODY}` };
|
|
56
|
+
if (b.length < 16 + length)
|
|
57
|
+
return { more: true };
|
|
58
|
+
// LOCAL: the proxy's own connection (a health check); its address block is discarded, as the spec says
|
|
59
|
+
if (command === 0)
|
|
60
|
+
return { header: { command: "LOCAL", source: null }, length: 16 + length };
|
|
61
|
+
const body = b.subarray(16, 16 + length);
|
|
62
|
+
const addresses = family === 0x11 ? 12 : family === 0x21 ? 36 : null;
|
|
63
|
+
if (addresses === null) {
|
|
64
|
+
return { refused: `a PROXY header for family and transport 0x${family.toString(16).padStart(2, "0")}: Mason reads TCP over IPv4 (0x11) or IPv6 (0x21), and an UNSPEC one with LOCAL alone` };
|
|
65
|
+
}
|
|
66
|
+
if (length < addresses)
|
|
67
|
+
return { refused: `a PROXY header of ${length} bytes past its first 16, too few for its addresses (${addresses})` };
|
|
68
|
+
const source = family === 0x11
|
|
69
|
+
? { address: Array.from(body.subarray(0, 4)).join("."), port: (body[8] << 8) | body[9] }
|
|
70
|
+
: { address: ipv6Text(body.subarray(0, 16)), port: (body[32] << 8) | body[33] };
|
|
71
|
+
// the TLVs (type, a 2-byte length, the value): skipped, each held inside the block
|
|
72
|
+
for (let at = addresses; at < length;) {
|
|
73
|
+
if (length - at < 3)
|
|
74
|
+
return { refused: "a PROXY header whose last TLV is cut short" };
|
|
75
|
+
at += 3 + ((body[at + 1] << 8) | body[at + 2]);
|
|
76
|
+
if (at > length)
|
|
77
|
+
return { refused: "a PROXY header with a TLV past its end" };
|
|
78
|
+
}
|
|
79
|
+
return { header: { command: "PROXY", source }, length: 16 + length };
|
|
80
|
+
}
|
|
81
|
+
/** Whether a connection's first byte is a PROXY header's, v2 or v1: never a startup packet's (its length's first byte is
|
|
82
|
+
* 0) nor a TLS ClientHello's (0x16). */
|
|
83
|
+
export const startsProxyHeader = (first) => first === PROXY_V2_SIGNATURE[0] || first === PROXY_V1_START[0];
|
|
84
|
+
/**
|
|
85
|
+
* THE CLIENT'S ADDRESS, THE ONE READER OF IT: the source the header of a named proxy carries, else the socket's peer
|
|
86
|
+
* (a LOCAL header's connection is the proxy's own), shown as Mason shows an address (`shownAddress`); null when the
|
|
87
|
+
* peer cannot be read.
|
|
88
|
+
*/
|
|
89
|
+
export function clientAddress(peer, header) {
|
|
90
|
+
return header?.source?.address ?? shownAddress(peer);
|
|
91
|
+
}
|
|
92
|
+
/** Whether `address` (as `shownAddress` shows one, or in any spelling of the same address) is among those `from` names. */
|
|
93
|
+
export const proxyNames = (from, address) => inRanges(from.ranges, address);
|
|
94
|
+
/** what a prefix of 0 is refused with: a header believed from every address lets any client claim to be any other */
|
|
95
|
+
const EVERY = "so any client could claim to be any other: name the proxy's own addresses";
|
|
96
|
+
/**
|
|
97
|
+
* `MASON_PG_PROXY_FROM` read: null when it is unset or empty (no header is believed from anyone, as before), the
|
|
98
|
+
* addresses it names, or why not: every entry that is not an address or a CIDR, each said with what is wrong, or a list
|
|
99
|
+
* whose entries TOGETHER name every IPv4 or every IPv6 address (read as places, never spellings), under which any
|
|
100
|
+
* client could claim to be any other.
|
|
101
|
+
*/
|
|
102
|
+
export function parseProxyFrom(value) {
|
|
103
|
+
if (value === undefined || value.trim() === "")
|
|
104
|
+
return null;
|
|
105
|
+
const entries = value.split(",").map((e) => e.trim());
|
|
106
|
+
const ranges = [], wrong = [];
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
if (e === "") {
|
|
109
|
+
wrong.push("an empty entry between two commas");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const c = cidrOf(e, EVERY);
|
|
113
|
+
if ("problem" in c)
|
|
114
|
+
wrong.push(c.problem);
|
|
115
|
+
else
|
|
116
|
+
ranges.push(rangeOf(c));
|
|
117
|
+
}
|
|
118
|
+
const setting = `${PROXY_SETTING}=${JSON.stringify(value)}`;
|
|
119
|
+
if (wrong.length)
|
|
120
|
+
return { problem: `${setting}: ${wrong.join("; ")}` };
|
|
121
|
+
const every = [
|
|
122
|
+
...(covers(ranges, V4_START, V4_END) ? ["every IPv4 address (0.0.0.0/0)"] : []),
|
|
123
|
+
// IPv6's every address but the v4-mapped ones, which are IPv4's and judged above
|
|
124
|
+
...(covers(ranges, 0n, V4_START - 1n) && covers(ranges, V4_END + 1n, ALL_END) ? ["every IPv6 address (::/0)"] : []),
|
|
125
|
+
];
|
|
126
|
+
if (every.length)
|
|
127
|
+
return { problem: `${setting}: its entries together name ${every.join(" and ")}, so any client could claim to be any other: name the proxy's own addresses` };
|
|
128
|
+
return { entries, ranges };
|
|
129
|
+
}
|