@mason-data/cli 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +3 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -31,9 +31,13 @@
|
|
|
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";
|
|
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";
|
|
37
41
|
import { 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;
|
|
@@ -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("'", "''")}'`;
|
|
@@ -234,15 +239,189 @@ export function catalogDefaults(schema) {
|
|
|
234
239
|
* The source's views, one statement each beside `catalogDefaults`'s: a view reading a table
|
|
235
240
|
* Mason has not copied (or a function the catalog lacks) is said in the log and left out. Each definition qualifies
|
|
236
241
|
* 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).
|
|
242
|
+
* is created once the other is (the worker runs these in passes). Every role may SEE every view (`catalogRoles`: the
|
|
243
|
+
* grant is in the view's own statement, since the view must exist first): reading one is held to the tables it reads.
|
|
238
244
|
*/
|
|
239
|
-
export function catalogViews(schema) {
|
|
245
|
+
export function catalogViews(schema, roles = new Map()) {
|
|
240
246
|
return (schema.views ?? []).map((v) => {
|
|
241
247
|
const name = `${ident(v.schema)}.${ident(v.name)}`;
|
|
242
248
|
const comment = v.description ? ` COMMENT ON VIEW ${name} IS ${literal(v.description)};` : "";
|
|
243
|
-
|
|
249
|
+
// 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
|
|
250
|
+
// grant on Postgres)
|
|
251
|
+
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("");
|
|
252
|
+
return `CREATE SCHEMA IF NOT EXISTS ${ident(v.schema)}; CREATE VIEW ${name} AS ${v.definition.replace(/;\s*$/, "")};${comment}${granted}`;
|
|
244
253
|
});
|
|
245
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* THE CATALOG'S ROLES MIRROR THE ROLES FILE (GitHub issue 30; src/cli/roles.ts, src/duck/grants.ts): one Postgres role
|
|
257
|
+
* per Mason role, NOLOGIN, with `GRANT SELECT` on each table the role reads whole and `GRANT SELECT (columns)` on
|
|
258
|
+
* each it reads in part, and USAGE on every schema the copied tables are in. A session with a role runs its catalog
|
|
259
|
+
* statements under `SET ROLE` (`asRole`), so `information_schema.tables` / `.columns`, `has_table_privilege` and
|
|
260
|
+
* `has_column_privilege` answer for the role as Postgres's own do — and `mason mcp`'s `tables` and `describe`, which
|
|
261
|
+
* ask them, list a role what it may read. `pg_catalog` itself (pg_class, pg_attribute) does not filter by privilege
|
|
262
|
+
* on Postgres either; it still lists every synced column, said in docs/POSTGRES-API.md. A table the role names that the
|
|
263
|
+
* schema does not hold yet (added, not copied) is granted at the rebuild its copy brings; a column the copy lacks is
|
|
264
|
+
* left out (the roles file was held to the copy already, src/cli/roles.ts). Rebuilt with the schema: the server
|
|
265
|
+
* replaces the catalog when the roles move, as it does when the tables do. **`set_config` is revoked from PUBLIC
|
|
266
|
+
* while roles exist** (the review of #35): the catalog's one connection is the superuser's with `SET ROLE` on top, so
|
|
267
|
+
* inside one statement `set_config('role', 'none', false)` was a permitted RESET ROLE and the statement's later
|
|
268
|
+
* targets ran as the superuser (`information_schema` listed every hidden column's name); revoked, a role's session is
|
|
269
|
+
* refused it (42501 `permission denied for function set_config`) and the superuser keeps it. A role of `tables: all`
|
|
270
|
+
* (stage 1b) is resolved to a whole-table grant on every copied table (src/cli/roles.ts resolveRoles), so it is written
|
|
271
|
+
* here as per-table `GRANT SELECT`s, never `GRANT SELECT ON ALL TABLES IN SCHEMA`: the catalog is rebuilt from the
|
|
272
|
+
* resolved grants whenever the copied tables move, so a table added later is granted at that rebuild. The file's
|
|
273
|
+
* `default:` role is one of these roles: a user with no role of its own runs its catalog requests under it; under a
|
|
274
|
+
* file with no default it runs them as `NO_ROLE_CATALOG` (src/duck/grants.ts catalogRolesOf), a role with no grant.
|
|
275
|
+
*/
|
|
276
|
+
export function catalogRoles(schema, roles) {
|
|
277
|
+
const out = [];
|
|
278
|
+
if (roles.size === 0)
|
|
279
|
+
return out;
|
|
280
|
+
out.push("REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC;");
|
|
281
|
+
const schemas = [...new Set(schema.tables.map((t) => t.schema ?? "public"))];
|
|
282
|
+
const byName = new Map(schema.tables.map((t) => [`${t.schema ?? "public"}.${t.name}`, t]));
|
|
283
|
+
for (const r of roles.values()) {
|
|
284
|
+
const role = ident(r.name);
|
|
285
|
+
out.push(`CREATE ROLE ${role} NOLOGIN;`);
|
|
286
|
+
for (const s of schemas)
|
|
287
|
+
out.push(`GRANT USAGE ON SCHEMA ${ident(s)} TO ${role};`);
|
|
288
|
+
for (const g of r.tables.values()) {
|
|
289
|
+
const t = byName.get(g.table);
|
|
290
|
+
if (t === undefined)
|
|
291
|
+
continue;
|
|
292
|
+
if (g.all) {
|
|
293
|
+
out.push(`GRANT SELECT ON ${qualified(t)} TO ${role};`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const columns = g.columns.filter((c) => t.columns.some((x) => x.name === c));
|
|
297
|
+
if (columns.length > 0)
|
|
298
|
+
out.push(`GRANT SELECT (${columns.map(ident).join(", ")}) ON ${qualified(t)} TO ${role};`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* THE ROLES' ROW FILTERS AS THE CATALOG'S OWN ROW SECURITY (src/duck/row-filter.ts): every copied table some role filters
|
|
305
|
+
* gets `ENABLE ROW LEVEL SECURITY` and one permissive SELECT policy per role that reads it — its `where:` with each
|
|
306
|
+
* attribute read as `current_setting('mason.attr.<name>')::<type>` (a list `string_to_array(…, ',')::<type>`), the type
|
|
307
|
+
* Postgres inferred for it (`filterTypes`, carried on the roles as `filterTypes`), each request carrying the session's
|
|
308
|
+
* attributes as settings (`asSession`); a `through:` as `EXISTS (SELECT 1 FROM <parent> …)`, the parent's own policy
|
|
309
|
+
* applying inside; `true` for a role that reads it unfiltered (row security with no policy hides every row). So
|
|
310
|
+
* `pg_policies`, `relrowsecurity` and the catalog's own analysis see what Postgres would. The catalog holds no row: the
|
|
311
|
+
* rows Mason answers are filtered on the copy (src/duck/plan.ts), never here. A filter whose types are not known (its
|
|
312
|
+
* check failed: the roles' problem refuses every user held to them) gets no policy. Idempotent: each policy dropped first.
|
|
313
|
+
*/
|
|
314
|
+
export function catalogPolicies(schema, roles) {
|
|
315
|
+
const filtered = new Set([...roles.values()].flatMap((g) => [...(g.filters?.keys() ?? [])]));
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const t of schema.tables) {
|
|
318
|
+
const name = `${t.schema ?? "public"}.${t.name}`;
|
|
319
|
+
if (!filtered.has(name))
|
|
320
|
+
continue;
|
|
321
|
+
out.push(`ALTER TABLE ${qualified(t)} ENABLE ROW LEVEL SECURITY`);
|
|
322
|
+
for (const g of roles.values()) {
|
|
323
|
+
if (!g.tables.has(name))
|
|
324
|
+
continue;
|
|
325
|
+
const f = g.filters?.get(name);
|
|
326
|
+
let using;
|
|
327
|
+
if (f === undefined)
|
|
328
|
+
using = "true";
|
|
329
|
+
else if (f.through !== undefined)
|
|
330
|
+
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)})`;
|
|
331
|
+
else {
|
|
332
|
+
const types = g.filterTypes?.get(name);
|
|
333
|
+
if (types === undefined || f.where === undefined)
|
|
334
|
+
continue;
|
|
335
|
+
const shapes = g.attributes ?? new Map();
|
|
336
|
+
using = renderWhere(f.where.parts, (a) => {
|
|
337
|
+
const read = `current_setting('mason.attr.${a}')`;
|
|
338
|
+
return shapes.get(a) === "list" ? `string_to_array(${read}, ',')::${types.get(a) ?? "text[]"}` : `${read}::${types.get(a) ?? "text"}`;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
out.push(`DROP POLICY IF EXISTS ${ident(`mason_${g.name}`)} ON ${qualified(t)}`);
|
|
342
|
+
out.push(`CREATE POLICY ${ident(`mason_${g.name}`)} ON ${qualified(t)} AS PERMISSIVE FOR SELECT TO ${ident(g.name)} USING (${using})`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A SESSION'S CATALOG REQUEST AS ITS ROLE: `SET ROLE` before the request's frames and `RESET ROLE` after, in ONE
|
|
349
|
+
* message — the catalog runs one request at a time (`PgCatalog`'s chain) and a message whole, so no other session's
|
|
350
|
+
* request runs under this role, and none of this session's runs without it. A Sync is added before the RESET where
|
|
351
|
+
* the request has none: after an error Postgres ignores every message until a Sync (ignore_till_sync), the RESET
|
|
352
|
+
* included, and a role left set would be every session's.
|
|
353
|
+
*/
|
|
354
|
+
export function asRole(message, role) {
|
|
355
|
+
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
356
|
+
const synced = splitFrames(own).some((f) => f.type === "S");
|
|
357
|
+
const set = `SET ROLE ${ident(role)}`;
|
|
358
|
+
return Buffer.concat([frame("Q", cstr(set)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr("RESET ROLE"))]);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The reply to an `asRole` message, less the SET's and the RESET's own replies (each a CommandComplete and a
|
|
362
|
+
* ReadyForQuery; the Sync added by `asRole` leaves its ReadyForQuery, which every caller drops). `setRoleRefused`: the
|
|
363
|
+
* SET ROLE's ErrorResponse, when the catalog has no such role (its roles are a rebuild behind the file).
|
|
364
|
+
*/
|
|
365
|
+
export function unwrapRole(frames) {
|
|
366
|
+
const first = frames[0];
|
|
367
|
+
if (first === undefined)
|
|
368
|
+
return { frames: [], setRoleRefused: null };
|
|
369
|
+
if (first.type === "E")
|
|
370
|
+
return { frames: [], setRoleRefused: first.frame };
|
|
371
|
+
// the SET's CommandComplete and ReadyForQuery first; the RESET's CommandComplete and ReadyForQuery last
|
|
372
|
+
const inner = frames.slice(first.type === "C" && frames[1]?.type === "Z" ? 2 : 0);
|
|
373
|
+
const n = inner.length;
|
|
374
|
+
const end = n >= 2 && inner[n - 1].type === "Z" && inner[n - 2].type === "C" ? n - 2 : n;
|
|
375
|
+
return { frames: inner.slice(0, end), setRoleRefused: null };
|
|
376
|
+
}
|
|
377
|
+
/** the placeholder parameter a session's settings ride in, for one request of its (`asSession`): named anew by each
|
|
378
|
+
* process, so no name a tool writes is it — `current_setting('mason.session')` answered the carrier's JSON where
|
|
379
|
+
* Postgres (and `SHOW`, and the same beside a copied table) answers 42704 (a review finding of #46). A placeholder
|
|
380
|
+
* stays defined once SET, so the name, not a RESET, is what keeps it out of reach. */
|
|
381
|
+
export const SESSION_SETTINGS = `mason_${randomBytes(8).toString("hex")}.session`;
|
|
382
|
+
/**
|
|
383
|
+
* THE CATALOG'S `current_setting(name[, missing_ok])` IS THE SESSION'S (GitHub issue 40: after `SET TimeZone` it answered
|
|
384
|
+
* the catalog's UTC, where `SHOW TimeZone` answered the session's): the value the session holds under that name, from
|
|
385
|
+
* the request's `SESSION_SETTINGS` (`asSession`: every parameter the listener's `sessionSetting` answers), else
|
|
386
|
+
* Postgres's own (`block_size`; its 42704, or NULL under missing_ok, for a name nobody knows). The `version()`
|
|
387
|
+
* override's way: search_path lists pg_catalog after public, so a statement reading no table and calling it is
|
|
388
|
+
* forwarded as ever; one that writes `pg_catalog.current_setting` is spliced to this one (server.ts
|
|
389
|
+
* userSpliced: Postgres's built-in runs its C function by OID, so its catalog row cannot be replaced). Plain
|
|
390
|
+
* SQL (plpgsql would load its library, and SHOW ALL list its `plpgsql.*`); STRICT, as Postgres's.
|
|
391
|
+
*/
|
|
392
|
+
export function currentSettingSql() {
|
|
393
|
+
const held = `nullif(pg_catalog.current_setting('${SESSION_SETTINGS}', true), '')::jsonb ->> lower($1)`;
|
|
394
|
+
return [
|
|
395
|
+
`CREATE OR REPLACE FUNCTION public.current_setting(text) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1)) $$`,
|
|
396
|
+
`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)) $$`,
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A SESSION'S CATALOG REQUEST WITH ITS SETTINGS: `SET <SESSION_SETTINGS> = '<its settings as JSON>'` before the request's
|
|
401
|
+
* frames and `RESET` after, in ONE message, as `asRole` wraps a role (around it, when the session has one) — the catalog
|
|
402
|
+
* runs one request at a time and a message whole, so no other session's request reads these settings, and none of this
|
|
403
|
+
* session's runs without them. A Sync is added before the RESET where the request has none (ignore_till_sync: a
|
|
404
|
+
* setting left set would be every session's). `settings` are the listener's `sessionSetting`, never read here. The
|
|
405
|
+
* catalog's search_path is pinned in the same SET (`public, pg_catalog`): the overrides of `current_setting` and
|
|
406
|
+
* `version()` win only while public comes first, and the request must never depend on what the catalog was left with.
|
|
407
|
+
*/
|
|
408
|
+
export function asSession(message, settings) {
|
|
409
|
+
const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
|
|
410
|
+
const frames = splitFrames(own);
|
|
411
|
+
const synced = frames.some((f) => f.type === "S") || frames.at(-1)?.type === "Q";
|
|
412
|
+
const json = JSON.stringify(Object.fromEntries(settings));
|
|
413
|
+
return Buffer.concat([frame("Q", cstr(`SET search_path = public, pg_catalog; SET ${SESSION_SETTINGS} = ${quoteLiteral(json)}`)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr(`RESET ${SESSION_SETTINGS}`))]);
|
|
414
|
+
}
|
|
415
|
+
/** The reply to an `asSession` message, less the SETs' and the RESET's own replies (two CommandCompletes and a
|
|
416
|
+
* ReadyForQuery first, a CommandComplete and a ReadyForQuery last). A SET the catalog refused is a Mason bug, said. */
|
|
417
|
+
export function unwrapSession(frames) {
|
|
418
|
+
const ok = frames[0]?.type === "C" && frames[1]?.type === "C" && frames[2]?.type === "Z";
|
|
419
|
+
const n = frames.length;
|
|
420
|
+
if (!ok || n < 5 || frames[n - 1].type !== "Z" || frames[n - 2].type !== "C") {
|
|
421
|
+
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
|
+
}
|
|
423
|
+
return frames.slice(3, n - 2);
|
|
424
|
+
}
|
|
246
425
|
/**
|
|
247
426
|
* WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
|
|
248
427
|
* 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 +436,7 @@ export function leftOutLine(r) {
|
|
|
257
436
|
return `catalog: a source default left out (${r.message}): ${r.statement}`;
|
|
258
437
|
const missing = /^relation "([^"]+)" does not exist$/.exec(r.message)?.[1];
|
|
259
438
|
const name = `${unquote(view[1])}.${unquote(view[2])}`;
|
|
260
|
-
return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not
|
|
439
|
+
return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not synced` : `catalog: view ${name} left out (${r.message})`;
|
|
261
440
|
}
|
|
262
441
|
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
263
442
|
/** One worker thread holding one PGlite: requests one at a time (PgCatalog's chain), each with a deadline. */
|
|
@@ -303,7 +482,8 @@ class CatalogWorker {
|
|
|
303
482
|
return new Promise((resolve, reject) => {
|
|
304
483
|
const id = this.#next++;
|
|
305
484
|
const timer = setTimeout(() => {
|
|
306
|
-
|
|
485
|
+
// the message reaches the client on the wire and the request log: Mason's words, never the catalog's engine's name
|
|
486
|
+
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
487
|
void this.#worker.terminate();
|
|
308
488
|
}, ms);
|
|
309
489
|
timer.unref();
|
|
@@ -318,6 +498,7 @@ class CatalogWorker {
|
|
|
318
498
|
}
|
|
319
499
|
export class PgCatalog {
|
|
320
500
|
#schema;
|
|
501
|
+
#roles;
|
|
321
502
|
#idleMs;
|
|
322
503
|
#statementMs;
|
|
323
504
|
#log;
|
|
@@ -331,8 +512,12 @@ export class PgCatalog {
|
|
|
331
512
|
#chain = Promise.resolve();
|
|
332
513
|
#idleTimer = null;
|
|
333
514
|
#lastUsed = 0;
|
|
515
|
+
/** the roles' row security policies (`catalogPolicies`), written once the listener checked the filters: run on the
|
|
516
|
+
* instance up now and at every start after, until the schema or the roles are replaced */
|
|
517
|
+
#policies = [];
|
|
334
518
|
constructor(schema, opts = {}) {
|
|
335
519
|
this.#schema = schema;
|
|
520
|
+
this.#roles = opts.roles ?? new Map();
|
|
336
521
|
this.#idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
|
|
337
522
|
this.#statementMs = opts.statementMs ?? DEFAULT_STATEMENT_MS;
|
|
338
523
|
this.#log = opts.log ?? ((line) => console.error(line));
|
|
@@ -367,13 +552,36 @@ export class PgCatalog {
|
|
|
367
552
|
stop() {
|
|
368
553
|
return this.#enqueue(() => this.#close("stopped"));
|
|
369
554
|
}
|
|
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) {
|
|
555
|
+
/** Swaps in a new schema (after an apply), and the roles with it (the roles file moved): the running instance is
|
|
556
|
+
* stopped and the next `run` starts with the new DDL. Resolves once the old instance is gone; a caller need not wait for it. */
|
|
557
|
+
replace(schema, roles = this.#roles) {
|
|
373
558
|
this.#schema = schema;
|
|
559
|
+
this.#roles = roles;
|
|
560
|
+
this.#policies = [];
|
|
374
561
|
this.#said.clear();
|
|
375
562
|
return this.#enqueue(() => this.#close("replaced"));
|
|
376
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* THE ROLES' ROW SECURITY POLICIES (`catalogPolicies`), written now (the instance started where needed) and at every
|
|
566
|
+
* start after, until the next `replace`. A statement the catalog refuses is said, never fatal: the rows are the copy's.
|
|
567
|
+
*/
|
|
568
|
+
policies(ddl) {
|
|
569
|
+
return this.#enqueue(async () => {
|
|
570
|
+
this.#policies = ddl;
|
|
571
|
+
if (ddl.length === 0)
|
|
572
|
+
return;
|
|
573
|
+
const db = await this.#ensureStarted();
|
|
574
|
+
for (const sql of ddl) {
|
|
575
|
+
try {
|
|
576
|
+
await this.#ask(db, { op: "query", sql }, "a row security policy");
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
this.#log(`catalog: a row security policy left out (${errorMessage(e)}): ${sql}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
this.#touch();
|
|
583
|
+
});
|
|
584
|
+
}
|
|
377
585
|
#enqueue(fn) {
|
|
378
586
|
const result = this.#chain.then(fn);
|
|
379
587
|
// the chain only ORDERS calls: a rejection reaches the caller through `result`, and the next call must still run
|
|
@@ -401,11 +609,12 @@ export class PgCatalog {
|
|
|
401
609
|
if (this.#db !== null && this.#db.alive)
|
|
402
610
|
return this.#db;
|
|
403
611
|
this.#db = null;
|
|
404
|
-
const schema = this.#schema;
|
|
612
|
+
const schema = this.#schema, roles = this.#roles;
|
|
405
613
|
const t0 = Date.now();
|
|
406
614
|
const db = new CatalogWorker();
|
|
407
615
|
try {
|
|
408
|
-
const started = await this.#ask(db, { op: "init", statements: ["SET TimeZone = 'UTC'", ...catalogDdl(schema), ...
|
|
616
|
+
const started = await this.#ask(db, { op: "init", statements: ["SET TimeZone = 'UTC'", ...catalogDdl(schema), ...catalogRoles(schema, roles), ...this.#setup],
|
|
617
|
+
optional: [...catalogDefaults(schema), ...catalogViews(schema, roles), ...this.#policies] }, "its start");
|
|
409
618
|
// each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
|
|
410
619
|
for (const r of started.refused ?? []) {
|
|
411
620
|
const line = leftOutLine(r);
|
|
@@ -423,7 +632,7 @@ export class PgCatalog {
|
|
|
423
632
|
throw new Error(`PGlite could not start: ${errorMessage(e)}`, { cause: e });
|
|
424
633
|
}
|
|
425
634
|
this.#db = db;
|
|
426
|
-
this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)`);
|
|
635
|
+
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
636
|
this.#touch();
|
|
428
637
|
return db;
|
|
429
638
|
}
|
|
@@ -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");
|
|
@@ -75,12 +75,10 @@ class BodyReader {
|
|
|
75
75
|
rest() { return Buffer.from(this.#buf.subarray(this.#off)); }
|
|
76
76
|
get done() { return this.#off >= this.#buf.length; }
|
|
77
77
|
}
|
|
78
|
-
/** The
|
|
79
|
-
*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function frameLength(buf, headerLength, limit) {
|
|
83
|
-
const len = buf.readInt32BE(headerLength - 4);
|
|
78
|
+
/** The length field of the frame at `off` in `buf` (type byte excluded, the field itself included), checked against
|
|
79
|
+
* `limit`; `headerLength` is 5 with a type byte, 4 in the startup phase. */
|
|
80
|
+
function frameLength(buf, off, headerLength, limit) {
|
|
81
|
+
const len = buf.readInt32BE(off + headerLength - 4);
|
|
84
82
|
if (len < 4)
|
|
85
83
|
throw new ProtocolError(`malformed message: a length of ${len} (the length counts itself, so it is at least 4)`);
|
|
86
84
|
if (len > limit)
|
|
@@ -89,8 +87,10 @@ function frameLength(buf, headerLength, limit) {
|
|
|
89
87
|
}
|
|
90
88
|
/** Decodes what a frontend sends. Push every chunk the socket delivers; call `next()` until it returns null. */
|
|
91
89
|
export class FrontendDecoder {
|
|
92
|
-
/** the bytes
|
|
90
|
+
/** the chunks joined so far: consumed bytes before `#off`, then the frames not yet read, the last perhaps partial */
|
|
93
91
|
#buf = Buffer.alloc(0);
|
|
92
|
+
/** where the next frame starts in `#buf`: a frame consumed moves this, never the bytes behind it (BackendDecoder) */
|
|
93
|
+
#off = 0;
|
|
94
94
|
/** chunks received since, concatenated ONCE, when the frame they complete is whole (never per chunk: a frame in
|
|
95
95
|
* 64 KiB pieces re-copied everything received so far on every piece — review of #480) */
|
|
96
96
|
#pending = [];
|
|
@@ -106,13 +106,16 @@ export class FrontendDecoder {
|
|
|
106
106
|
this.#pending.push(Buffer.from(chunk));
|
|
107
107
|
this.#pendingLength += chunk.length;
|
|
108
108
|
}
|
|
109
|
-
/** Makes `#buf` hold at least `need` bytes when that many have arrived, moving the pending chunks in with
|
|
109
|
+
/** Makes `#buf` hold at least `need` bytes past `#off` when that many have arrived, moving the pending chunks in with
|
|
110
|
+
* one copy, which leaves the consumed bytes behind. */
|
|
110
111
|
#assemble(need) {
|
|
111
|
-
|
|
112
|
+
const held = this.#buf.length - this.#off;
|
|
113
|
+
if (held >= need)
|
|
112
114
|
return true;
|
|
113
|
-
if (
|
|
115
|
+
if (held + this.#pendingLength < need)
|
|
114
116
|
return false;
|
|
115
|
-
this.#buf = Buffer.concat([this.#buf, ...this.#pending]);
|
|
117
|
+
this.#buf = Buffer.concat([this.#buf.subarray(this.#off), ...this.#pending]);
|
|
118
|
+
this.#off = 0;
|
|
116
119
|
this.#pending = [];
|
|
117
120
|
this.#pendingLength = 0;
|
|
118
121
|
return true;
|
|
@@ -126,12 +129,18 @@ export class FrontendDecoder {
|
|
|
126
129
|
#whole(headerLength) {
|
|
127
130
|
if (!this.#assemble(headerLength))
|
|
128
131
|
return null;
|
|
129
|
-
const len = frameLength(this.#buf, headerLength, this.#startup ? Math.min(this.maxLength, MAX_STARTUP_LENGTH) : this.maxLength);
|
|
132
|
+
const len = frameLength(this.#buf, this.#off, headerLength, this.#startup ? Math.min(this.maxLength, MAX_STARTUP_LENGTH) : this.maxLength);
|
|
130
133
|
return this.#assemble(headerLength - 4 + len) ? len : null;
|
|
131
134
|
}
|
|
135
|
+
/** The frame's body, a view of `#buf` that lives as long as its BodyReader: every value a reader returns is a copy. */
|
|
132
136
|
#take(headerLength, len) {
|
|
133
|
-
const body = this.#buf.subarray(headerLength, headerLength - 4 + len);
|
|
134
|
-
this.#
|
|
137
|
+
const body = this.#buf.subarray(this.#off + headerLength, this.#off + headerLength - 4 + len);
|
|
138
|
+
this.#off += headerLength - 4 + len;
|
|
139
|
+
// wholly consumed, it is let go: a connection idle on a frame boundary holds no bytes of what it last sent
|
|
140
|
+
if (this.#off === this.#buf.length) {
|
|
141
|
+
this.#buf = Buffer.alloc(0);
|
|
142
|
+
this.#off = 0;
|
|
143
|
+
}
|
|
135
144
|
return body;
|
|
136
145
|
}
|
|
137
146
|
#nextStartup() {
|
|
@@ -165,7 +174,7 @@ export class FrontendDecoder {
|
|
|
165
174
|
const len = this.#whole(5);
|
|
166
175
|
if (len === null)
|
|
167
176
|
return null;
|
|
168
|
-
const code = String.fromCharCode(this.#buf[
|
|
177
|
+
const code = String.fromCharCode(this.#buf[this.#off]);
|
|
169
178
|
const r = new BodyReader(this.#take(5, len), `'${code}'`);
|
|
170
179
|
switch (code) {
|
|
171
180
|
case "p": return { type: "Password", password: r.cstring() };
|
|
@@ -353,22 +362,29 @@ function decodeBackendBody(code, body) {
|
|
|
353
362
|
/** Decodes what a backend sends, chunk by chunk, as FrontendDecoder does for the other direction. */
|
|
354
363
|
export class BackendDecoder {
|
|
355
364
|
#buf = Buffer.alloc(0);
|
|
365
|
+
/** WHERE THE NEXT FRAME STARTS IN `#buf`: `next()` moves this and `push` drops what is behind it. Copying the rest off
|
|
366
|
+
* after every message was quadratic in a reply: a 30 MB reply of 150,000 rows took about six minutes on CI (issue
|
|
367
|
+
* 77). A new buffer is always allocated, never written over, and no decoded message keeps a view of one (BodyReader
|
|
368
|
+
* copies). */
|
|
369
|
+
#off = 0;
|
|
356
370
|
push(chunk) {
|
|
357
|
-
|
|
371
|
+
const rest = this.#buf.subarray(this.#off);
|
|
372
|
+
this.#buf = rest.length === 0 ? Buffer.from(chunk) : Buffer.concat([rest, chunk]);
|
|
373
|
+
this.#off = 0;
|
|
358
374
|
}
|
|
359
375
|
next() {
|
|
360
|
-
if (this
|
|
376
|
+
if (this.pending < 5)
|
|
361
377
|
return null;
|
|
362
|
-
const len = frameLength(this.#buf, 5, MAX_MESSAGE_LENGTH);
|
|
363
|
-
if (this
|
|
378
|
+
const len = frameLength(this.#buf, this.#off, 5, MAX_MESSAGE_LENGTH);
|
|
379
|
+
if (this.pending < 1 + len)
|
|
364
380
|
return null;
|
|
365
|
-
const code = String.fromCharCode(this.#buf[
|
|
366
|
-
const body = this.#buf.subarray(5, 1 + len);
|
|
367
|
-
this.#
|
|
381
|
+
const code = String.fromCharCode(this.#buf[this.#off]);
|
|
382
|
+
const body = this.#buf.subarray(this.#off + 5, this.#off + 1 + len);
|
|
383
|
+
this.#off += 1 + len;
|
|
368
384
|
return decodeBackendBody(code, body);
|
|
369
385
|
}
|
|
370
386
|
/** bytes pushed and not yet consumed by a whole message */
|
|
371
|
-
get pending() { return this.#buf.length; }
|
|
387
|
+
get pending() { return this.#buf.length - this.#off; }
|
|
372
388
|
}
|
|
373
389
|
/** Every message of a buffer that holds whole messages only (a `execProtocolRaw` reply, a builder's output). A
|
|
374
390
|
* trailing partial message throws: the caller promised a complete reply. */
|