@mason-data/cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -31,10 +31,14 @@
31
31
  * The instance's TimeZone is UTC (the API treats every time as UTC, §4.8); its search_path is Postgres's default, so
32
32
  * `public."orders"` and `orders` both resolve, as a tool expects.
33
33
  */
34
+ import { randomBytes } from "node:crypto";
34
35
  import { Worker } from "node:worker_threads";
35
36
  import { fileURLToPath } from "node:url";
36
37
  import { runnable } from "../../lib/runnable.js";
37
- import { PG_API_TYPE_SPELLING, typeNameParts } from "./schema.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";
41
+ import { HELD_ROW_INPUT, HELD_TYPE_INPUT, madeTypeName, PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
38
42
  /** Ten minutes: long enough to outlive a tool's startup burst and a person's pause between two queries. */
39
43
  const DEFAULT_IDLE_MS = 10 * 60_000;
40
44
  /** A catalog statement's deadline (`MASON_PG_CATALOG_TIMEOUT_MS`): a tool's introspection answers in milliseconds; a
@@ -46,6 +50,7 @@ export const DEFAULT_STATEMENT_MS = 10_000;
46
50
  export const MIN_START_MS = 30_000;
47
51
  /** A SQL identifier, double-quoted with an inner quote doubled. */
48
52
  const ident = (name) => `"${name.replaceAll('"', '""')}"`;
53
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
49
54
  /** A SQL string literal, single-quoted with an inner quote doubled. */
50
55
  const literal = (text) => `'${text.replaceAll("'", "''")}'`;
51
56
  /** a table's name in the catalog: its schema (Mason's source schema, else public) and name */
@@ -70,11 +75,11 @@ function isForeignKey(schema, table, join) {
70
75
  }
71
76
  const joinNote = (join) => `joins ${join.table} on ${join.on[1]} (${join.relationship})`;
72
77
  /**
73
- * THE CATALOG'S DATABASE IS THE ONE A TOOL CONNECTED TO (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("'", "''")}'`;
@@ -112,12 +117,19 @@ export function catalogDdl(schema) {
112
117
  for (const name of [...new Set(schema.tables.map((t) => t.schema).filter((x) => x !== undefined && x !== "public"))]) {
113
118
  statements.push(`CREATE SCHEMA IF NOT EXISTS ${ident(name)};`);
114
119
  }
120
+ // THE SOURCE'S COLLATIONS (src/duck/collation.ts), before the tables: each the catalog lacks, declared under its name with
121
+ // the source's provider, locale and determinism (a locale PGlite cannot open declared on its root locale: what it
122
+ // MEANS is the source's row of its name, read by the resolver, never this one's), so a column's COLLATE and a
123
+ // statement's resolve as on the source. In pg_catalog, public, a table's schema, or a column's collation's schema
124
+ statements.push(...collationDdl(schema));
115
125
  // a source enum a column has: the type, its labels in their declared order, before the tables —
116
126
  // spelled as the source's format_type spells it (`mood`, `other.level`, `"Odd"`), its schema made first
117
127
  const enums = new Map();
128
+ // one declaration a type, the FIRST column's: the synced tables come first, and src/duck/sync.ts hands a generated
129
+ // table's column its synced type's labels (one source of an enum's labels), so no later column's can replace them
118
130
  for (const t of schema.tables)
119
131
  for (const c of t.columns)
120
- if (c.enumLabels !== undefined && c.spelled !== undefined)
132
+ if (c.enumLabels !== undefined && c.spelled !== undefined && !enums.has(c.spelled.replace(/\[\]$/, "")))
121
133
  enums.set(c.spelled.replace(/\[\]$/, ""), c.enumLabels);
122
134
  for (const [type, labels] of enums) {
123
135
  const qualifier = typeNameParts(type).schema;
@@ -125,10 +137,22 @@ export function catalogDdl(schema) {
125
137
  statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
126
138
  statements.push(`CREATE TYPE ${type} AS ENUM (${labels.map(literal).join(", ")});`);
127
139
  }
140
+ // a type a database made, held as its text: its name, the text's input and output, and nothing over it
141
+ const made = new Map(schema.tables.flatMap((t) => t.columns.filter((c) => c.made !== undefined).map((c) => [madeTypeName(c.spelled), c.made])));
142
+ [...made].forEach(([type, kind], i) => {
143
+ const input = kind === "row" ? HELD_ROW_INPUT : HELD_TYPE_INPUT;
144
+ const qualifier = typeNameParts(type).schema;
145
+ if (qualifier !== null && qualifier !== '"public"' && qualifier !== "public")
146
+ statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
147
+ statements.push(`CREATE TYPE ${type};`, `CREATE FUNCTION pg_catalog.${input}${i}(cstring) RETURNS ${type} LANGUAGE internal IMMUTABLE STRICT AS 'textin';`, `CREATE FUNCTION pg_catalog.mason_held_out${i}(${type}) RETURNS cstring LANGUAGE internal IMMUTABLE STRICT AS 'textout';`, `CREATE TYPE ${type} (INPUT = pg_catalog.${input}${i}, OUTPUT = pg_catalog.mason_held_out${i}, LIKE = text);`);
148
+ });
128
149
  for (const table of schema.tables) {
129
150
  const lines = [];
130
- for (const c of table.columns)
131
- lines.push(`${ident(c.name)} ${c.spelled ?? PG_API_TYPE_SPELLING[c.type]}${c.notNull === true ? " NOT NULL" : ""}`);
151
+ for (const c of table.columns) {
152
+ // a column's own collation: the source's, so Postgres's analysis orders it as the source does
153
+ const collate = c.collation !== undefined ? ` COLLATE ${ident(c.collation.schema)}.${ident(c.collation.name)}` : "";
154
+ lines.push(`${ident(c.name)} ${c.made !== undefined ? `${madeTypeName(c.spelled)}${c.spelled.endsWith("[]") ? "[]" : ""}` : c.spelled ?? PG_API_TYPE_SPELLING[c.type]}${collate}${c.notNull === true ? " NOT NULL" : ""}`);
155
+ }
132
156
  if (table.primaryKey.length > 0)
133
157
  lines.push(`PRIMARY KEY (${table.primaryKey.map(ident).join(", ")})`);
134
158
  statements.push(`CREATE TABLE ${qualified(table)} (\n ${lines.join(",\n ")}\n);`);
@@ -165,6 +189,38 @@ export function catalogDdl(schema) {
165
189
  }
166
190
  return [...statements, ...constraints, ...sourceTypeNames(schema)];
167
191
  }
192
+ /**
193
+ * THE SOURCE'S COLLATIONS THE CATALOG LACKS, declared (one DO block: ~800 ICU collations in tens of milliseconds): each
194
+ * under its schema and name, by the source's provider, locale and determinism, else — a locale PGlite's ICU or libc
195
+ * cannot open — the same name on the root locale (ICU), C (libc) or C (builtin), which its analysis treats alike: the
196
+ * resolver reads what a collation means from the SOURCE's row of its name (src/duck/collation.ts), never this one. Only
197
+ * where a name can be met: pg_catalog, public, a table's schema, and a synced column's collation's own schema.
198
+ */
199
+ export function collationDdl(schema) {
200
+ const used = schema.tables.flatMap((t) => t.columns.flatMap((c) => c.collation !== undefined ? [c.collation] : []));
201
+ const schemas = new Set(["pg_catalog", "public", ...schema.tables.map((t) => t.schema ?? "public"), ...used.map((c) => c.schema)]);
202
+ const declared = (schema.collations ?? []).filter((c) => c.provider !== "d" && schemas.has(c.schema));
203
+ if (declared.length === 0)
204
+ return [];
205
+ const out = [];
206
+ for (const s of new Set(declared.map((c) => c.schema)))
207
+ if (s !== "pg_catalog" && s !== "public")
208
+ out.push(`CREATE SCHEMA IF NOT EXISTS ${ident(s)};`);
209
+ // ONE INSERT of the rows, never a CREATE COLLATION each: a source lists about 1,600 (every ICU locale and every libc
210
+ // one the OS has), and a CREATE each took the catalog's start past its deadline (about 18 ms each, measured; the
211
+ // INSERT, 260 ms for 1,600). The catalog only ANALYSES under them: what a statement orders by is the synced data's, so
212
+ // a locale PGlite has no data for is never opened. A name the catalog has already (`C`, `und-x-icu`) is the source's
213
+ // too; the OIDs are past any the catalog makes, so none collides
214
+ const rows = declared.map((c) => ({ schema: c.schema, name: c.name, provider: c.provider, collate: c.collate, locale: c.locale, deterministic: c.deterministic }));
215
+ out.push(`INSERT INTO pg_catalog.pg_collation (oid, collname, collnamespace, collowner, collprovider, collisdeterministic, collencoding, collcollate, collctype, colllocale)
216
+ SELECT (greatest((SELECT max(oid)::bigint FROM pg_catalog.pg_collation), 3000000000) + row_number() OVER ())::oid, c.name, n.oid, 10, c.provider, c.deterministic,
217
+ CASE WHEN c.provider = 'c' THEN 6 ELSE -1 END, CASE WHEN c.provider = 'c' THEN c."collate" END, CASE WHEN c.provider = 'c' THEN c."collate" END,
218
+ CASE WHEN c.provider <> 'c' THEN coalesce(c.locale, c."collate") END
219
+ FROM json_to_recordset(${literal(JSON.stringify(rows))}) AS c(schema text, name text, provider "char", "collate" text, locale text, deterministic boolean)
220
+ JOIN pg_catalog.pg_namespace n ON n.nspname = c.schema
221
+ WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_collation x WHERE x.collnamespace = n.oid AND x.collname = c.name AND x.collencoding IN (-1, 6));`);
222
+ return out;
223
+ }
168
224
  /**
169
225
  * THE NAME OF A TYPE BY THE OID THE WIRE SENDS FOR IT. A column of a type the source made (an enum, a citext) is
170
226
  * sent with the SOURCE's OID (src/duck/plan.ts `userTypes`: what the source's own clients cached), which in this
@@ -185,14 +241,19 @@ export function sourceTypeNames(schema) {
185
241
  if (c.typeOid === undefined || c.spelled === undefined)
186
242
  continue;
187
243
  // an array column's own OID is its array type's; an element's array type is the element's spelling with []
188
- names.set(c.typeOid, c.spelled);
189
- if (c.typeArrayOid !== undefined && !c.spelled.endsWith("[]"))
190
- names.set(c.typeArrayOid, `${c.spelled}[]`);
244
+ const spelled = c.made !== undefined ? `${madeTypeName(c.spelled)}${c.spelled.endsWith("[]") ? "[]" : ""}` : c.spelled;
245
+ names.set(c.typeOid, spelled);
246
+ if (c.typeArrayOid !== undefined && !spelled.endsWith("[]"))
247
+ names.set(c.typeArrayOid, `${spelled}[]`);
191
248
  }
192
249
  }
193
250
  // with no type the source made, every OID the wire sends is Postgres's: the wire's name is format_type's
194
- if (names.size === 0)
195
- return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
251
+ // (format_type is declared all the same: a catalog statement's every unqualified call of it is spliced to this one,
252
+ // server.ts userSpliced, since the session's search_path puts pg_catalog first)
253
+ if (names.size === 0) {
254
+ return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`,
255
+ `CREATE OR REPLACE FUNCTION public.format_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
256
+ }
196
257
  const values = [...names].map(([oid, spelled]) => `(${oid}::oid, ${literal(spelled)})`).join(", ");
197
258
  return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
198
259
  SELECT coalesce((SELECT pg_catalog.format_type(pg_catalog.to_regtype(v.spelled), $2) FROM (VALUES ${values}) AS v(source, spelled) WHERE v.source = $1),
@@ -234,15 +295,265 @@ export function catalogDefaults(schema) {
234
295
  * The source's views, one statement each beside `catalogDefaults`'s: a view reading a table
235
296
  * Mason has not copied (or a function the catalog lacks) is said in the log and left out. Each definition qualifies
236
297
  * every name outside pg_catalog (src/cli/preflight.ts VIEWS_SQL), so it reads the same tables here; a view over a view
237
- * is created once the other is (the worker runs these in passes).
298
+ * is created once the other is (the worker runs these in passes). Every role may SEE every view (`catalogRoles`: the
299
+ * grant is in the view's own statement, since the view must exist first): reading one is held to the tables it reads.
238
300
  */
239
- export function catalogViews(schema) {
301
+ export function catalogViews(schema, roles = new Map()) {
240
302
  return (schema.views ?? []).map((v) => {
241
303
  const name = `${ident(v.schema)}.${ident(v.name)}`;
242
304
  const comment = v.description ? ` COMMENT ON VIEW ${name} IS ${literal(v.description)};` : "";
243
- return `CREATE SCHEMA IF NOT EXISTS ${ident(v.schema)}; CREATE VIEW ${name} AS ${v.definition.replace(/;\s*$/, "")};${comment}`;
305
+ // every role of the file may see a view; the catalog's role of a user held to nothing sees none (as a login with no
306
+ // grant on Postgres)
307
+ const granted = [...roles.keys()].filter((r) => r !== NO_ROLE_CATALOG).map((r) => ` GRANT USAGE ON SCHEMA ${ident(v.schema)} TO ${ident(r)}; GRANT SELECT ON ${name} TO ${ident(r)};`).join("");
308
+ return `CREATE SCHEMA IF NOT EXISTS ${ident(v.schema)}; CREATE VIEW ${name} AS ${v.definition.replace(/;\s*$/, "")};${comment}${granted}`;
244
309
  });
245
310
  }
311
+ /**
312
+ * THE CATALOG'S ROLES MIRROR THE ROLES FILE (GitHub issue 30; src/cli/roles.ts, src/duck/grants.ts): one Postgres role
313
+ * per Mason role, NOLOGIN, with `GRANT SELECT` on each table the role reads whole and `GRANT SELECT (columns)` on
314
+ * each it reads in part, and USAGE on every schema the copied tables are in. A session with a role runs its catalog
315
+ * statements under `SET ROLE` (`asRole`), so `information_schema.tables` / `.columns`, `has_table_privilege` and
316
+ * `has_column_privilege` answer for the role as Postgres's own do — and `mason mcp`'s `tables` and `describe`, which
317
+ * ask them, list a role what it may read. `pg_catalog` itself (pg_class, pg_attribute) does not filter by privilege
318
+ * on Postgres either; it still lists every synced column, said in docs/POSTGRES-API.md. A table the role names that the
319
+ * schema does not hold yet (added, not copied) is granted at the rebuild its copy brings; a column the copy lacks is
320
+ * left out (the roles file was held to the copy already, src/cli/roles.ts). Rebuilt with the schema: the server
321
+ * replaces the catalog when the roles move, as it does when the tables do. **`set_config` is revoked from PUBLIC
322
+ * while roles exist** (the review of #35): the catalog's one connection is the superuser's with `SET ROLE` on top, so
323
+ * inside one statement `set_config('role', 'none', false)` was a permitted RESET ROLE and the statement's later
324
+ * targets ran as the superuser (`information_schema` listed every hidden column's name); revoked, a role's session is
325
+ * refused it (42501 `permission denied for function set_config`) and the superuser keeps it. A role of `tables: all`
326
+ * (stage 1b) is resolved to a whole-table grant on every copied table (src/cli/roles.ts resolveRoles), so it is written
327
+ * here as per-table `GRANT SELECT`s, never `GRANT SELECT ON ALL TABLES IN SCHEMA`: the catalog is rebuilt from the
328
+ * resolved grants whenever the copied tables move, so a table added later is granted at that rebuild. The file's
329
+ * `default:` role is one of these roles: a user with no role of its own runs its catalog requests under it; under a
330
+ * file with no default it runs them as `NO_ROLE_CATALOG` (src/duck/grants.ts catalogRolesOf), a role with no grant.
331
+ */
332
+ export function catalogRoles(schema, roles) {
333
+ const out = [];
334
+ if (roles.size === 0)
335
+ return out;
336
+ out.push("REVOKE EXECUTE ON FUNCTION pg_catalog.set_config(text, text, boolean) FROM PUBLIC;");
337
+ const schemas = [...new Set(schema.tables.map((t) => t.schema ?? "public"))];
338
+ const byName = new Map(schema.tables.map((t) => [`${t.schema ?? "public"}.${t.name}`, t]));
339
+ for (const r of roles.values()) {
340
+ const role = ident(r.name);
341
+ out.push(`CREATE ROLE ${role} NOLOGIN;`);
342
+ for (const s of schemas)
343
+ out.push(`GRANT USAGE ON SCHEMA ${ident(s)} TO ${role};`);
344
+ for (const g of r.tables.values()) {
345
+ const t = byName.get(g.table);
346
+ if (t === undefined)
347
+ continue;
348
+ if (g.all) {
349
+ out.push(`GRANT SELECT ON ${qualified(t)} TO ${role};`);
350
+ continue;
351
+ }
352
+ const columns = g.columns.filter((c) => t.columns.some((x) => x.name === c));
353
+ if (columns.length > 0)
354
+ out.push(`GRANT SELECT (${columns.map(ident).join(", ")}) ON ${qualified(t)} TO ${role};`);
355
+ }
356
+ }
357
+ return out;
358
+ }
359
+ /**
360
+ * THE ROLES' ROW FILTERS AS THE CATALOG'S OWN ROW SECURITY (src/duck/row-filter.ts): every copied table some role filters
361
+ * gets `ENABLE ROW LEVEL SECURITY` and one permissive SELECT policy per role that reads it — its `where:` with each
362
+ * attribute read as `current_setting('mason.attr.<name>')::<type>` (a list `string_to_array(…, ',')::<type>`), the type
363
+ * Postgres inferred for it (`filterTypes`, carried on the roles as `filterTypes`), each request carrying the session's
364
+ * attributes as settings (`asSession`); a `through:` as `EXISTS (SELECT 1 FROM <parent> …)`, the parent's own policy
365
+ * applying inside; `true` for a role that reads it unfiltered (row security with no policy hides every row). So
366
+ * `pg_policies`, `relrowsecurity` and the catalog's own analysis see what Postgres would. The catalog holds no row: the
367
+ * rows Mason answers are filtered on the copy (src/duck/plan.ts), never here. A filter whose types are not known (its
368
+ * check failed: the roles' problem refuses every user held to them) gets no policy. Idempotent: each policy dropped first.
369
+ */
370
+ export function catalogPolicies(schema, roles) {
371
+ const filtered = new Set([...roles.values()].flatMap((g) => [...(g.filters?.keys() ?? [])]));
372
+ const out = [];
373
+ for (const t of schema.tables) {
374
+ const name = `${t.schema ?? "public"}.${t.name}`;
375
+ if (!filtered.has(name))
376
+ continue;
377
+ out.push(`ALTER TABLE ${qualified(t)} ENABLE ROW LEVEL SECURITY`);
378
+ for (const g of roles.values()) {
379
+ if (!g.tables.has(name))
380
+ continue;
381
+ const f = g.filters?.get(name);
382
+ let using;
383
+ if (f === undefined)
384
+ using = "true";
385
+ else if (f.through !== undefined)
386
+ using = `EXISTS (SELECT 1 FROM ${tableSql(f.through.parent)} AS __mason_parent WHERE __mason_parent.${ident(f.through.parentColumn)} = ${qualified(t)}.${ident(f.through.column)})`;
387
+ else {
388
+ const types = g.filterTypes?.get(name);
389
+ if (types === undefined || f.where === undefined)
390
+ continue;
391
+ const shapes = g.attributes ?? new Map();
392
+ using = renderWhere(f.where.parts, (a) => {
393
+ const read = `current_setting('mason.attr.${a}')`;
394
+ return shapes.get(a) === "list" ? `string_to_array(${read}, ',')::${types.get(a) ?? "text[]"}` : `${read}::${types.get(a) ?? "text"}`;
395
+ });
396
+ }
397
+ out.push(`DROP POLICY IF EXISTS ${ident(`mason_${g.name}`)} ON ${qualified(t)}`);
398
+ out.push(`CREATE POLICY ${ident(`mason_${g.name}`)} ON ${qualified(t)} AS PERMISSIVE FOR SELECT TO ${ident(g.name)} USING (${using})`);
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+ /**
404
+ * A SESSION'S CATALOG REQUEST AS ITS ROLE: `SET ROLE` before the request's frames and `RESET ROLE` after, in ONE
405
+ * message — the catalog runs one request at a time (`PgCatalog`'s chain) and a message whole, so no other session's
406
+ * request runs under this role, and none of this session's runs without it. A Sync is added before the RESET where
407
+ * the request has none: after an error Postgres ignores every message until a Sync (ignore_till_sync), the RESET
408
+ * included, and a role left set would be every session's.
409
+ */
410
+ export function asRole(message, role) {
411
+ const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
412
+ const synced = splitFrames(own).some((f) => f.type === "S");
413
+ const set = `SET ROLE ${ident(role)}`;
414
+ return Buffer.concat([frame("Q", cstr(set)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr("RESET ROLE"))]);
415
+ }
416
+ /**
417
+ * The reply to an `asRole` message, less the SET's and the RESET's own replies (each a CommandComplete and a
418
+ * ReadyForQuery; the Sync added by `asRole` leaves its ReadyForQuery, which every caller drops). `setRoleRefused`: the
419
+ * SET ROLE's ErrorResponse, when the catalog has no such role (its roles are a rebuild behind the file).
420
+ */
421
+ export function unwrapRole(frames) {
422
+ const first = frames[0];
423
+ if (first === undefined)
424
+ return { frames: [], setRoleRefused: null };
425
+ if (first.type === "E")
426
+ return { frames: [], setRoleRefused: first.frame };
427
+ // the SET's CommandComplete and ReadyForQuery first; the RESET's CommandComplete and ReadyForQuery last
428
+ const inner = frames.slice(first.type === "C" && frames[1]?.type === "Z" ? 2 : 0);
429
+ const n = inner.length;
430
+ const end = n >= 2 && inner[n - 1].type === "Z" && inner[n - 2].type === "C" ? n - 2 : n;
431
+ return { frames: inner.slice(0, end), setRoleRefused: null };
432
+ }
433
+ /** the prefix of the placeholders a session's request rides in (`asSession`): named anew by each process, so no name a
434
+ * tool writes is one of them — `current_setting('mason.session')` answered the carrier's JSON where Postgres (and
435
+ * `SHOW`, and the same beside a copied table) answers 42704 (a review finding of #46). A placeholder stays defined once
436
+ * SET, so the name, not a RESET, is what keeps it out of reach. */
437
+ const CARRIER = `mason_${randomBytes(8).toString("hex")}`;
438
+ /** the placeholder a session's settings ride in, for one request of its (`asSession`) */
439
+ export const SESSION_SETTINGS = `${CARRIER}.session`;
440
+ /** the catalog's own state, which its start sets and every request of a session leaves it in: UTC, `public` first (Mason's
441
+ * own reads that name no schema find its overrides), ISO with month-day-year */
442
+ export const CATALOG_BASE = { timeZone: "UTC", searchPath: "public, pg_catalog", dateStyle: "ISO, MDY" };
443
+ /**
444
+ * A search_path as SET writes it, read as Postgres's `SplitIdentifierString` reads the parameter's text: a list split at
445
+ * the commas outside double quotes, an unquoted name folded to lower case, a quoted one as written (`"$user"` kept); an
446
+ * empty list is `''`. Every name is written quoted, so no text of a session's reaches the SQL but as an identifier.
447
+ */
448
+ export function searchPathSql(path) {
449
+ const names = [];
450
+ let i = 0;
451
+ while (i < path.length) {
452
+ while (i < path.length && /[\s,]/.test(path[i]))
453
+ i++;
454
+ if (i >= path.length)
455
+ break;
456
+ let name = "";
457
+ if (path[i] === '"') {
458
+ i++;
459
+ while (i < path.length) {
460
+ if (path[i] === '"') {
461
+ if (path[i + 1] === '"') {
462
+ name += '"';
463
+ i += 2;
464
+ continue;
465
+ }
466
+ i++;
467
+ break;
468
+ }
469
+ name += path[i++];
470
+ }
471
+ }
472
+ else {
473
+ while (i < path.length && path[i] !== ",")
474
+ name += path[i++];
475
+ name = name.trim().toLowerCase();
476
+ }
477
+ while (i < path.length && path[i] !== ",")
478
+ i++;
479
+ names.push(name);
480
+ }
481
+ return names.length === 0 ? "''" : names.map((n) => `"${n.replaceAll('"', '""')}"`).join(", ");
482
+ }
483
+ /** THE CATALOG'S ONE APPLIER of a session's state: its SETs, three statements (`local`: for the transaction they run
484
+ * in, as a lookup of Mason's sets them inside one batch) */
485
+ export function sessionSetSql(state, local = false) {
486
+ const set = local ? "SET LOCAL" : "SET";
487
+ return `${set} search_path = ${searchPathSql(state.searchPath)}; ${set} TimeZone = ${quoteLiteral(state.timeZone)}; ${set} DateStyle = ${quoteLiteral(state.dateStyle)}`;
488
+ }
489
+ /** the catalog's base set back (`CATALOG_BASE`), after a request that applied a session's state */
490
+ export const CATALOG_BASE_SQL = sessionSetSql(CATALOG_BASE);
491
+ /** how many CommandCompletes `sessionSetSql` answers: one a SET */
492
+ const SESSION_SETS = 3;
493
+ /**
494
+ * LATEST(x ORDER BY k…), THE AGGREGATE MASON ADDS: the x of the row its ORDER BY sorts last, by Postgres's sort rules.
495
+ * Declared in the catalog so that Postgres's analysis types and names it (an AGGREF of its argument's type), and by the
496
+ * oracle's real Postgres from this ONE definition, so the two compare like with like. Its transition keeps the latest
497
+ * row's value, a NULL included (not STRICT); over no rows it is NULL. In pg_catalog, so no search_path hides it. The
498
+ * engine never runs this: src/duck/rewrite.ts `latest` rewrites it, and refuses it without an ORDER BY (`latest-order`).
499
+ */
500
+ export const LATEST_SQL = [
501
+ "CREATE OR REPLACE FUNCTION pg_catalog.mason_latest_step(anyelement, anyelement) RETURNS anyelement LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT $2 $$",
502
+ "CREATE OR REPLACE AGGREGATE pg_catalog.latest(anyelement) (SFUNC = pg_catalog.mason_latest_step, STYPE = anyelement)",
503
+ ];
504
+ /**
505
+ * THE CATALOG'S `current_setting(name[, missing_ok])` IS THE SESSION'S (GitHub issue 40: after `SET TimeZone` it answered
506
+ * the catalog's UTC, where `SHOW TimeZone` answered the session's): the value the session holds under that name, from
507
+ * the request's `SESSION_SETTINGS` (`asSession`: every parameter the listener's `sessionSetting` answers), else
508
+ * Postgres's own (`block_size`; its 42704, or NULL under missing_ok, for a name nobody knows). The `version()`
509
+ * override's way: search_path lists pg_catalog after public, so a statement reading no table and calling it is
510
+ * forwarded as ever; one that writes `pg_catalog.current_setting` is spliced to this one (server.ts
511
+ * userSpliced: Postgres's built-in runs its C function by OID, so its catalog row cannot be replaced). Plain
512
+ * SQL (plpgsql would load its library, and SHOW ALL list its `plpgsql.*`); STRICT, as Postgres's.
513
+ */
514
+ export function currentSettingSql() {
515
+ const held = `nullif(pg_catalog.current_setting('${SESSION_SETTINGS}', true), '')::jsonb ->> lower($1)`;
516
+ return [
517
+ `CREATE OR REPLACE FUNCTION public.current_setting(text) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1)) $$`,
518
+ `CREATE OR REPLACE FUNCTION public.current_setting(text, boolean) RETURNS text LANGUAGE sql STABLE STRICT AS $$ SELECT coalesce(${held}, pg_catalog.current_setting($1, $2)) $$`,
519
+ ];
520
+ }
521
+ /**
522
+ * A SESSION'S CATALOG REQUEST UNDER ITS STATE AND WITH ITS SETTINGS, IN A TRANSACTION OF ITS OWN: `BEGIN`, the session's
523
+ * TimeZone, search_path and DateStyle (`sessionSetSql`) and `SET <SESSION_SETTINGS> = '<its settings as JSON>'` before
524
+ * the request's frames; `ROLLBACK` and the catalog's base (`CATALOG_BASE`) set back after, in ONE message, as `asRole`
525
+ * wraps a role (around it, when the session has one) — the catalog runs one request at a time and a message whole, so
526
+ * no other session's request reads these, and none of this session's runs without them. A Sync is added before the
527
+ * restore where the request has none (ignore_till_sync: a setting left set would be every session's). The `BEGIN`'s
528
+ * message is run at the session's transaction instant (`PgCatalog.run`'s `at`, catalog-worker.ts `pinnedClock`), so
529
+ * the transaction it begins starts at that instant and every reader of the transaction's start in the request —
530
+ * now(), the clock keywords, a relative word however it reaches a date or time — reads the session's (GitHub issue
531
+ * 253, review r3 of PR 333). The ROLLBACK undoes anything the request set, whether it succeeded or failed.
532
+ * `state` and `settings` are the listener's (`sessionState`, `sessionSetting`), never read here. The search_path is the
533
+ * session's as written, pg_catalog first unless it lists it, as on Postgres: the overrides of `version()`,
534
+ * `current_setting` and `format_type` are reached by the splice of every call of them (server.ts userSpliced), never by
535
+ * the order of the path.
536
+ */
537
+ export function asSession(message, state, settings) {
538
+ const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
539
+ const frames = splitFrames(own);
540
+ const synced = frames.some((f) => f.type === "S") || frames.at(-1)?.type === "Q";
541
+ const json = JSON.stringify(Object.fromEntries(settings));
542
+ const before = `BEGIN; ${sessionSetSql(state)}; SET ${SESSION_SETTINGS} = ${quoteLiteral(json)}`;
543
+ const after = `ROLLBACK; RESET ${SESSION_SETTINGS}; ${CATALOG_BASE_SQL}`;
544
+ return Buffer.concat([frame("Q", cstr(before)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr(after))]);
545
+ }
546
+ /** The reply to an `asSession` message, less the applier's and the restore's own replies (a CommandComplete a SET and a
547
+ * ReadyForQuery, first and last). A SET the catalog refused is a Mason bug, said. */
548
+ export function unwrapSession(frames) {
549
+ const sets = SESSION_SETS + 2;
550
+ const run = (from) => frames.slice(from, from + sets).every((f) => f.type === "C") && frames[from + sets]?.type === "Z";
551
+ const n = frames.length;
552
+ if (n < 2 * (sets + 1) || !run(0) || !run(n - sets - 1)) {
553
+ throw new Error(`the catalog Postgres did not take the session's settings around a request (${frames.map((f) => f.type).join(", ")}): report it`);
554
+ }
555
+ return frames.slice(sets + 1, n - sets - 1);
556
+ }
246
557
  /**
247
558
  * WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
248
559
  * view's whole definition at each start of the instance — 7 multi-line entries per start, most of a 190 KB log, their
@@ -257,7 +568,7 @@ export function leftOutLine(r) {
257
568
  return `catalog: a source default left out (${r.message}): ${r.statement}`;
258
569
  const missing = /^relation "([^"]+)" does not exist$/.exec(r.message)?.[1];
259
570
  const name = `${unquote(view[1])}.${unquote(view[2])}`;
260
- return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not copied` : `catalog: view ${name} left out (${r.message})`;
571
+ return missing !== undefined ? `catalog: view ${name} left out: it reads ${missing}, which is not synced` : `catalog: view ${name} left out (${r.message})`;
261
572
  }
262
573
  const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
263
574
  /** One worker thread holding one PGlite: requests one at a time (PgCatalog's chain), each with a deadline. */
@@ -303,7 +614,8 @@ class CatalogWorker {
303
614
  return new Promise((resolve, reject) => {
304
615
  const id = this.#next++;
305
616
  const timer = setTimeout(() => {
306
- 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)`));
617
+ // the message reaches the client on the wire and the request log: Mason's words, never the catalog's engine's name
618
+ this.#die(new Error(`the catalog did not answer ${what} within ${ms / 1000} s, so Mason started it again (the next statement is answered as usual; MASON_PG_CATALOG_TIMEOUT_MS raises the deadline)`));
307
619
  void this.#worker.terminate();
308
620
  }, ms);
309
621
  timer.unref();
@@ -318,6 +630,7 @@ class CatalogWorker {
318
630
  }
319
631
  export class PgCatalog {
320
632
  #schema;
633
+ #roles;
321
634
  #idleMs;
322
635
  #statementMs;
323
636
  #log;
@@ -331,8 +644,12 @@ export class PgCatalog {
331
644
  #chain = Promise.resolve();
332
645
  #idleTimer = null;
333
646
  #lastUsed = 0;
647
+ /** the roles' row security policies (`catalogPolicies`), written once the listener checked the filters: run on the
648
+ * instance up now and at every start after, until the schema or the roles are replaced */
649
+ #policies = [];
334
650
  constructor(schema, opts = {}) {
335
651
  this.#schema = schema;
652
+ this.#roles = opts.roles ?? new Map();
336
653
  this.#idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
337
654
  this.#statementMs = opts.statementMs ?? DEFAULT_STATEMENT_MS;
338
655
  this.#log = opts.log ?? ((line) => console.error(line));
@@ -344,12 +661,14 @@ export class PgCatalog {
344
661
  return this.#db !== null && this.#db.alive;
345
662
  }
346
663
  /** Runs raw frontend protocol bytes — one message or several (Parse, Bind, Describe, Execute, Sync in one buffer
347
- * works) — and returns the backend's raw reply, starting the instance when needed. */
348
- run(message) {
664
+ * works) — and returns the backend's raw reply, starting the instance when needed. `at`: the instant the first
665
+ * message runs at, the start of the transaction it begins (`asSession`; catalog-worker.ts `pinnedClock`), in
666
+ * MICROSECONDS since the epoch as Postgres's (src/lib/instant.ts), which the catalog reads back exactly. */
667
+ run(message, at) {
349
668
  return this.#enqueue(async () => {
350
669
  const db = await this.#ensureStarted();
351
670
  const copy = new Uint8Array(message);
352
- const reply = await this.#ask(db, { op: "raw", bytes: copy }, "a statement", [copy.buffer]);
671
+ const reply = await this.#ask(db, { op: "raw", bytes: copy, ...(at !== undefined ? { at } : {}) }, "a statement", [copy.buffer]);
353
672
  this.#touch();
354
673
  return reply.bytes ?? new Uint8Array(0);
355
674
  });
@@ -367,13 +686,36 @@ export class PgCatalog {
367
686
  stop() {
368
687
  return this.#enqueue(() => this.#close("stopped"));
369
688
  }
370
- /** Swaps in a new schema (after an apply): 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) {
689
+ /** Swaps in a new schema (after an apply), and the roles with it (the roles file moved): the running instance is
690
+ * stopped and the next `run` starts with the new DDL. Resolves once the old instance is gone; a caller need not wait for it. */
691
+ replace(schema, roles = this.#roles) {
373
692
  this.#schema = schema;
693
+ this.#roles = roles;
694
+ this.#policies = [];
374
695
  this.#said.clear();
375
696
  return this.#enqueue(() => this.#close("replaced"));
376
697
  }
698
+ /**
699
+ * THE ROLES' ROW SECURITY POLICIES (`catalogPolicies`), written now (the instance started where needed) and at every
700
+ * start after, until the next `replace`. A statement the catalog refuses is said, never fatal: the rows are the copy's.
701
+ */
702
+ policies(ddl) {
703
+ return this.#enqueue(async () => {
704
+ this.#policies = ddl;
705
+ if (ddl.length === 0)
706
+ return;
707
+ const db = await this.#ensureStarted();
708
+ for (const sql of ddl) {
709
+ try {
710
+ await this.#ask(db, { op: "query", sql }, "a row security policy");
711
+ }
712
+ catch (e) {
713
+ this.#log(`catalog: a row security policy left out (${errorMessage(e)}): ${sql}`);
714
+ }
715
+ }
716
+ this.#touch();
717
+ });
718
+ }
377
719
  #enqueue(fn) {
378
720
  const result = this.#chain.then(fn);
379
721
  // the chain only ORDERS calls: a rejection reaches the caller through `result`, and the next call must still run
@@ -401,11 +743,12 @@ export class PgCatalog {
401
743
  if (this.#db !== null && this.#db.alive)
402
744
  return this.#db;
403
745
  this.#db = null;
404
- const schema = this.#schema;
746
+ const schema = this.#schema, roles = this.#roles;
405
747
  const t0 = Date.now();
406
748
  const db = new CatalogWorker();
407
749
  try {
408
- const started = await this.#ask(db, { op: "init", statements: ["SET TimeZone = 'UTC'", ...catalogDdl(schema), ...this.#setup], optional: [...catalogDefaults(schema), ...catalogViews(schema)] }, "its start");
750
+ const started = await this.#ask(db, { op: "init", statements: [CATALOG_BASE_SQL, ...catalogDdl(schema), ...catalogRoles(schema, roles), ...this.#setup],
751
+ optional: [...catalogDefaults(schema), ...catalogViews(schema, roles), ...this.#policies] }, "its start");
409
752
  // each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
410
753
  for (const r of started.refused ?? []) {
411
754
  const line = leftOutLine(r);
@@ -423,7 +766,7 @@ export class PgCatalog {
423
766
  throw new Error(`PGlite could not start: ${errorMessage(e)}`, { cause: e });
424
767
  }
425
768
  this.#db = db;
426
- this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)`);
769
+ this.#log(`catalog: PGlite started in ${Date.now() - t0} ms with ${schema.tables.length} table(s)${roles.size > 0 ? ` and ${roles.size} role(s)` : ""}`);
427
770
  this.#touch();
428
771
  return db;
429
772
  }
@@ -82,7 +82,9 @@ export function fieldOf(name, type, format) {
82
82
  export class EncodeError extends Error {
83
83
  /** the SQLSTATE Postgres raises for the same value (a timestamp past its range is 22008), else XX000: Mason's gap */
84
84
  code;
85
- 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");
@@ -15,6 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import { parsePort } from "../../lib/port.js";
17
17
  import { plainListener } from "./plain.js";
18
+ import { parseProxyFrom } from "./proxy.js";
18
19
  export const DEFAULT_PG_PORT = 5433;
19
20
  /** The listener's address from `env`, or the problem with its port (`parsePort`'s words). */
20
21
  export function pgListenerAddress(env, apiHost) {
@@ -24,8 +25,8 @@ export function pgListenerAddress(env, apiHost) {
24
25
  return { host: env.MASON_PG_HOST || apiHost, port: port.port };
25
26
  }
26
27
  /** Where and how the listener listens, from `env` (`MASON_PG_HOST`, `MASON_PG_PORT`, `MASON_PG_TLS_CERT`,
27
- * `MASON_PG_TLS_KEY`, `MASON_PG_PLAIN`), or why it cannot: a bad port, half a certificate, plain off loopback. Reads no
28
- * file, so a start can be refused before anything is made. */
28
+ * `MASON_PG_TLS_KEY`, `MASON_PG_PLAIN`, `MASON_PG_PROXY_FROM`), or why it cannot: a bad port, half a certificate, plain
29
+ * off loopback, a proxy's address that is no address. Reads no file, so a start can be refused before anything is made. */
29
30
  export function pgListenerSettings(env, apiHost) {
30
31
  const address = pgListenerAddress(env, apiHost);
31
32
  if ("problem" in address)
@@ -36,7 +37,14 @@ export function pgListenerSettings(env, apiHost) {
36
37
  const plain = plainListener(address.host, env.MASON_PG_PLAIN, Boolean(cert && key));
37
38
  if (plain !== null)
38
39
  return plain;
39
- return { ...address, tls: cert && key ? { cert, key } : null };
40
+ const proxyFrom = parseProxyFrom(env.MASON_PG_PROXY_FROM);
41
+ if (proxyFrom !== null && "problem" in proxyFrom) {
42
+ return { problem: proxyFrom.problem, kind: "proxy_from",
43
+ why: "a PROXY header names the client every statement is recorded as, so it is believed only from the addresses this names",
44
+ fix: "Name the addresses of your TCP proxy or load balancer alone, comma-separated (MASON_PG_PROXY_FROM=10.0.0.0/16,10.1.0.0/16), " +
45
+ "or unset it to read no PROXY header — docs/POSTGRES-API.md \"Behind a TCP proxy\"" };
46
+ }
47
+ return { ...address, tls: cert && key ? { cert, key } : null, ...(proxyFrom !== null ? { proxyFrom } : {}) };
40
48
  }
41
49
  /** The certificate and key the settings name, read; or why not. */
42
50
  export function readPgTls(files) {