@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.
Files changed (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1173 -207
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. 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 (the deployment): current_database(), pg_database — pgjdbc's
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 deployment named `template1` failed on pg_database_datname_index and the catalog never started).
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
- return `CREATE SCHEMA IF NOT EXISTS ${ident(v.schema)}; CREATE VIEW ${name} AS ${v.definition.replace(/;\s*$/, "")};${comment}`;
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 copied` : `catalog: view ${name} left out (${r.message})`;
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
- this.#die(new Error(`the catalog did not answer ${what} within ${ms / 1000} s: PGlite was discarded (the next statement starts a fresh one; MASON_PG_CATALOG_TIMEOUT_MS raises the deadline)`));
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): the running instance is stopped and the next `run` starts with the new
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), ...this.#setup], optional: [...catalogDefaults(schema), ...catalogViews(schema)] }, "its start");
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
- constructor(message, code = "XX000") { super(message); this.code = code; }
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 (Mason gap explain-option)`, at, "0A000", "bind the parameters' values and EXPLAIN that: DuckDB plans the statement with them");
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 (Mason gap explain-option)`, at, "0A000", "write FORMAT TEXT or FORMAT JSON");
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 next frame of `buf` at `off`: its length (type byte excluded, length field included) when the frame is whole,
79
- * or null for "need more bytes". `headerLength` is 5 with a type byte, 4 in the startup phase. */
80
- /** The length field of the frame at the start of `buf` (type byte excluded, the field itself included), checked
81
- * against `limit`; `headerLength` is 5 with a type byte, 4 in the startup phase. */
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 of the frame being assembled, once its header has arrived */
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 one copy. */
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
- if (this.#buf.length >= need)
112
+ const held = this.#buf.length - this.#off;
113
+ if (held >= need)
112
114
  return true;
113
- if (this.#buf.length + this.#pendingLength < need)
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.#buf = Buffer.from(this.#buf.subarray(headerLength - 4 + len));
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[0]);
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
- this.#buf = this.#buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buf, chunk]);
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.#buf.length < 5)
376
+ if (this.pending < 5)
361
377
  return null;
362
- const len = frameLength(this.#buf, 5, MAX_MESSAGE_LENGTH);
363
- if (this.#buf.length < 1 + len)
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[0]);
366
- const body = this.#buf.subarray(5, 1 + len);
367
- this.#buf = Buffer.from(this.#buf.subarray(1 + len));
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. */