@mason-data/cli 0.7.0 → 0.9.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 +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- 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 +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- 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 +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -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 +15 -13
- 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 +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +12 -4
|
@@ -31,10 +31,14 @@
|
|
|
31
31
|
* The instance's TimeZone is UTC (the API treats every time as UTC, §4.8); its search_path is Postgres's default, so
|
|
32
32
|
* `public."orders"` and `orders` both resolve, as a tool expects.
|
|
33
33
|
*/
|
|
34
|
+
import { randomBytes } from "node:crypto";
|
|
34
35
|
import { Worker } from "node:worker_threads";
|
|
35
36
|
import { fileURLToPath } from "node:url";
|
|
36
37
|
import { runnable } from "../../lib/runnable.js";
|
|
37
|
-
import {
|
|
38
|
+
import { NO_ROLE_CATALOG } from "../../duck/grants.js";
|
|
39
|
+
import { renderWhere, tableSql } from "../../duck/row-filter.js";
|
|
40
|
+
import { cstr, frame, splitFrames } from "./protocol.js";
|
|
41
|
+
import { HELD_ROW_INPUT, HELD_TYPE_INPUT, madeTypeName, PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
|
|
38
42
|
/** Ten minutes: long enough to outlive a tool's startup burst and a person's pause between two queries. */
|
|
39
43
|
const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
40
44
|
/** A catalog statement's deadline (`MASON_PG_CATALOG_TIMEOUT_MS`): a tool's introspection answers in milliseconds; a
|
|
@@ -46,6 +50,7 @@ export const DEFAULT_STATEMENT_MS = 10_000;
|
|
|
46
50
|
export const MIN_START_MS = 30_000;
|
|
47
51
|
/** A SQL identifier, double-quoted with an inner quote doubled. */
|
|
48
52
|
const ident = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
53
|
+
const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
49
54
|
/** A SQL string literal, single-quoted with an inner quote doubled. */
|
|
50
55
|
const literal = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
51
56
|
/** a table's name in the catalog: its schema (Mason's source schema, else public) and name */
|
|
@@ -70,11 +75,11 @@ function isForeignKey(schema, table, join) {
|
|
|
70
75
|
}
|
|
71
76
|
const joinNote = (join) => `joins ${join.table} on ${join.on[1]} (${join.relationship})`;
|
|
72
77
|
/**
|
|
73
|
-
* THE CATALOG'S DATABASE IS THE ONE A TOOL CONNECTED TO (
|
|
78
|
+
* THE CATALOG'S DATABASE IS THE ONE A TOOL CONNECTED TO (src/lib/database-name.ts): current_database(), pg_database — pgjdbc's
|
|
74
79
|
* getCatalogs and every TABLE_CAT, DBeaver's navigator — once said the sidecar's own `postgres`. The current
|
|
75
80
|
* database's row is renamed (ALTER DATABASE refuses the current one; the catalog row does not), after moving aside any
|
|
76
81
|
* OTHER database already holding the name — PGlite's `template0` / `template1` — since pg_database's names are unique
|
|
77
|
-
* (#484's review: a
|
|
82
|
+
* (#484's review: a name of `template1` failed on pg_database_datname_index and the catalog never started).
|
|
78
83
|
*/
|
|
79
84
|
export function databaseNameSql(name) {
|
|
80
85
|
const q = `'${name.replaceAll("'", "''")}'`;
|
|
@@ -112,12 +117,19 @@ export function catalogDdl(schema) {
|
|
|
112
117
|
for (const name of [...new Set(schema.tables.map((t) => t.schema).filter((x) => x !== undefined && x !== "public"))]) {
|
|
113
118
|
statements.push(`CREATE SCHEMA IF NOT EXISTS ${ident(name)};`);
|
|
114
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));
|
|
115
125
|
// a source enum a column has: the type, its labels in their declared order, before the tables —
|
|
116
126
|
// spelled as the source's format_type spells it (`mood`, `other.level`, `"Odd"`), its schema made first
|
|
117
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
|
|
118
130
|
for (const t of schema.tables)
|
|
119
131
|
for (const c of t.columns)
|
|
120
|
-
if (c.enumLabels !== undefined && c.spelled !== undefined)
|
|
132
|
+
if (c.enumLabels !== undefined && c.spelled !== undefined && !enums.has(c.spelled.replace(/\[\]$/, "")))
|
|
121
133
|
enums.set(c.spelled.replace(/\[\]$/, ""), c.enumLabels);
|
|
122
134
|
for (const [type, labels] of enums) {
|
|
123
135
|
const qualifier = typeNameParts(type).schema;
|
|
@@ -125,10 +137,22 @@ export function catalogDdl(schema) {
|
|
|
125
137
|
statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
|
|
126
138
|
statements.push(`CREATE TYPE ${type} AS ENUM (${labels.map(literal).join(", ")});`);
|
|
127
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
|
+
});
|
|
128
149
|
for (const table of schema.tables) {
|
|
129
150
|
const lines = [];
|
|
130
|
-
for (const c of table.columns)
|
|
131
|
-
|
|
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
|
+
}
|
|
132
156
|
if (table.primaryKey.length > 0)
|
|
133
157
|
lines.push(`PRIMARY KEY (${table.primaryKey.map(ident).join(", ")})`);
|
|
134
158
|
statements.push(`CREATE TABLE ${qualified(table)} (\n ${lines.join(",\n ")}\n);`);
|
|
@@ -165,6 +189,38 @@ export function catalogDdl(schema) {
|
|
|
165
189
|
}
|
|
166
190
|
return [...statements, ...constraints, ...sourceTypeNames(schema)];
|
|
167
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
|
+
}
|
|
168
224
|
/**
|
|
169
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
|
|
170
226
|
* sent with the SOURCE's OID (src/duck/plan.ts `userTypes`: what the source's own clients cached), which in this
|
|
@@ -185,14 +241,19 @@ export function sourceTypeNames(schema) {
|
|
|
185
241
|
if (c.typeOid === undefined || c.spelled === undefined)
|
|
186
242
|
continue;
|
|
187
243
|
// an array column's own OID is its array type's; an element's array type is the element's spelling with []
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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}[]`);
|
|
191
248
|
}
|
|
192
249
|
}
|
|
193
250
|
// with no type the source made, every OID the wire sends is Postgres's: the wire's name is format_type's
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|
|
196
257
|
const values = [...names].map(([oid, spelled]) => `(${oid}::oid, ${literal(spelled)})`).join(", ");
|
|
197
258
|
return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
|
|
198
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),
|
|
@@ -234,15 +295,265 @@ export function catalogDefaults(schema) {
|
|
|
234
295
|
* The source's views, one statement each beside `catalogDefaults`'s: a view reading a table
|
|
235
296
|
* Mason has not copied (or a function the catalog lacks) is said in the log and left out. Each definition qualifies
|
|
236
297
|
* every name outside pg_catalog (src/cli/preflight.ts VIEWS_SQL), so it reads the same tables here; a view over a view
|
|
237
|
-
* is created once the other is (the worker runs these in passes).
|
|
298
|
+
* is created once the other is (the worker runs these in passes). Every role may SEE every view (`catalogRoles`: the
|
|
299
|
+
* grant is in the view's own statement, since the view must exist first): reading one is held to the tables it reads.
|
|
238
300
|
*/
|
|
239
|
-
export function catalogViews(schema) {
|
|
301
|
+
export function catalogViews(schema, roles = new Map()) {
|
|
240
302
|
return (schema.views ?? []).map((v) => {
|
|
241
303
|
const name = `${ident(v.schema)}.${ident(v.name)}`;
|
|
242
304
|
const comment = v.description ? ` COMMENT ON VIEW ${name} IS ${literal(v.description)};` : "";
|
|
243
|
-
|
|
305
|
+
// every role of the file may see a view; the catalog's role of a user held to nothing sees none (as a login with no
|
|
306
|
+
// grant on Postgres)
|
|
307
|
+
const granted = [...roles.keys()].filter((r) => r !== NO_ROLE_CATALOG).map((r) => ` GRANT USAGE ON SCHEMA ${ident(v.schema)} TO ${ident(r)}; GRANT SELECT ON ${name} TO ${ident(r)};`).join("");
|
|
308
|
+
return `CREATE SCHEMA IF NOT EXISTS ${ident(v.schema)}; CREATE VIEW ${name} AS ${v.definition.replace(/;\s*$/, "")};${comment}${granted}`;
|
|
244
309
|
});
|
|
245
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* THE CATALOG'S ROLES MIRROR THE ROLES FILE (GitHub issue 30; src/cli/roles.ts, src/duck/grants.ts): one Postgres role
|
|
313
|
+
* per Mason role, NOLOGIN, with `GRANT SELECT` on each table the role reads whole and `GRANT SELECT (columns)` on
|
|
314
|
+
* each it reads in part, and USAGE on every schema the copied tables are in. A session with a role runs its catalog
|
|
315
|
+
* statements under `SET ROLE` (`asRole`), so `information_schema.tables` / `.columns`, `has_table_privilege` and
|
|
316
|
+
* `has_column_privilege` answer for the role as Postgres's own do — and `mason mcp`'s `tables` and `describe`, which
|
|
317
|
+
* ask them, list a role what it may read. `pg_catalog` itself (pg_class, pg_attribute) does not filter by privilege
|
|
318
|
+
* on Postgres either; it still lists every synced column, said in docs/POSTGRES-API.md. A table the role names that the
|
|
319
|
+
* schema does not hold yet (added, not copied) is granted at the rebuild its copy brings; a column the copy lacks is
|
|
320
|
+
* left out (the roles file was held to the copy already, src/cli/roles.ts). Rebuilt with the schema: the server
|
|
321
|
+
* replaces the catalog when the roles move, as it does when the tables do. **`set_config` is revoked from PUBLIC
|
|
322
|
+
* while roles exist** (the review of #35): the catalog's one connection is the superuser's with `SET ROLE` on top, so
|
|
323
|
+
* inside one statement `set_config('role', 'none', false)` was a permitted RESET ROLE and the statement's later
|
|
324
|
+
* targets ran as the superuser (`information_schema` listed every hidden column's name); revoked, a role's session is
|
|
325
|
+
* refused it (42501 `permission denied for function set_config`) and the superuser keeps it. A role of `tables: all`
|
|
326
|
+
* (stage 1b) is resolved to a whole-table grant on every copied table (src/cli/roles.ts resolveRoles), so it is written
|
|
327
|
+
* here as per-table `GRANT SELECT`s, never `GRANT SELECT ON ALL TABLES IN SCHEMA`: the catalog is rebuilt from the
|
|
328
|
+
* resolved grants whenever the copied tables move, so a table added later is granted at that rebuild. The file's
|
|
329
|
+
* `default:` role is one of these roles: a user with no role of its own runs its catalog requests under it; under a
|
|
330
|
+
* file with no default it runs them as `NO_ROLE_CATALOG` (src/duck/grants.ts catalogRolesOf), a role with no grant.
|
|
331
|
+
*/
|
|
332
|
+
export function catalogRoles(schema, roles) {
|
|
333
|
+
const out = [];
|
|
334
|
+
if (roles.size === 0)
|
|
335
|
+
return out;
|
|
336
|
+
out.push("REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC;");
|
|
337
|
+
const schemas = [...new Set(schema.tables.map((t) => t.schema ?? "public"))];
|
|
338
|
+
const byName = new Map(schema.tables.map((t) => [`${t.schema ?? "public"}.${t.name}`, t]));
|
|
339
|
+
for (const r of roles.values()) {
|
|
340
|
+
const role = ident(r.name);
|
|
341
|
+
out.push(`CREATE ROLE ${role} NOLOGIN;`);
|
|
342
|
+
for (const s of schemas)
|
|
343
|
+
out.push(`GRANT USAGE ON SCHEMA ${ident(s)} TO ${role};`);
|
|
344
|
+
for (const g of r.tables.values()) {
|
|
345
|
+
const t = byName.get(g.table);
|
|
346
|
+
if (t === undefined)
|
|
347
|
+
continue;
|
|
348
|
+
if (g.all) {
|
|
349
|
+
out.push(`GRANT SELECT ON ${qualified(t)} TO ${role};`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
const columns = g.columns.filter((c) => t.columns.some((x) => x.name === c));
|
|
353
|
+
if (columns.length > 0)
|
|
354
|
+
out.push(`GRANT SELECT (${columns.map(ident).join(", ")}) ON ${qualified(t)} TO ${role};`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* THE ROLES' ROW FILTERS AS THE CATALOG'S OWN ROW SECURITY (src/duck/row-filter.ts): every copied table some role filters
|
|
361
|
+
* gets `ENABLE ROW LEVEL SECURITY` and one permissive SELECT policy per role that reads it — its `where:` with each
|
|
362
|
+
* attribute read as `current_setting('mason.attr.<name>')::<type>` (a list `string_to_array(…, ',')::<type>`), the type
|
|
363
|
+
* Postgres inferred for it (`filterTypes`, carried on the roles as `filterTypes`), each request carrying the session's
|
|
364
|
+
* attributes as settings (`asSession`); a `through:` as `EXISTS (SELECT 1 FROM <parent> …)`, the parent's own policy
|
|
365
|
+
* applying inside; `true` for a role that reads it unfiltered (row security with no policy hides every row). So
|
|
366
|
+
* `pg_policies`, `relrowsecurity` and the catalog's own analysis see what Postgres would. The catalog holds no row: the
|
|
367
|
+
* rows Mason answers are filtered on the copy (src/duck/plan.ts), never here. A filter whose types are not known (its
|
|
368
|
+
* check failed: the roles' problem refuses every user held to them) gets no policy. Idempotent: each policy dropped first.
|
|
369
|
+
*/
|
|
370
|
+
export function catalogPolicies(schema, roles) {
|
|
371
|
+
const filtered = new Set([...roles.values()].flatMap((g) => [...(g.filters?.keys() ?? [])]));
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const t of schema.tables) {
|
|
374
|
+
const name = `${t.schema ?? "public"}.${t.name}`;
|
|
375
|
+
if (!filtered.has(name))
|
|
376
|
+
continue;
|
|
377
|
+
out.push(`ALTER TABLE ${qualified(t)} ENABLE ROW LEVEL SECURITY`);
|
|
378
|
+
for (const g of roles.values()) {
|
|
379
|
+
if (!g.tables.has(name))
|
|
380
|
+
continue;
|
|
381
|
+
const f = g.filters?.get(name);
|
|
382
|
+
let using;
|
|
383
|
+
if (f === undefined)
|
|
384
|
+
using = "true";
|
|
385
|
+
else if (f.through !== undefined)
|
|
386
|
+
using = `EXISTS (SELECT 1 FROM ${tableSql(f.through.parent)} AS __mason_parent WHERE __mason_parent.${ident(f.through.parentColumn)} = ${qualified(t)}.${ident(f.through.column)})`;
|
|
387
|
+
else {
|
|
388
|
+
const types = g.filterTypes?.get(name);
|
|
389
|
+
if (types === undefined || f.where === undefined)
|
|
390
|
+
continue;
|
|
391
|
+
const shapes = g.attributes ?? new Map();
|
|
392
|
+
using = renderWhere(f.where.parts, (a) => {
|
|
393
|
+
const read = `current_setting('mason.attr.${a}')`;
|
|
394
|
+
return shapes.get(a) === "list" ? `string_to_array(${read}, ',')::${types.get(a) ?? "text[]"}` : `${read}::${types.get(a) ?? "text"}`;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
out.push(`DROP POLICY IF EXISTS ${ident(`mason_${g.name}`)} ON ${qualified(t)}`);
|
|
398
|
+
out.push(`CREATE POLICY ${ident(`mason_${g.name}`)} ON ${qualified(t)} AS PERMISSIVE FOR SELECT TO ${ident(g.name)} USING (${using})`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* A SESSION'S CATALOG REQUEST AS ITS ROLE: `SET ROLE` before the request's frames and `RESET ROLE` after, in ONE
|
|
405
|
+
* message — the catalog runs one request at a time (`PgCatalog`'s chain) and a message whole, so no other session's
|
|
406
|
+
* request runs under this role, and none of this session's runs without it. A Sync is added before the RESET where
|
|
407
|
+
* the request has none: after an error Postgres ignores every message until a Sync (ignore_till_sync), the RESET
|
|
408
|
+
* included, and a role left set would be every session's.
|
|
409
|
+
*/
|
|
410
|
+
export function asRole(message, role) {
|
|
411
|
+
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
412
|
+
const synced = splitFrames(own).some((f) => f.type === "S");
|
|
413
|
+
const set = `SET ROLE ${ident(role)}`;
|
|
414
|
+
return Buffer.concat([frame("Q", cstr(set)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr("RESET ROLE"))]);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The reply to an `asRole` message, less the SET's and the RESET's own replies (each a CommandComplete and a
|
|
418
|
+
* ReadyForQuery; the Sync added by `asRole` leaves its ReadyForQuery, which every caller drops). `setRoleRefused`: the
|
|
419
|
+
* SET ROLE's ErrorResponse, when the catalog has no such role (its roles are a rebuild behind the file).
|
|
420
|
+
*/
|
|
421
|
+
export function unwrapRole(frames) {
|
|
422
|
+
const first = frames[0];
|
|
423
|
+
if (first === undefined)
|
|
424
|
+
return { frames: [], setRoleRefused: null };
|
|
425
|
+
if (first.type === "E")
|
|
426
|
+
return { frames: [], setRoleRefused: first.frame };
|
|
427
|
+
// the SET's CommandComplete and ReadyForQuery first; the RESET's CommandComplete and ReadyForQuery last
|
|
428
|
+
const inner = frames.slice(first.type === "C" && frames[1]?.type === "Z" ? 2 : 0);
|
|
429
|
+
const n = inner.length;
|
|
430
|
+
const end = n >= 2 && inner[n - 1].type === "Z" && inner[n - 2].type === "C" ? n - 2 : n;
|
|
431
|
+
return { frames: inner.slice(0, end), setRoleRefused: null };
|
|
432
|
+
}
|
|
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
|
+
];
|
|
504
|
+
/**
|
|
505
|
+
* THE CATALOG'S `current_setting(name[, missing_ok])` IS THE SESSION'S (GitHub issue 40: after `SET TimeZone` it answered
|
|
506
|
+
* the catalog's UTC, where `SHOW TimeZone` answered the session's): the value the session holds under that name, from
|
|
507
|
+
* the request's `SESSION_SETTINGS` (`asSession`: every parameter the listener's `sessionSetting` answers), else
|
|
508
|
+
* Postgres's own (`block_size`; its 42704, or NULL under missing_ok, for a name nobody knows). The `version()`
|
|
509
|
+
* override's way: search_path lists pg_catalog after public, so a statement reading no table and calling it is
|
|
510
|
+
* forwarded as ever; one that writes `pg_catalog.current_setting` is spliced to this one (server.ts
|
|
511
|
+
* userSpliced: Postgres's built-in runs its C function by OID, so its catalog row cannot be replaced). Plain
|
|
512
|
+
* SQL (plpgsql would load its library, and SHOW ALL list its `plpgsql.*`); STRICT, as Postgres's.
|
|
513
|
+
*/
|
|
514
|
+
export function currentSettingSql() {
|
|
515
|
+
const held = `nullif(pg_catalog.current_setting('${SESSION_SETTINGS}', true), '')::jsonb ->> lower($1)`;
|
|
516
|
+
return [
|
|
517
|
+
`CREATE OR REPLACE FUNCTION public.current_setting(text) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1)) $$`,
|
|
518
|
+
`CREATE OR REPLACE FUNCTION public.current_setting(text, boolean) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1, $2)) $$`,
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
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.
|
|
536
|
+
*/
|
|
537
|
+
export function asSession(message, state, settings) {
|
|
538
|
+
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
539
|
+
const frames = splitFrames(own);
|
|
540
|
+
const synced = frames.some((f) => f.type === "S") || frames.at(-1)?.type === "Q";
|
|
541
|
+
const json = JSON.stringify(Object.fromEntries(settings));
|
|
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))]);
|
|
545
|
+
}
|
|
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. */
|
|
548
|
+
export function unwrapSession(frames) {
|
|
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";
|
|
551
|
+
const n = frames.length;
|
|
552
|
+
if (n < 2 * (sets + 1) || !run(0) || !run(n - sets - 1)) {
|
|
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`);
|
|
554
|
+
}
|
|
555
|
+
return frames.slice(sets + 1, n - sets - 1);
|
|
556
|
+
}
|
|
246
557
|
/**
|
|
247
558
|
* WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
|
|
248
559
|
* view's whole definition at each start of the instance — 7 multi-line entries per start, most of a 190 KB log, their
|
|
@@ -257,7 +568,7 @@ export function leftOutLine(r) {
|
|
|
257
568
|
return `catalog: a source default left out (${r.message}): ${r.statement}`;
|
|
258
569
|
const missing = /^relation "([^"]+)" does not exist$/.exec(r.message)?.[1];
|
|
259
570
|
const name = `${unquote(view[1])}.${unquote(view[2])}`;
|
|
260
|
-
return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not
|
|
571
|
+
return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not synced` : `catalog: view ${name} left out (${r.message})`;
|
|
261
572
|
}
|
|
262
573
|
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
263
574
|
/** One worker thread holding one PGlite: requests one at a time (PgCatalog's chain), each with a deadline. */
|
|
@@ -303,7 +614,8 @@ class CatalogWorker {
|
|
|
303
614
|
return new Promise((resolve, reject) => {
|
|
304
615
|
const id = this.#next++;
|
|
305
616
|
const timer = setTimeout(() => {
|
|
306
|
-
|
|
617
|
+
// the message reaches the client on the wire and the request log: Mason's words, never the catalog's engine's name
|
|
618
|
+
this.#die(new Error(`the catalog did not answer ${what} within ${ms / 1000} s, so Mason started it again (the next statement is answered as usual; MASON_PG_CATALOG_TIMEOUT_MS raises the deadline)`));
|
|
307
619
|
void this.#worker.terminate();
|
|
308
620
|
}, ms);
|
|
309
621
|
timer.unref();
|
|
@@ -318,6 +630,7 @@ class CatalogWorker {
|
|
|
318
630
|
}
|
|
319
631
|
export class PgCatalog {
|
|
320
632
|
#schema;
|
|
633
|
+
#roles;
|
|
321
634
|
#idleMs;
|
|
322
635
|
#statementMs;
|
|
323
636
|
#log;
|
|
@@ -331,8 +644,12 @@ export class PgCatalog {
|
|
|
331
644
|
#chain = Promise.resolve();
|
|
332
645
|
#idleTimer = null;
|
|
333
646
|
#lastUsed = 0;
|
|
647
|
+
/** the roles' row security policies (`catalogPolicies`), written once the listener checked the filters: run on the
|
|
648
|
+
* instance up now and at every start after, until the schema or the roles are replaced */
|
|
649
|
+
#policies = [];
|
|
334
650
|
constructor(schema, opts = {}) {
|
|
335
651
|
this.#schema = schema;
|
|
652
|
+
this.#roles = opts.roles ?? new Map();
|
|
336
653
|
this.#idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
|
|
337
654
|
this.#statementMs = opts.statementMs ?? DEFAULT_STATEMENT_MS;
|
|
338
655
|
this.#log = opts.log ?? ((line) => console.error(line));
|
|
@@ -344,12 +661,14 @@ export class PgCatalog {
|
|
|
344
661
|
return this.#db !== null && this.#db.alive;
|
|
345
662
|
}
|
|
346
663
|
/** Runs raw frontend protocol bytes — one message or several (Parse, Bind, Describe, Execute, Sync in one buffer
|
|
347
|
-
* works) — and returns the backend's raw reply, starting the instance when needed.
|
|
348
|
-
|
|
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) {
|
|
349
668
|
return this.#enqueue(async () => {
|
|
350
669
|
const db = await this.#ensureStarted();
|
|
351
670
|
const copy = new Uint8Array(message);
|
|
352
|
-
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]);
|
|
353
672
|
this.#touch();
|
|
354
673
|
return reply.bytes ?? new Uint8Array(0);
|
|
355
674
|
});
|
|
@@ -367,13 +686,36 @@ export class PgCatalog {
|
|
|
367
686
|
stop() {
|
|
368
687
|
return this.#enqueue(() => this.#close("stopped"));
|
|
369
688
|
}
|
|
370
|
-
/** Swaps in a new schema (after an apply)
|
|
371
|
-
* DDL. Resolves once the old instance is gone; a caller need not wait for it. */
|
|
372
|
-
replace(schema) {
|
|
689
|
+
/** Swaps in a new schema (after an apply), and the roles with it (the roles file moved): the running instance is
|
|
690
|
+
* stopped and the next `run` starts with the new DDL. Resolves once the old instance is gone; a caller need not wait for it. */
|
|
691
|
+
replace(schema, roles = this.#roles) {
|
|
373
692
|
this.#schema = schema;
|
|
693
|
+
this.#roles = roles;
|
|
694
|
+
this.#policies = [];
|
|
374
695
|
this.#said.clear();
|
|
375
696
|
return this.#enqueue(() => this.#close("replaced"));
|
|
376
697
|
}
|
|
698
|
+
/**
|
|
699
|
+
* THE ROLES' ROW SECURITY POLICIES (`catalogPolicies`), written now (the instance started where needed) and at every
|
|
700
|
+
* start after, until the next `replace`. A statement the catalog refuses is said, never fatal: the rows are the copy's.
|
|
701
|
+
*/
|
|
702
|
+
policies(ddl) {
|
|
703
|
+
return this.#enqueue(async () => {
|
|
704
|
+
this.#policies = ddl;
|
|
705
|
+
if (ddl.length === 0)
|
|
706
|
+
return;
|
|
707
|
+
const db = await this.#ensureStarted();
|
|
708
|
+
for (const sql of ddl) {
|
|
709
|
+
try {
|
|
710
|
+
await this.#ask(db, { op: "query", sql }, "a row security policy");
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
this.#log(`catalog: a row security policy left out (${errorMessage(e)}): ${sql}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
this.#touch();
|
|
717
|
+
});
|
|
718
|
+
}
|
|
377
719
|
#enqueue(fn) {
|
|
378
720
|
const result = this.#chain.then(fn);
|
|
379
721
|
// the chain only ORDERS calls: a rejection reaches the caller through `result`, and the next call must still run
|
|
@@ -401,11 +743,12 @@ export class PgCatalog {
|
|
|
401
743
|
if (this.#db !== null && this.#db.alive)
|
|
402
744
|
return this.#db;
|
|
403
745
|
this.#db = null;
|
|
404
|
-
const schema = this.#schema;
|
|
746
|
+
const schema = this.#schema, roles = this.#roles;
|
|
405
747
|
const t0 = Date.now();
|
|
406
748
|
const db = new CatalogWorker();
|
|
407
749
|
try {
|
|
408
|
-
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],
|
|
751
|
+
optional: [...catalogDefaults(schema), ...catalogViews(schema, roles), ...this.#policies] }, "its start");
|
|
409
752
|
// each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
|
|
410
753
|
for (const r of started.refused ?? []) {
|
|
411
754
|
const line = leftOutLine(r);
|
|
@@ -423,7 +766,7 @@ export class PgCatalog {
|
|
|
423
766
|
throw new Error(`PGlite could not start: ${errorMessage(e)}`, { cause: e });
|
|
424
767
|
}
|
|
425
768
|
this.#db = db;
|
|
426
|
-
this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)`);
|
|
769
|
+
this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)${roles.size > 0 ? ` and ${roles.size} role(s)` : ""}`);
|
|
427
770
|
this.#touch();
|
|
428
771
|
return db;
|
|
429
772
|
}
|
|
@@ -82,7 +82,9 @@ export function fieldOf(name, type, format) {
|
|
|
82
82
|
export class EncodeError extends Error {
|
|
83
83
|
/** the SQLSTATE Postgres raises for the same value (a timestamp past its range is 22008), else XX000: Mason's gap */
|
|
84
84
|
code;
|
|
85
|
-
|
|
85
|
+
/** the registry entry that refused the value (src/duck/gaps.ts): the request log's `gap`; null for any other error */
|
|
86
|
+
gap;
|
|
87
|
+
constructor(message, code = "XX000", gap = null) { super(message); this.code = code; this.gap = gap; }
|
|
86
88
|
}
|
|
87
89
|
const refuse = (type, value, why) => {
|
|
88
90
|
throw new EncodeError(`cannot send ${JSON.stringify(value)} as ${type}: ${why}`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseSql, PostgresSyntaxError } from "./parser.js";
|
|
2
2
|
import { Unsupported } from "./translate.js";
|
|
3
|
+
import { gapMark } from "../../duck/gaps.js";
|
|
3
4
|
const bodyOf = (n) => Object.values(n)[0];
|
|
4
5
|
/** Postgres's defGetBoolean: no value is true; true / false / on / off / 1 / 0 */
|
|
5
6
|
function booleanOf(name, arg, at) {
|
|
@@ -84,7 +85,7 @@ export function explainOptions(options) {
|
|
|
84
85
|
}
|
|
85
86
|
case "generic_plan":
|
|
86
87
|
if (booleanOf(name, b.arg, at))
|
|
87
|
-
throw new Unsupported(`EXPLAIN (GENERIC_PLAN) is not supported by Mason (
|
|
88
|
+
throw new Unsupported(`EXPLAIN (GENERIC_PLAN) is not supported by Mason (${gapMark("explain-option")})`, at, "0A000", "bind the parameters' values and EXPLAIN that: Mason plans the statement with them", "explain-option");
|
|
88
89
|
break;
|
|
89
90
|
case "format": {
|
|
90
91
|
const v = stringOf(name, b.arg, at);
|
|
@@ -93,7 +94,7 @@ export function explainOptions(options) {
|
|
|
93
94
|
break;
|
|
94
95
|
}
|
|
95
96
|
if (v === "xml" || v === "yaml")
|
|
96
|
-
throw new Unsupported(`EXPLAIN (FORMAT ${v.toUpperCase()}) is not supported by Mason (
|
|
97
|
+
throw new Unsupported(`EXPLAIN (FORMAT ${v.toUpperCase()}) is not supported by Mason (${gapMark("explain-option")})`, at, "0A000", "write FORMAT TEXT or FORMAT JSON", "explain-option");
|
|
97
98
|
throw new Unsupported(`unrecognized value for EXPLAIN option "format": "${v}"`, at, "22023");
|
|
98
99
|
}
|
|
99
100
|
default: throw new Unsupported(`unrecognized EXPLAIN option "${name}"`, at, "42601");
|
|
@@ -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) {
|