@mason-data/cli 0.6.2 → 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 (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  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 +1290 -223
  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/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. 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("'", "''")}'`;
@@ -163,7 +168,42 @@ export function catalogDdl(schema) {
163
168
  statements.push(`COMMENT ON COLUMN ${qualified(table)}.${ident(c.name)} IS ${literal(parts.join("; "))};`);
164
169
  }
165
170
  }
166
- return [...statements, ...constraints];
171
+ return [...statements, ...constraints, ...sourceTypeNames(schema)];
172
+ }
173
+ /**
174
+ * THE NAME OF A TYPE BY THE OID THE WIRE SENDS FOR IT. A column of a type the source made (an enum, a citext) is
175
+ * sent with the SOURCE's OID (src/duck/plan.ts `userTypes`: what the source's own clients cached), which in this
176
+ * catalog means another type or none (both count from 16384): `format_type(<that oid>, …)` answered `???`, or another
177
+ * table's row type. Two readers ask by two OID spaces, and one OID can be in both:
178
+ * - `public.mason_wire_type(oid, integer)` names an OID the WIRE sent: a source OID as its type here, every other as
179
+ * Postgres does. `mason mcp` names a RowDescription's columns by it; this map is the one place a wire OID is named;
180
+ * - the catalog's `format_type(oid, integer)` — the `version()` override's way, search_path listing pg_catalog after
181
+ * public — keeps the catalog's meaning for an OID the catalog has (a reader of pg_attribute.atttypid asks by those)
182
+ * and names any other by the wire's map. `pg_catalog.format_type`, written out, is Postgres's own.
183
+ */
184
+ export function sourceTypeNames(schema) {
185
+ const names = new Map();
186
+ for (const t of schema.tables) {
187
+ if (t.rowTypeOid !== undefined)
188
+ names.set(t.rowTypeOid, qualified(t));
189
+ for (const c of t.columns) {
190
+ if (c.typeOid === undefined || c.spelled === undefined)
191
+ continue;
192
+ // an array column's own OID is its array type's; an element's array type is the element's spelling with []
193
+ names.set(c.typeOid, c.spelled);
194
+ if (c.typeArrayOid !== undefined && !c.spelled.endsWith("[]"))
195
+ names.set(c.typeArrayOid, `${c.spelled}[]`);
196
+ }
197
+ }
198
+ // with no type the source made, every OID the wire sends is Postgres's: the wire's name is format_type's
199
+ if (names.size === 0)
200
+ return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
201
+ const values = [...names].map(([oid, spelled]) => `(${oid}::oid, ${literal(spelled)})`).join(", ");
202
+ return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
203
+ 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),
204
+ pg_catalog.format_type($1, $2)) $$;`,
205
+ `CREATE OR REPLACE FUNCTION public.format_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
206
+ SELECT CASE WHEN EXISTS (SELECT FROM pg_catalog.pg_type t WHERE t.oid = $1) THEN pg_catalog.format_type($1, $2) ELSE public.mason_wire_type($1, $2) END $$;`];
167
207
  }
168
208
  /**
169
209
  * The source's column defaults, one statement each, run after the schema's DDL by
@@ -199,15 +239,189 @@ export function catalogDefaults(schema) {
199
239
  * The source's views, one statement each beside `catalogDefaults`'s: a view reading a table
200
240
  * Mason has not copied (or a function the catalog lacks) is said in the log and left out. Each definition qualifies
201
241
  * every name outside pg_catalog (src/cli/preflight.ts VIEWS_SQL), so it reads the same tables here; a view over a view
202
- * 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.
203
244
  */
204
- export function catalogViews(schema) {
245
+ export function catalogViews(schema, roles = new Map()) {
205
246
  return (schema.views ?? []).map((v) => {
206
247
  const name = `${ident(v.schema)}.${ident(v.name)}`;
207
248
  const comment = v.description ? ` COMMENT ON VIEW ${name} IS ${literal(v.description)};` : "";
208
- 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}`;
209
253
  });
210
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
+ }
211
425
  /**
212
426
  * WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
213
427
  * view's whole definition at each start of the instance — 7 multi-line entries per start, most of a 190 KB log, their
@@ -222,7 +436,7 @@ export function leftOutLine(r) {
222
436
  return `catalog: a source default left out (${r.message}): ${r.statement}`;
223
437
  const missing = /^relation "([^"]+)" does not exist$/.exec(r.message)?.[1];
224
438
  const name = `${unquote(view[1])}.${unquote(view[2])}`;
225
- 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})`;
226
440
  }
227
441
  const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
228
442
  /** One worker thread holding one PGlite: requests one at a time (PgCatalog's chain), each with a deadline. */
@@ -268,7 +482,8 @@ class CatalogWorker {
268
482
  return new Promise((resolve, reject) => {
269
483
  const id = this.#next++;
270
484
  const timer = setTimeout(() => {
271
- 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)`));
272
487
  void this.#worker.terminate();
273
488
  }, ms);
274
489
  timer.unref();
@@ -283,6 +498,7 @@ class CatalogWorker {
283
498
  }
284
499
  export class PgCatalog {
285
500
  #schema;
501
+ #roles;
286
502
  #idleMs;
287
503
  #statementMs;
288
504
  #log;
@@ -296,8 +512,12 @@ export class PgCatalog {
296
512
  #chain = Promise.resolve();
297
513
  #idleTimer = null;
298
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 = [];
299
518
  constructor(schema, opts = {}) {
300
519
  this.#schema = schema;
520
+ this.#roles = opts.roles ?? new Map();
301
521
  this.#idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
302
522
  this.#statementMs = opts.statementMs ?? DEFAULT_STATEMENT_MS;
303
523
  this.#log = opts.log ?? ((line) => console.error(line));
@@ -332,13 +552,36 @@ export class PgCatalog {
332
552
  stop() {
333
553
  return this.#enqueue(() => this.#close("stopped"));
334
554
  }
335
- /** Swaps in a new schema (after an apply): the running instance is stopped and the next `run` starts with the new
336
- * DDL. Resolves once the old instance is gone; a caller need not wait for it. */
337
- 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) {
338
558
  this.#schema = schema;
559
+ this.#roles = roles;
560
+ this.#policies = [];
339
561
  this.#said.clear();
340
562
  return this.#enqueue(() => this.#close("replaced"));
341
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
+ }
342
585
  #enqueue(fn) {
343
586
  const result = this.#chain.then(fn);
344
587
  // the chain only ORDERS calls: a rejection reaches the caller through `result`, and the next call must still run
@@ -366,11 +609,12 @@ export class PgCatalog {
366
609
  if (this.#db !== null && this.#db.alive)
367
610
  return this.#db;
368
611
  this.#db = null;
369
- const schema = this.#schema;
612
+ const schema = this.#schema, roles = this.#roles;
370
613
  const t0 = Date.now();
371
614
  const db = new CatalogWorker();
372
615
  try {
373
- 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");
374
618
  // each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
375
619
  for (const r of started.refused ?? []) {
376
620
  const line = leftOutLine(r);
@@ -388,7 +632,7 @@ export class PgCatalog {
388
632
  throw new Error(`PGlite could not start: ${errorMessage(e)}`, { cause: e });
389
633
  }
390
634
  this.#db = db;
391
- 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)` : ""}`);
392
636
  this.#touch();
393
637
  return db;
394
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. */