@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
@@ -3,18 +3,22 @@ import { explainedText, explainOptions } from "./explain.js";
3
3
  import * as crypto from "node:crypto";
4
4
  import * as net from "node:net";
5
5
  import * as tls from "node:tls";
6
- import { ApiError } from "../error.js";
6
+ import { ApiError, USER_DISABLED } from "../error.js";
7
7
  import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
8
+ import { gapMark } from "../../duck/gaps.js";
8
9
  import { canonicalZone } from "../../lib/timezone.js";
9
- import { databaseNameSql, PgCatalog } from "./catalog.js";
10
+ import { catalogRolesOf, exampleRole, grantRefusal, NO_ROLE_CATALOG, NO_ROLES } from "../../duck/grants.js";
11
+ import { asRole, asSession, catalogPolicies, currentSettingSql, databaseNameSql, PgCatalog, unwrapRole, unwrapSession } from "./catalog.js";
12
+ import { wrongDatabaseHint } from "../../lib/database-name.js";
13
+ import { attributeSetting, bindFilter, filterProbe, filterTypes, missingAttributes } from "../../duck/row-filter.js";
10
14
  import { EncodeError, OID, fieldOf } from "./encode.js";
11
15
  import { numericFromBinary, NumericError } from "../../lib/pg-numeric.js";
12
16
  import { parseSql, PostgresSyntaxError, stopParser } from "./parser.js";
13
- import { FrontendDecoder, ProtocolError, authenticationCleartextPassword, authenticationOk, backendKeyData, bindComplete, closeComplete, commandComplete, dataRow, emptyQueryResponse, errorResponse, noData, noticeResponse, parameterDescription, parameterStatus, parseComplete, portalSuspended, readyForQuery, rowDescription, sslResponse, cstr, int16, int32, frame, decodeNoticeFrame, splitFrames, MAX_MESSAGE_LENGTH, } from "./protocol.js";
17
+ import { FrontendDecoder, ProtocolError, authenticationCleartextPassword, authenticationOk, backendKeyData, bindComplete, closeComplete, commandComplete, dataRow, emptyQueryResponse, errorResponse, noData, noticeResponse, parameterDescription, parameterStatus, parseComplete, portalSuspended, readyForQuery, rowDescription, sslResponse, cstr, int16, int32, frame, decodeBackend, decodeNoticeFrame, splitFrames, MAX_MESSAGE_LENGTH, } from "./protocol.js";
14
18
  import { typeNameParts } from "./schema.js";
15
- import { Unsupported, duckTranslate } from "./translate.js";
19
+ import { Unsupported, duckTranslate, endsFailedBlock } from "./translate.js";
16
20
  /** what an EXPLAIN of a catalog statement says first: whose plan it is */
17
- const CATALOG_EXPLAINED = "Mason: answered by the catalog Postgres (Mason's own, PGlite), not DuckDB: a statement of pg_catalog or information_schema, or of no table, is the catalog's, and this is its plan";
21
+ const CATALOG_EXPLAINED = "Mason: answered by the catalog Postgres (Mason's own, PGlite), not the synced data: a statement of pg_catalog or information_schema, or of no table, is the catalog's, and this is its plan";
18
22
  /** an EXPLAIN's one column, Postgres's: text, or json for FORMAT JSON (ExplainResultDesc) */
19
23
  const explainField = (o, format) => ({ name: "QUERY PLAN", typeOid: o.format === "json" ? 114 : 25, typeSize: -1, typeModifier: -1, format });
20
24
  /** a DataRow's values (null for NULL) */
@@ -43,7 +47,7 @@ function catalogExplained(frames, o) {
43
47
  return frames;
44
48
  const doc = JSON.parse(value.toString("utf8"));
45
49
  if (doc[0] !== undefined)
46
- doc[0].Mason = { "Answered By": "the catalog Postgres (Mason's own, PGlite), not DuckDB", Why: "a statement of pg_catalog or information_schema, or of no table, is the catalog's" };
50
+ doc[0].Mason = { "Answered By": "the catalog Postgres (Mason's own, PGlite), not the synced data", Why: "a statement of pg_catalog or information_schema, or of no table, is the catalog's" };
47
51
  return [...frames.slice(0, first), dataRow([Buffer.from(JSON.stringify(doc, null, 2))]), ...frames.slice(first + 1)];
48
52
  }
49
53
  /** Postgres's names over DuckDB's: each named column renamed, a `*` (null) spanning what DuckDB expanded it to */
@@ -66,6 +70,15 @@ export function postgresNames(columns, names) {
66
70
  }
67
71
  return at === columns.length ? out : [...columns];
68
72
  }
73
+ /** whether any role of the file filters rows (src/duck/row-filter.ts): nothing is checked, bound or keyed where none does */
74
+ const filtersRows = (live) => [...live.grants.values()].some((g) => g.filters !== undefined && g.filters.size > 0);
75
+ /** the row filters a session's role holds, bound to its user's values, as one string: a plan's key and its `rowsKey`;
76
+ * "" where it holds none */
77
+ function rowsKeyOf(g) {
78
+ if (g?.rows === undefined || g.rows.size === 0)
79
+ return "";
80
+ return JSON.stringify([...g.rows.values()].map((f) => [f.table, f.predicate ?? null, f.through ?? null]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
81
+ }
69
82
  /** a statement_timeout's value in ms, Postgres's units (a bare number is ms; `1.5s`, `5min`), 0 for none; null where
70
83
  * it is no duration */
71
84
  export function timeoutMs(value) {
@@ -80,7 +93,18 @@ export function timeoutMs(value) {
80
93
  export class StatementError extends Error {
81
94
  fields;
82
95
  sql;
83
- constructor(fields, sql = "") { super(fields.message); this.fields = fields; this.sql = sql; }
96
+ /** the registry entry (src/duck/gaps.ts) that refused the statement, as the refusal was built with it (`gapRefusal`,
97
+ * src/duck/store.ts pgErrorOf): what the request log records as `gap`; null for every other error */
98
+ gap;
99
+ constructor(fields, sql = "", gap = null) { super(fields.message); this.fields = fields; this.sql = sql; this.gap = gap; }
100
+ }
101
+ /**
102
+ * A REFUSAL BY A REGISTRY ENTRY (src/duck/gaps.ts), the one way one is built: 0A000, `Mason gap <id>` in its DETAIL (what
103
+ * the oracle and a reader read), followed by `detail` (docs/SQL-SUPPORT.md, or what it names), and the id itself on the
104
+ * error, so the request log records which entry refused it without reading the text back.
105
+ */
106
+ export function gapRefusal(gap, message, hint, sql = "", detail = " (docs/SQL-SUPPORT.md)") {
107
+ return new StatementError({ code: "0A000", message, hint, detail: `${gapMark(gap)}${detail}` }, sql, gap);
84
108
  }
85
109
  export const SERVER_VERSION = "18.6";
86
110
  export const SERVER_VERSION_NUM = "180006";
@@ -96,10 +120,27 @@ export function versionText(product, version) {
96
120
  * keeps one for hours — ends at its first statement after this long.
97
121
  */
98
122
  export const DEFAULT_RECHECK_MS = 30_000;
123
+ /** How many connections may be logging in on a share's socket at once: more than a pool opens at a time. */
124
+ export const DEFAULT_PENDING_LIMIT = 32;
125
+ /** How long one may take to log in: a tunnel's round trips and a TLS handshake are seconds, Postgres allows 60. */
126
+ export const DEFAULT_PENDING_MS = 15_000;
127
+ /** EACH STATEMENT'S NUMBER IN THIS RUN, by the controller that runs it — the thing a cancel aborts, so a cursor's is one
128
+ * number across the Executes that page its rows — given when first asked (`runningNow`), never reused */
129
+ const statementIds = new WeakMap();
130
+ let nextStatementId = 1;
131
+ function statementIdOf(c) {
132
+ let id = statementIds.get(c);
133
+ if (id === undefined) {
134
+ id = nextStatementId++;
135
+ statementIds.set(c, id);
136
+ }
137
+ return id;
138
+ }
99
139
  // ---------------------------------------------------------------------------------------------------------------------
100
140
  // bytes
101
141
  /** the type byte of a RowDescription frame */
102
142
  const ROW_DESCRIPTION = 0x54;
143
+ const DATA_ROW = 0x44;
103
144
  /** the first byte of a TLS handshake record (a ClientHello): never a startup packet's, whose length's high byte is 0
104
145
  * under MAX_STARTUP_LENGTH, and never SSLRequest's (length 8) */
105
146
  const TLS_HANDSHAKE = 0x16;
@@ -112,10 +153,13 @@ class PgError extends Error {
112
153
  fields;
113
154
  /** the HTTP-like status the request log records (400 for a refusal, 401 a refused login, 500 Mason's failure) */
114
155
  status;
115
- constructor(fields, status = 400) {
156
+ /** the registry entry that refused it (StatementError.gap): the request log's `gap` */
157
+ gap;
158
+ constructor(fields, status = 400, gap = null) {
116
159
  super(fields.message);
117
160
  this.fields = { severity: "ERROR", ...fields };
118
161
  this.status = status;
162
+ this.gap = gap;
119
163
  }
120
164
  }
121
165
  /** The SQLSTATE an ApiError's status is told as (a refused login's, src/duck/users.ts). */
@@ -134,12 +178,14 @@ function sqlstateOf(status, code) {
134
178
  }
135
179
  }
136
180
  /**
137
- * A refused or failed statement as its log line (`Session.refuse`): `failed` where the fault is Mason's or the
138
- * machine's (SQLSTATE classes XX internal, 53 resources, 57 a timeout or cancel, 58 system), `refused` for the
139
- * statement's own; the detail and the hint where there are, and the statement on one line, as the answered lines carry it.
181
+ * A refused or failed statement as its log line (`Session.refuse`): its outcome (src/api/request-log.ts outcomeOf) —
182
+ * `cancelled` for 57014 (a cancel or a timeout), `failed` where the fault is Mason's or the machine's (SQLSTATE classes
183
+ * XX internal, 53 resources, 57, 58 system), `refused` for the statement's own; the detail and the hint where there
184
+ * are, and the statement on one line, as the answered lines carry it.
140
185
  */
141
186
  export function refusalLine(session, f, sql) {
142
- const word = outcomeOf(f.code) === "failed" ? "failed" : "refused";
187
+ const o = outcomeOf(f.code);
188
+ const word = o === "ok" ? "refused" : o;
143
189
  const statement = sql.replace(/\s+/g, " ").trim();
144
190
  return `session ${session} ${word}: ${f.code} ${f.message}${f.detail ? `; detail: ${f.detail}` : ""}${f.hint ? `; hint: ${f.hint}` : ""}` +
145
191
  ` — ${statement === "" ? "(no statement)" : statement}`;
@@ -148,9 +194,9 @@ function asPgError(err, sql) {
148
194
  if (err instanceof PgError)
149
195
  return err;
150
196
  if (err instanceof StatementError)
151
- return new PgError({ ...err.fields, position: err.fields.position ?? undefined }, err.fields.code.startsWith("XX") ? 500 : 400);
197
+ return new PgError({ ...err.fields, position: err.fields.position ?? undefined }, err.fields.code.startsWith("XX") ? 500 : 400, err.gap);
152
198
  if (err instanceof Unsupported) {
153
- return new PgError({ code: err.sqlstate, message: err.message, position: positionOf(sql, err.offset), hint: err.hint, detail: "Mason answers the Postgres subset in docs/POSTGRES-API.md" });
199
+ return new PgError({ code: err.sqlstate, message: err.message, position: positionOf(sql, err.offset), hint: err.hint, detail: "Mason answers the Postgres subset in docs/POSTGRES-API.md" }, 400, err.gap);
154
200
  }
155
201
  if (err instanceof PostgresSyntaxError)
156
202
  return new PgError({ code: "42601", message: err.message, position: positionOf(sql, err.offset) });
@@ -158,11 +204,11 @@ function asPgError(err, sql) {
158
204
  return new PgError({ code: sqlstateOf(err.status, err.code), message: err.message, detail: `Mason: ${err.code}` }, err.status);
159
205
  // Postgres's own error for a numeric (a division by zero, 22012; a field overflow, 22003), raised as Postgres raises it
160
206
  if (err instanceof NumericError)
161
- return new PgError({ code: err.sqlstate, message: err.message }, err.sqlstate === "XX000" ? 500 : 400);
207
+ return new PgError({ code: err.sqlstate, message: err.message }, err.sqlstate === "XX000" ? 500 : 400, err.gap);
162
208
  if (err instanceof EncodeError)
163
209
  return err.code === "XX000"
164
210
  ? new PgError({ code: "XX000", message: err.message, detail: "Mason: a value the column's Postgres type cannot carry; report it" }, 500)
165
- : new PgError({ code: err.code, message: err.message }, 400);
211
+ : new PgError({ code: err.code, message: err.message }, 400, err.gap);
166
212
  if (err instanceof ProtocolError)
167
213
  return new PgError({ code: "08P01", message: err.message }, 500);
168
214
  const message = err instanceof Error ? err.message : String(err);
@@ -176,32 +222,48 @@ const REPORTED = new Set(["application_name", "client_encoding", "datestyle", "i
176
222
  const SPELLING = { datestyle: "DateStyle", intervalstyle: "IntervalStyle", timezone: "TimeZone" };
177
223
  const spell = (name) => SPELLING[name] ?? name;
178
224
  const defaultVars = () => ({ timeZone: "UTC", applicationName: "", searchPath: '"$user", public', other: new Map() });
179
- /** What `SHOW` / `current_setting` answer for the parameters this server fixes. */
180
- function fixedParameter(name, vars, user) {
225
+ const copyVars = (v) => ({ ...v, other: new Map(v.other) });
226
+ /** One parameter's value carried from one set of values to another, from where `setParameter` keeps it. */
227
+ function copySetting(name, from, to) {
181
228
  switch (name) {
182
- case "server_version": return SERVER_VERSION;
183
- case "server_version_num": return SERVER_VERSION_NUM;
184
- case "server_encoding":
185
- case "client_encoding": return "UTF8";
186
- case "datestyle": return "ISO, MDY";
187
- case "intervalstyle": return "postgres";
188
- case "timezone": return vars.timeZone;
189
- case "application_name": return vars.applicationName;
190
- case "search_path": return vars.searchPath;
191
- case "integer_datetimes":
192
- case "standard_conforming_strings":
193
- case "default_transaction_read_only":
194
- case "transaction_read_only":
195
- case "in_hot_standby": return "on";
196
- case "is_superuser": return "off";
197
- case "session_authorization":
198
- case "role": return user;
199
- case "transaction_isolation":
200
- case "default_transaction_isolation": return "read committed";
201
- case "extra_float_digits": return "1";
202
- case "max_identifier_length": return "63";
203
- default: return null;
229
+ case "timezone":
230
+ case "time zone":
231
+ to.timeZone = from.timeZone;
232
+ return;
233
+ case "application_name":
234
+ to.applicationName = from.applicationName;
235
+ return;
236
+ case "search_path":
237
+ to.searchPath = from.searchPath;
238
+ return;
204
239
  }
240
+ const value = from.other.get(name);
241
+ if (value === undefined)
242
+ to.other.delete(name);
243
+ else
244
+ to.other.set(name, value);
245
+ }
246
+ /** A placeholder's name as Postgres's `valid_custom_variable_name` reads it: two or more simple identifiers, dotted. */
247
+ function placeholderName(name) {
248
+ if (/^[A-Za-z_\u0080-\uffff][A-Za-z0-9_$\u0080-\uffff]*(\.[A-Za-z_\u0080-\uffff][A-Za-z0-9_$\u0080-\uffff]*)+$/.test(name))
249
+ return;
250
+ throw new PgError({ code: "42602", message: `invalid configuration parameter name "${name}"`, detail: "Custom parameter names must be two or more simple identifiers separated by dots." });
251
+ }
252
+ /** What `SHOW` / `current_setting` answer for the parameters this server fixes, by name: read by `fixedParameter` alone. */
253
+ const FIXED = new Map([
254
+ ["server_version", () => SERVER_VERSION], ["server_version_num", () => SERVER_VERSION_NUM],
255
+ ["server_encoding", () => "UTF8"], ["client_encoding", () => "UTF8"],
256
+ ["datestyle", () => "ISO, MDY"], ["intervalstyle", () => "postgres"],
257
+ ["timezone", (v) => v.timeZone], ["application_name", (v) => v.applicationName], ["search_path", (v) => v.searchPath],
258
+ ...["integer_datetimes", "standard_conforming_strings", "default_transaction_read_only", "transaction_read_only", "in_hot_standby"].map((n) => [n, () => "on"]),
259
+ ["is_superuser", () => "off"],
260
+ // the session's user, and no role SET over it (SET ROLE is its own name or NONE, setStatement): Postgres's `none`
261
+ ["session_authorization", (_v, user) => user], ["role", () => "none"],
262
+ ["transaction_isolation", () => "read committed"], ["default_transaction_isolation", () => "read committed"],
263
+ ["extra_float_digits", () => "1"], ["max_identifier_length", () => "63"],
264
+ ]);
265
+ function fixedParameter(name, vars, user) {
266
+ return FIXED.get(name)?.(vars, user) ?? null;
205
267
  }
206
268
  /**
207
269
  * WHAT `SHOW` / `current_setting` SAY ABOUT TLS (`SHOW ssl` once said `off` on a TLS session, the catalog
@@ -219,18 +281,44 @@ export function tlsParameters(on) {
219
281
  ssl_ciphers: tls.DEFAULT_CIPHERS, ssl_groups: tls.DEFAULT_ECDH_CURVE, ssl_prefer_server_ciphers: "off",
220
282
  };
221
283
  }
222
- /** The catalog's `current_setting(name[, missing_ok])`, answering the TLS parameters first (`tlsParameters`) and the rest
223
- * as Postgres's own: the `version()` override's way (search_path lists pg_catalog after public), so a statement reading
224
- * no table and calling it is forwarded as ever, and one table answers SHOW and current_setting alike. */
225
- export function currentSettingSql(params) {
226
- const cases = Object.entries(params).map(([name, value]) => `WHEN ${quoteLiteral(name)} THEN ${quoteLiteral(value)}`).join(" ");
227
- return [
228
- `CREATE OR REPLACE FUNCTION public.current_setting(text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT CASE lower($1) ${cases} ELSE pg_catalog.current_setting($1) END $$`,
229
- `CREATE OR REPLACE FUNCTION public.current_setting(text, boolean) RETURNS text LANGUAGE sql STABLE AS $$ SELECT CASE lower($1) ${cases} ELSE pg_catalog.current_setting($1, $2) END $$`,
230
- ];
231
- }
284
+ /** Postgres's answer to anything but a failed block's end */
285
+ const aborted = () => new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
232
286
  // ---------------------------------------------------------------------------------------------------------------------
233
287
  // one connection
288
+ /** A name as Postgres takes one from a startup packet: its first 63 bytes (NAMEDATALEN - 1). */
289
+ const nameLength = (name) => name === undefined ? undefined : Buffer.from(name, "utf8").subarray(0, 63).toString("utf8");
290
+ /** The hint a refused login is told on the run's own listener. */
291
+ const USER_LOGIN_HINT = "a tool that keeps one password connects as a user: mason user add <name> prints its password and URL";
292
+ /**
293
+ * THE CONNECTIONS STILL LOGGING IN ON A PUBLIC SOCKET (`ShareDoor`): at most `limit`, each for at most `ms`, the oldest
294
+ * making way for a new one. A limit that refused the NEW connection would hand the socket to whoever opened `limit`
295
+ * connections first and sat on them, which is the attack; every connection through a tunnel comes from 127.0.0.1, so
296
+ * a limit per source cannot exist.
297
+ */
298
+ class LoginQueue {
299
+ waiting = new Map();
300
+ limit;
301
+ ms;
302
+ dropped;
303
+ constructor(limit, ms, dropped) { this.limit = limit; this.ms = ms; this.dropped = dropped; }
304
+ admit(s) {
305
+ if (this.waiting.size >= this.limit) {
306
+ const oldest = this.waiting.keys().next().value;
307
+ this.dropped?.("made way for a newer connection");
308
+ oldest.end("made way for a newer connection");
309
+ }
310
+ const timer = setTimeout(() => { this.dropped?.("did not log in in time"); s.end("did not log in in time"); }, this.ms);
311
+ timer.unref();
312
+ this.waiting.set(s, timer);
313
+ }
314
+ settled(s) {
315
+ const timer = this.waiting.get(s);
316
+ if (timer === undefined)
317
+ return;
318
+ clearTimeout(timer);
319
+ this.waiting.delete(s);
320
+ }
321
+ }
234
322
  let nextSessionId = 1;
235
323
  class Session {
236
324
  id = nextSessionId++;
@@ -241,10 +329,25 @@ class Session {
241
329
  user = "";
242
330
  token = null;
243
331
  presented = null;
332
+ /** the database a public socket's startup named that is not this one: refused once the login is good */
333
+ wrongDatabase = null;
244
334
  /** when the user was last found live (a user's session is asked again, `recheckMs`) */
245
335
  verifiedAt = 0;
246
336
  vars = defaultVars();
337
+ /** the session's parameters as its startup left them (its packet's `application_name` and `options`): what RESET restores */
338
+ startupVars = defaultVars();
339
+ /** each reported parameter's value as the client was last told it (`reportChanges`) */
340
+ reported = new Map();
247
341
  tx = "none";
342
+ /**
343
+ * WHAT THE TRANSACTION WOULD LEAVE OF THE SESSION'S SETTINGS, from its first SET: `rollback`, the values it found, and
344
+ * `commit`, those with its SETs and none of its LOCALs. Neither is ever read for an answer: `vars` is what is in force,
345
+ * and `endTransaction` replaces it with one of these. Null: nothing was set since the transaction began.
346
+ */
347
+ settingsTx = null;
348
+ /** the statement runs in an IMPLICIT transaction block, as Postgres's IsTransactionBlock reads one: a Query of several
349
+ * statements, or a Sync's batch once an Execute completed (`extended`). A plain transaction of one statement is none */
350
+ implicitBlock = false;
248
351
  /** the instant the transaction began (Postgres's `now()`), null between transactions */
249
352
  txNow = null;
250
353
  /** the instant of the statement being run (`statement_timestamp()`): its Query's arrival or its Bind */
@@ -255,6 +358,8 @@ class Session {
255
358
  skipToSync = false;
256
359
  /** the query running now, for a CancelRequest and the socket closing */
257
360
  running = null;
361
+ /** the bytes written to the client so far: a message's are this less its mark (`Asked.sentAt`; `bytesFor`) */
362
+ sentBytes = 0;
258
363
  /** this session's DuckDB connection, opened at its first statement over a table */
259
364
  duckRunner = null;
260
365
  /** Closes the session's DuckDB connection, when it has one: only ever with no statement running on it (`end`, `settled`). */
@@ -263,7 +368,7 @@ class Session {
263
368
  return;
264
369
  const runner = this.duckRunner;
265
370
  this.duckRunner = null;
266
- runner.close().catch((e) => { this.server.log(`session ${this.id}: closing its DuckDB connection failed: ${e instanceof Error ? e.message : String(e)}`); });
371
+ runner.close().catch((e) => { this.server.log(`session ${this.id}: closing its connection to the engine failed: ${e instanceof Error ? e.message : String(e)}`); });
267
372
  }
268
373
  /** A statement ended, however: the session's next one may run, and a session closed meanwhile closes its connection now. */
269
374
  settled() {
@@ -280,10 +385,14 @@ class Session {
280
385
  sniffed = false;
281
386
  closedBy = null;
282
387
  server;
283
- constructor(socket, server) {
388
+ /** the socket it arrived on: how it may start, and who may log in */
389
+ door;
390
+ constructor(socket, server, door) {
284
391
  this.server = server;
392
+ this.door = door;
285
393
  this.socket = socket;
286
394
  this.attach(socket);
395
+ door.logins?.admit(this);
287
396
  }
288
397
  attach(socket) {
289
398
  socket.on("data", (chunk) => {
@@ -307,7 +416,18 @@ class Session {
307
416
  sendAll(parts) {
308
417
  if (this.phase === "closed")
309
418
  return;
310
- this.socket.write(Buffer.concat(parts));
419
+ const bytes = Buffer.concat(parts);
420
+ this.sentBytes += bytes.length;
421
+ this.socket.write(bytes);
422
+ }
423
+ /** The statement running now (RunningStatement), or null: one DuckDB runs or sends, so a cancel would stop it. */
424
+ runningNow(now) {
425
+ if (this.running === null || this.asked === null || this.phase !== "ready")
426
+ return null;
427
+ const stored = storedPayload(this.asked.sql);
428
+ return { session: this.id, statement: statementIdOf(this.running), user: this.token?.user ?? "", applicationName: this.vars.applicationName, method: this.asked.method,
429
+ startedAt: new Date(this.asked.startedAt).toISOString(), runningMs: now - this.asked.startedAt, rows: this.asked.rows ?? 0,
430
+ query: stored.text, queryTruncated: stored.truncated };
311
431
  }
312
432
  end(why) {
313
433
  if (this.phase === "closed")
@@ -315,6 +435,7 @@ class Session {
315
435
  const wasReady = this.phase === "ready";
316
436
  this.phase = "closed";
317
437
  this.closedBy = why;
438
+ this.door.logins?.settled(this);
318
439
  if (wasReady)
319
440
  this.server.log(`session ${this.id} closed: ${why}`);
320
441
  this.running?.abort();
@@ -382,20 +503,24 @@ class Session {
382
503
  this.server.log(`trace ${this.id} ${JSON.stringify(this.vars.applicationName)} ${m.type}: ${JSON.stringify(m.sql)}`);
383
504
  }
384
505
  if (m.type === "Query" || m.type === "Parse" || m.type === "Bind" || m.type === "Describe" || m.type === "Execute") {
385
- this.asked = { sql: m.type === "Query" || m.type === "Parse" ? m.sql : this.sqlOf(m), method: m.type.toUpperCase(), startedAt: Date.now() };
506
+ this.asked = { sql: m.type === "Query" || m.type === "Parse" ? m.sql : this.sqlOf(m), method: m.type.toUpperCase(), startedAt: Date.now(), sentAt: this.sentBytes };
386
507
  }
387
508
  // EVERY statement of a user's session is held to its removal (review of #507: a check inside the answer let a pool's
388
509
  // keep-alive, its catalog reads, SET and SHOW go on for ever after a revocation)
389
510
  if ((m.type === "Query" || m.type === "Parse" || m.type === "Execute") && !(m.type !== "Query" && this.skipToSync)) {
390
511
  const refused = await this.stillLive(m.type);
512
+ if (this.phase === "closed")
513
+ return;
514
+ // the roles' row filters checked by the catalog Postgres for the roles as they are now, before any statement is
515
+ // held to them (Server.checkRoles: once per roles file and copied tables; nothing to do where no role filters rows)
516
+ await this.server.checkRoles();
391
517
  if (this.phase === "closed")
392
518
  return;
393
519
  if (refused !== null) {
394
520
  this.refuse(refused, m.type === "Execute" ? this.sqlOf(m) : m.sql);
395
- if (this.tx === "open")
396
- this.tx = "failed";
521
+ this.abortTransaction();
397
522
  if (m.type === "Query")
398
- this.send(readyForQuery(this.txStatus()));
523
+ this.send(...this.ready());
399
524
  else
400
525
  this.skipToSync = true;
401
526
  return;
@@ -433,19 +558,19 @@ class Session {
433
558
  startup(m) {
434
559
  switch (m.type) {
435
560
  case "SSLRequest": {
436
- if (this.server.tls === null) {
561
+ if (this.door.tls === null) {
437
562
  this.send(sslResponse(false));
438
563
  return;
439
564
  }
440
565
  this.send(sslResponse(true));
441
- this.startTls(this.server.tls, null);
566
+ this.startTls(this.door.tls, null);
442
567
  return;
443
568
  }
444
569
  case "GSSENCRequest":
445
570
  this.send(sslResponse(false));
446
571
  return;
447
572
  case "CancelRequest": {
448
- this.server.cancel(m.pid, m.secret);
573
+ this.server.cancel(m.pid, m.secret, this.door);
449
574
  this.end("cancel request");
450
575
  return;
451
576
  }
@@ -454,11 +579,12 @@ class Session {
454
579
  this.fatal("0A000", `unsupported frontend protocol ${m.major}.${m.minor}: server supports 3.0`);
455
580
  return;
456
581
  }
457
- if (this.server.tls !== null && !(this.socket instanceof tls.TLSSocket)) {
582
+ if (this.door.tls !== null && !(this.socket instanceof tls.TLSSocket)) {
458
583
  this.fatal("28000", "this server requires TLS: the password must not travel in clear (connect with sslmode=require)");
459
584
  return;
460
585
  }
461
- const user = m.params.user;
586
+ // a public socket's refused logins are rows of the request log: what a stranger names is a name's length there
587
+ const user = this.door.public ? nameLength(m.params.user) : m.params.user;
462
588
  if (user === undefined || user === "") {
463
589
  this.fatal("28000", "no PostgreSQL user name specified in startup packet");
464
590
  return;
@@ -466,8 +592,13 @@ class Session {
466
592
  this.user = user;
467
593
  const database = m.params.database ?? user;
468
594
  if (database !== this.server.database) {
469
- this.fatal("3D000", `database "${database}" does not exist`, `this Mason serves the project "${this.server.database}": connect with database=${this.server.database}`);
470
- return;
595
+ // a share's socket is public: refused here, a wrong name and the right one would answer apart before any
596
+ // password, so a stranger could guess the project's. It is refused after the login, as Postgres does
597
+ if (!this.door.public) {
598
+ this.fatal("3D000", `database "${database}" does not exist`, wrongDatabaseHint(this.server.database));
599
+ return;
600
+ }
601
+ this.wrongDatabase = database;
471
602
  }
472
603
  const encoding = m.params.client_encoding;
473
604
  if (encoding !== undefined && !isUtf8(encoding)) {
@@ -475,7 +606,7 @@ class Session {
475
606
  return;
476
607
  }
477
608
  if (m.params.application_name !== undefined)
478
- this.vars.applicationName = m.params.application_name;
609
+ this.vars.applicationName = (this.door.public ? nameLength(m.params.application_name) : m.params.application_name);
479
610
  try {
480
611
  if (m.params.options !== undefined)
481
612
  this.commandLineOptions(m.params.options);
@@ -492,6 +623,8 @@ class Session {
492
623
  this.fatal(e.fields.code, e.message, e.fields.hint);
493
624
  return;
494
625
  }
626
+ // what RESET and RESET ALL restore: the startup packet's values beside the defaults
627
+ this.startupVars = copyVars(this.vars);
495
628
  this.phase = "password";
496
629
  this.send(authenticationCleartextPassword());
497
630
  return;
@@ -527,14 +660,14 @@ class Session {
527
660
  * and this listener has no certificate to send), so it is refused in the log, naming the way out, and closed.
528
661
  */
529
662
  directTls(first) {
530
- if (this.server.tls === null) {
663
+ if (this.door.tls === null) {
531
664
  this.server.log(`session ${this.id} refused: a direct TLS start (a TLS ClientHello as its first bytes: sslnegotiation=direct, or a TLS ` +
532
665
  `terminator re-encrypting to Mason) on a plain listener — set MASON_PG_TLS_CERT and MASON_PG_TLS_KEY to serve TLS, or terminate TLS ` +
533
666
  `in front and send Mason the plain protocol (docs/POSTGRES-API.md "Connecting")`);
534
667
  this.end("a direct TLS start on a plain listener");
535
668
  return;
536
669
  }
537
- this.startTls(this.server.tls, first);
670
+ this.startTls(this.door.tls, first);
538
671
  }
539
672
  /** libpq's `options=-c name=value …` (JDBC's `options`): each a SET before the session starts */
540
673
  commandLineOptions(options) {
@@ -555,25 +688,38 @@ class Session {
555
688
  }
556
689
  let token;
557
690
  try {
558
- token = await this.server.options.authenticate(m.password, this.user);
691
+ token = await this.door.authenticate(m.password, this.user);
559
692
  }
560
693
  catch (err) {
561
694
  const message = err instanceof Error ? err.message : String(err);
562
695
  // a revocation record that could not be read is no wrong password: the server could not tell, said as such
563
696
  const unknown = err instanceof ApiError && err.status === 503;
564
- const refused = new PgError({ code: unknown ? "57P03" : "28P01",
565
- message: unknown ? `the password could not be checked for user "${this.user}": ${message}` : `password authentication failed for user "${this.user}": ${message}`,
566
- hint: unknown ? "connect again once Mason can read its list of users"
567
- : "a tool that keeps one password connects as a user: mason user add <name> prints its password and URL" }, 401);
697
+ // a disabled user (`mason user disable`): its password was right, so it is no wrong password but a role that may
698
+ // not log in, which Postgres refuses 28000 "role … is not permitted to log in": that sentence alone, why in DETAIL,
699
+ // the way out in HINT, as Postgres splits them
700
+ const disabled = err instanceof ApiError && err.code === USER_DISABLED;
701
+ const refused = disabled
702
+ ? new PgError({ code: "28000", message, detail: `the user "${this.user}" is disabled (mason user disable). Mason: ${USER_DISABLED}`, hint: `mason user enable ${this.user} lets it connect again, with the same password` }, 401)
703
+ : new PgError({ code: unknown ? "57P03" : "28P01",
704
+ message: unknown ? `the password could not be checked for user "${this.user}": ${message}` : `password authentication failed for user "${this.user}": ${message}`,
705
+ hint: unknown ? "connect again once Mason can read its list of users"
706
+ : this.door.loginHint }, 401);
568
707
  // a tool that cannot connect is the first thing to look for in the request log
569
- this.asked = { sql: "", method: "CONNECT", startedAt: Date.now() };
570
- this.recordRequest(refused, undefined, err instanceof ApiError ? err.userName : undefined);
571
- this.send(errorResponse({ ...refused.fields, severity: "FATAL" }));
708
+ this.asked = { sql: "", method: "CONNECT", startedAt: Date.now(), sentAt: this.sentBytes };
709
+ const response = errorResponse({ ...refused.fields, severity: "FATAL" });
710
+ if (this.door.refused?.(this.user) !== false)
711
+ this.recordRequest(refused, undefined, err instanceof ApiError ? err.userName : undefined, [response]);
712
+ this.send(response);
572
713
  this.end("authentication failed");
573
714
  return;
574
715
  }
575
716
  if (this.phase !== "password")
576
717
  return; // the socket closed while the ledger was read
718
+ this.door.logins?.settled(this);
719
+ if (this.wrongDatabase !== null) {
720
+ this.fatal("3D000", `database "${this.wrongDatabase}" does not exist`, wrongDatabaseHint(this.server.database));
721
+ return;
722
+ }
577
723
  this.token = token;
578
724
  this.presented = m.password;
579
725
  this.verifiedAt = this.server.now();
@@ -581,28 +727,24 @@ class Session {
581
727
  this.decoder.maxLength = MAX_MESSAGE_LENGTH; // authenticated: a statement may be as long as the protocol allows
582
728
  // what is happening, as it happens (mason logs once showed little): who connected, and (end) who left
583
729
  this.server.log(`session ${this.id} connected: ${this.vars.applicationName === "" ? "a client with no application_name" : JSON.stringify(this.vars.applicationName)} as ${JSON.stringify(this.user)}` +
584
- (token.user !== undefined ? " (a mason user)" : ""));
585
- const status = [authenticationOk()];
586
- for (const [name, value] of this.reportedParameters())
587
- status.push(parameterStatus(name, value));
588
- status.push(backendKeyData(this.id, this.secret), readyForQuery("I"));
589
- this.sendAll(status);
730
+ (token.user !== undefined ? ` (${this.door.who})` : ""));
731
+ this.sendAll([authenticationOk(), ...this.reportChanges(), backendKeyData(this.id, this.secret), readyForQuery("I")]);
590
732
  }
591
733
  reportedParameters() {
592
- return [
593
- ["application_name", this.vars.applicationName], ["client_encoding", "UTF8"], ["DateStyle", "ISO, MDY"],
594
- ["default_transaction_read_only", "on"], ["in_hot_standby", "on"], ["integer_datetimes", "on"], ["IntervalStyle", "postgres"],
595
- ["is_superuser", "off"], ["search_path", this.vars.searchPath], ["server_encoding", "UTF8"], ["server_version", SERVER_VERSION],
596
- ["session_authorization", this.user], ["standard_conforming_strings", "on"], ["TimeZone", this.vars.timeZone],
597
- ];
734
+ // each value `sessionSetting`'s, the one SHOW and SHOW ALL answer: a reported value and a shown one never part
735
+ return [...REPORTED].sort().map((name) => {
736
+ const value = this.sessionSetting(name);
737
+ if (value === null)
738
+ throw new PgError({ code: "XX000", message: `the reported parameter ${name} has no value of this session's (Mason bug: report it)` }, 500);
739
+ return [spell(name), value];
740
+ });
598
741
  }
599
742
  // ---------------------------------------------------------------- session parameters
600
743
  /**
601
- * SET of one parameter. Returns the ParameterStatus to send when it is one Postgres reports. The parameters this
602
- * server has an opinion on are decided here; the rest go to the catalog Postgres for validation and are kept.
744
+ * SET of one parameter (`value` null: its default). The parameters this server has an opinion on are decided here;
745
+ * the rest go to the catalog Postgres for validation and are kept. What a client is told of it is `reportChanges`'s.
603
746
  */
604
747
  setParameter(name, value, atStartup = false) {
605
- const report = (n, v) => REPORTED.has(n) ? [parameterStatus(spell(n), v)] : [];
606
748
  switch (name) {
607
749
  case "timezone":
608
750
  case "time zone": {
@@ -610,48 +752,48 @@ class Session {
610
752
  if (zone === null)
611
753
  throw new PgError({ code: "22023", message: `invalid value for parameter "TimeZone": "${value}"`, hint: "an IANA zone name (Europe/London) or UTC: this API knows no abbreviation or fixed offset" });
612
754
  this.vars.timeZone = zone;
613
- return report("timezone", zone);
755
+ return;
614
756
  }
615
757
  case "application_name":
616
758
  this.vars.applicationName = value ?? "";
617
- return report(name, this.vars.applicationName);
759
+ return;
618
760
  case "search_path":
619
761
  this.vars.searchPath = value ?? '"$user", public';
620
- return report(name, this.vars.searchPath);
762
+ return;
621
763
  case "client_encoding":
622
764
  case "names":
623
765
  if (value !== null && !isUtf8(value))
624
766
  throw new PgError({ code: "22023", message: `invalid value for parameter "client_encoding": "${value}"`, hint: "this API sends UTF8 only" });
625
- return report("client_encoding", "UTF8");
767
+ return;
626
768
  case "datestyle":
627
769
  if (value !== null && !/^\s*iso\b/i.test(value) && !/,\s*iso\s*$/i.test(value) && value.trim() !== "") {
628
770
  throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
629
771
  }
630
- return report("datestyle", "ISO, MDY");
772
+ return;
631
773
  case "extra_float_digits": {
632
774
  const n = value === null ? 1 : Number(value);
633
775
  if (!Number.isInteger(n) || n < -15 || n > 3)
634
776
  throw new PgError({ code: "22023", message: `${n} is outside the valid range for parameter "extra_float_digits" (-15 .. 3)` });
635
777
  if (n < 1)
636
778
  throw new PgError({ code: "0A000", message: `extra_float_digits ${n} is not supported: this API sends every float as the shortest text that reads back exactly (Postgres's extra_float_digits >= 1, the default since Postgres 12); fewer digits would be a different number` });
637
- return [];
779
+ return;
638
780
  }
639
781
  case "standard_conforming_strings":
640
782
  if (value !== null && !/^(on|true|yes|1)$/i.test(value))
641
783
  throw new PgError({ code: "0A000", message: "standard_conforming_strings off is not supported: a string literal is read as Postgres reads it by default" });
642
- return report(name, "on");
643
- case "intervalstyle": return report(name, "postgres");
784
+ return;
785
+ case "intervalstyle": return;
644
786
  case "transaction_isolation":
645
787
  case "default_transaction_isolation":
646
788
  case "transaction":
647
789
  case "session characteristics":
648
790
  // read committed on a store that is never written: every level answers the same rows
649
- return [];
791
+ return;
650
792
  case "default_transaction_read_only":
651
793
  case "transaction_read_only":
652
794
  if (value !== null && /^(off|false|no|0)$/i.test(value))
653
795
  throw new PgError({ code: "25006", message: "cannot set transaction read-write mode: this API is read-only" });
654
- return report(name, "on");
796
+ return;
655
797
  case "statement_timeout":
656
798
  case "lock_timeout":
657
799
  case "idle_in_transaction_session_timeout":
@@ -680,7 +822,7 @@ class Session {
680
822
  this.vars.other.delete(name);
681
823
  else
682
824
  this.vars.other.set(name, value);
683
- return [];
825
+ return;
684
826
  default: {
685
827
  // a parameter Mason knows nothing of: the catalog Postgres decides whether it exists
686
828
  if (atStartup)
@@ -689,10 +831,124 @@ class Session {
689
831
  this.vars.other.delete(name);
690
832
  else
691
833
  this.vars.other.set(name, value);
692
- return [];
834
+ return;
693
835
  }
694
836
  }
695
837
  }
838
+ /**
839
+ * RESET of one parameter: the value the session STARTED with — its startup packet's (`application_name`, `options`'s
840
+ * `-c`) where it set one, else the default — as Postgres's RESET restores a parameter's session default. A placeholder
841
+ * (`myapp.x`, a dotted name Postgres does not list) has '' for its default and stays defined, as on Postgres.
842
+ */
843
+ async resetParameter(name) {
844
+ const start = this.startupVars;
845
+ switch (name) {
846
+ case "timezone":
847
+ case "time zone":
848
+ this.vars.timeZone = start.timeZone;
849
+ return;
850
+ case "application_name":
851
+ this.vars.applicationName = start.applicationName;
852
+ return;
853
+ case "search_path":
854
+ this.vars.searchPath = start.searchPath;
855
+ return;
856
+ }
857
+ this.setParameter(name, null);
858
+ const was = start.other.get(name) ?? (await this.isPlaceholder(name) ? "" : undefined);
859
+ if (was !== undefined)
860
+ this.vars.other.set(name, was);
861
+ }
862
+ /** RESET ALL (and DISCARD ALL's): every parameter back to the session's start, the startup packet's included; a
863
+ * placeholder the session SET stays defined, '' (`resetParameter`) */
864
+ async resetAll() {
865
+ const placeholders = [];
866
+ for (const name of this.vars.other.keys())
867
+ if (await this.isPlaceholder(name))
868
+ placeholders.push(name);
869
+ const tx = this.settingsAtStart();
870
+ this.vars = copyVars(this.startupVars);
871
+ for (const name of placeholders)
872
+ if (!this.vars.other.has(name))
873
+ this.vars.other.set(name, "");
874
+ // a SET of every parameter: it overrides each LOCAL before it, and a ROLLBACK undoes it
875
+ tx.commit = copyVars(this.vars);
876
+ }
877
+ /** a custom placeholder (`SET myapp.x`): a dotted name the catalog Postgres does not list (an extension's, `plpgsql.*`, it lists) */
878
+ async isPlaceholder(name) {
879
+ return name.includes(".") && !(await this.server.catalogSettings()).has(name.toLowerCase());
880
+ }
881
+ /**
882
+ * WHAT THE CLIENT IS TOLD OF ITS REPORTED PARAMETERS (GUC_REPORT): a ParameterStatus for each whose value is not the one
883
+ * last reported — all of them at the startup, those a SET, a RESET, RESET ALL or DISCARD ALL moved after it — as
884
+ * Postgres reports a changed one (`ReportChangedGUCOptions`), each value `sessionSetting`'s through `reportedParameters`.
885
+ * A driver that caches them (pgjdbc, npgsql) was left with a SET's value after RESET ALL (GitHub issue 41). After the
886
+ * startup, `ready` alone calls it: what a request moved is told once, before its ReadyForQuery.
887
+ */
888
+ reportChanges() {
889
+ const out = [];
890
+ for (const [name, value] of this.reportedParameters()) {
891
+ if (this.reported.get(name) === value)
892
+ continue;
893
+ this.reported.set(name, value);
894
+ out.push(parameterStatus(name, value));
895
+ }
896
+ return out;
897
+ }
898
+ /**
899
+ * A REQUEST'S END: the reported parameters it changed, then its ReadyForQuery — where Postgres 14+ reports them
900
+ * (`ReportChangedGUCOptions` in PostgresMain, just before ReadyForQuery): after the statement's CommandComplete, and
901
+ * ONCE per parameter per Query string or Sync, its last value (`SET application_name = 'a'; SET application_name =
902
+ * 'b'` reports 'b' alone). With no block open the request's implicit transaction ends here, so what it reverted is
903
+ * what is reported: a SET LOCAL told nothing, a block's revert is told by the ReadyForQuery that ends the block.
904
+ */
905
+ ready() {
906
+ if (this.tx === "none")
907
+ this.endTransaction(true);
908
+ return [...this.reportChanges(), readyForQuery(this.txStatus())];
909
+ }
910
+ /**
911
+ * A TRANSACTION'S END, explicit or implicit: the session's settings become what it leaves (`settingsTx`), as Postgres's
912
+ * AtEOXact_GUC restores them, one level, since a SAVEPOINT is refused here. A placeholder a transaction defined stays
913
+ * defined (`applySetting`); a transaction that set nothing changes nothing.
914
+ */
915
+ endTransaction(committed) {
916
+ this.implicitBlock = false;
917
+ if (this.settingsTx === null)
918
+ return;
919
+ this.vars = committed ? this.settingsTx.commit : this.settingsTx.rollback;
920
+ this.settingsTx = null;
921
+ }
922
+ /**
923
+ * A FAILED BLOCK ACCEPTS NOTHING BUT ITS END, on every message: Postgres checks a Query's statement, a Parse, a Bind and
924
+ * an Execute (`IsTransactionExitStmt`), each before the statement is classified or sent anywhere, so 25P02 is said
925
+ * where Mason's own refusal of it (an INSERT's 25006) or the catalog's answer would be. Undefined: an empty statement,
926
+ * which a Bind refuses as it refuses any other. A Parse of an empty string passes (`parseMessage`), a Describe has its
927
+ * own rule (`describedInFailedBlock`), and a Close always runs.
928
+ */
929
+ heldToFailedBlock(stmt) {
930
+ if (this.tx === "failed" && (stmt === undefined || !endsFailedBlock(stmt)))
931
+ throw aborted();
932
+ }
933
+ /** A Describe in a failed block: refused where it would describe a result (Postgres reads the catalog for a
934
+ * RowDescription, which an aborted transaction cannot), answered otherwise, so a driver that describes all it sends
935
+ * can still send its ROLLBACK. */
936
+ describedInFailedBlock(statement) {
937
+ const kind = statement.shape?.kind;
938
+ if (this.tx === "failed" && kind !== undefined && ["catalog", "duckdb", "explain", "show", "set_config"].includes(kind))
939
+ throw aborted();
940
+ }
941
+ /**
942
+ * A STATEMENT FAILED, the one way a transaction fails: an open block is failed (25P02 until its end), and in every
943
+ * case what the transaction set is undone NOW, as Postgres's AbortTransaction does at the error, never at the ROLLBACK
944
+ * that follows — so the failed block's own ReadyForQuery reports the revert, and an implicit transaction's SET (`SET a;
945
+ * SELECT 1/0`, or a pipeline's before its Sync) never took.
946
+ */
947
+ abortTransaction() {
948
+ if (this.tx === "open")
949
+ this.tx = "failed";
950
+ this.endTransaction(false);
951
+ }
696
952
  /** The catalog Postgres validates a parameter this server has no opinion on; its own error is the answer. Inside a
697
953
  * transaction that is ROLLED BACK: the sidecar is one connection every session shares, and a SET that stuck there
698
954
  * was every session's (review of #480). The value itself lives in this session's `vars.other`. */
@@ -716,6 +972,7 @@ class Session {
716
972
  finally {
717
973
  if (this.tx === "none")
718
974
  this.txNow = null;
975
+ this.implicitBlock = false;
719
976
  }
720
977
  }
721
978
  /** the transaction's "now", the transaction begun at `statementNow` when none is open */
@@ -731,18 +988,20 @@ class Session {
731
988
  catch (err) {
732
989
  const e = asPgError(err, sql);
733
990
  this.refuse(e, sql);
734
- if (this.tx === "open")
735
- this.tx = "failed";
736
- this.send(readyForQuery(this.txStatus()));
991
+ this.abortTransaction();
992
+ this.send(...this.ready());
737
993
  return;
738
994
  }
739
995
  if (stmts.length === 0) {
740
- this.send(emptyQueryResponse(), readyForQuery(this.txStatus()));
996
+ this.send(emptyQueryResponse(), ...this.ready());
741
997
  return;
742
998
  }
743
999
  for (const raw of stmts) {
744
1000
  const text = statementText(sql, raw);
745
- this.asked = { sql: text, method: "QUERY", startedAt: Date.now() };
1001
+ this.asked = { sql: text, method: "QUERY", startedAt: Date.now(), sentAt: this.sentBytes };
1002
+ // at every statement, as Postgres's BeginImplicitTransactionBlock: a COMMIT among them ended the block before it
1003
+ if (stmts.length > 1)
1004
+ this.implicitBlock = true;
746
1005
  try {
747
1006
  const out = await this.runSimple(raw, text, sql);
748
1007
  this.sendAll(out);
@@ -750,12 +1009,11 @@ class Session {
750
1009
  catch (err) {
751
1010
  const e = asPgError(err, sql);
752
1011
  this.refuse(e, text);
753
- if (this.tx === "open")
754
- this.tx = "failed";
1012
+ this.abortTransaction();
755
1013
  break;
756
1014
  }
757
1015
  }
758
- this.send(readyForQuery(this.txStatus()));
1016
+ this.send(...this.ready());
759
1017
  }
760
1018
  /**
761
1019
  * EVERY STATEMENT'S ErrorResponse goes out here, and is logged here at the normal level (a BI tool met an
@@ -766,9 +1024,10 @@ class Session {
766
1024
  */
767
1025
  refuse(e, sql, at) {
768
1026
  this.server.log(refusalLine(this.id, e.fields, sql));
769
- this.recordRequest(e, sql);
1027
+ const response = errorResponse(e.fields);
1028
+ this.recordRequest(e, sql, undefined, [response]);
770
1029
  this.traceRefusal(e, at);
771
- this.send(errorResponse(e.fields));
1030
+ this.send(response);
772
1031
  }
773
1032
  traceRefusal(e, at) {
774
1033
  if (this.server.options.trace !== true)
@@ -782,22 +1041,30 @@ class Session {
782
1041
  * statement's ErrorResponse goes out), and for a refused password (CONNECT), through `options.record`. A catalog
783
1042
  * read, a SET or a SHOW answered is the protocol's chatter, not recorded. Never throws: `record` buffers.
784
1043
  */
785
- recordRequest(answer, sql, refusedUser, of) {
786
- // `of`: a statement recorded after its own messages (a portal dropped suspended), not the one being asked now
787
- const asked = of === undefined ? this.asked : { sql: sql ?? "", method: of.method, startedAt: Date.now(), ...(of.jsonReads !== undefined ? { jsonReads: of.jsonReads } : {}) };
1044
+ recordRequest(answer, sql, refusedUser, then = [], of) {
1045
+ // `of`: a statement recorded after its own messages (a portal dropped suspended), not the one being asked now: what
1046
+ // it sent is what its pages sent (`carried`), nothing since
1047
+ const asked = of === undefined ? this.asked
1048
+ : { sql: sql ?? "", method: of.method, startedAt: Date.now(), sentAt: this.sentBytes, carried: of.carried, ...(of.jsonReads !== undefined ? { jsonReads: of.jsonReads } : {}) };
788
1049
  const startedAt = (!(answer instanceof PgError) ? answer.startedAt : undefined) ?? asked?.startedAt ?? Date.now();
1050
+ const bytesSent = this.bytesFor(then, asked);
789
1051
  // a refusal's statement is the one refuse() names (a simple Query's own statement, an Execute's portal's)
790
1052
  const stored = storedPayload(sql ?? asked?.sql ?? "");
791
1053
  const user = this.token?.user ?? refusedUser;
1054
+ // the EFFECTIVE role: the user's own, else the roles file's default (what the statement was held to)
1055
+ const role = this.roleName();
792
1056
  this.server.options.record({
793
1057
  requestId: crypto.randomUUID(), at: new Date(startedAt), method: asked?.method ?? "QUERY", durationMs: Date.now() - startedAt,
794
1058
  client: `postgres:${this.vars.applicationName || "unnamed"}`, session: this.id, payload: stored.text, payloadTruncated: stored.truncated,
795
- caller: hashCaller(this.presented),
1059
+ caller: hashCaller(this.presented), bytesSent,
796
1060
  ...(user !== undefined ? { user } : {}),
1061
+ ...(role !== undefined ? { role } : {}),
1062
+ ...(this.token?.attributes !== undefined && Object.keys(this.token.attributes).length > 0 ? { attributes: this.token.attributes } : {}),
797
1063
  ...(asked?.jsonReads !== undefined && asked.jsonReads.length > 0 ? { jsonReads: asked.jsonReads } : {}),
798
1064
  ...(answer instanceof PgError
799
1065
  ? { status: answer.status, sqlstate: answer.fields.code, error: answer.fields.message,
800
- ...(answer.fields.detail ? { detail: answer.fields.detail } : {}), ...(answer.fields.hint ? { hint: answer.fields.hint } : {}) }
1066
+ ...(answer.fields.detail ? { detail: answer.fields.detail } : {}), ...(answer.fields.hint ? { hint: answer.fields.hint } : {}),
1067
+ ...(answer.gap !== null ? { gap: answer.gap } : {}) }
801
1068
  : { status: 200, rows: answer.rows }),
802
1069
  });
803
1070
  }
@@ -813,36 +1080,35 @@ class Session {
813
1080
  if (raw.stmt === undefined)
814
1081
  return [emptyQueryResponse()];
815
1082
  const now = this.txStart(this.statementNow);
1083
+ this.heldToFailedBlock(raw.stmt);
816
1084
  const t = this.classify(raw.stmt, text);
817
- if (this.tx === "failed" && !(t.kind === "transaction" && t.tag !== "BEGIN" && t.tag !== "START TRANSACTION")) {
818
- throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
819
- }
820
1085
  switch (t.kind) {
821
1086
  case "empty": return [emptyQueryResponse()];
822
1087
  case "explain": {
823
1088
  const e = await this.explaining(raw, whole, t, []);
824
1089
  if (e.plan === null)
825
- return catalogExplained(await this.forwardSimple(text), e.options);
1090
+ return catalogExplained(await this.forwardSimple(this.spliced(text, raw)), e.options);
826
1091
  const controller = new AbortController();
827
1092
  const disarm = this.armTimeout(controller);
828
1093
  try {
829
1094
  const { field, rows } = await this.duckExplain({ options: e.options, plan: e.plan }, [], [], { parse: now, exec: now }, controller);
830
- this.recordRequest({ rows: rows.length });
831
- return [rowDescription([field]), ...rows, commandComplete("EXPLAIN")];
1095
+ const out = [rowDescription([field]), ...rows, commandComplete("EXPLAIN")];
1096
+ this.recordRequest({ rows: rows.length }, undefined, undefined, out);
1097
+ return out;
832
1098
  }
833
1099
  finally {
834
1100
  disarm();
835
1101
  }
836
1102
  }
837
- case "catalog": return this.forwardSimple(text);
1103
+ case "catalog": return this.forwardSimple(this.spliced(text, raw));
838
1104
  case "set": return this.setStatement(t);
1105
+ // its RowDescription first, as Postgres's: a value it refuses is refused after it
1106
+ case "set_config":
1107
+ this.send(this.setConfigDescription(t, 0));
1108
+ return this.setConfigStatement(t);
839
1109
  case "show": return this.showStatement(t.name);
840
1110
  case "transaction": return [...this.transaction(t.tag)];
841
- case "discard":
842
- this.statements.clear();
843
- this.dropPortals();
844
- this.vars = { ...defaultVars(), applicationName: this.vars.applicationName };
845
- return [commandComplete("DISCARD ALL")];
1111
+ case "discard": return this.discard(t.target);
846
1112
  case "deallocate":
847
1113
  this.statements.clear();
848
1114
  return [commandComplete("DEALLOCATE ALL")];
@@ -860,8 +1126,9 @@ class Session {
860
1126
  finally {
861
1127
  cursor.answer.close();
862
1128
  }
863
- this.recordRequest({ rows: cursor.sent });
864
- return [commandComplete(`SELECT ${cursor.sent}`)];
1129
+ const done = [commandComplete(`SELECT ${cursor.sent}`)];
1130
+ this.recordRequest({ rows: cursor.sent }, undefined, undefined, done);
1131
+ return done;
865
1132
  }
866
1133
  finally {
867
1134
  disarm();
@@ -869,15 +1136,200 @@ class Session {
869
1136
  }
870
1137
  }
871
1138
  }
872
- /** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone and
873
- * declared parameter types until the schema changes. Every statement DuckDB runs has one: never its text as written. */
1139
+ /** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone,
1140
+ * declared parameter types and the session's role until the schema or the roles change. Every statement DuckDB
1141
+ * runs has one: never its text as written. */
874
1142
  async duckPlan(raw, whole, paramOids, under = this.vars, wireBound = false) {
875
1143
  const stmt = raw.stmt;
876
1144
  const bytes = Buffer.from(whole, "utf8");
877
1145
  const base = raw.stmt_location ?? 0;
878
1146
  const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
879
1147
  const { searchPath, timeZone } = under;
880
- return this.server.duckPlan(text, paramOids, searchPath, timeZone, wireBound, () => this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, searchPath, timeZone, this.server.userTypes(), wireBound));
1148
+ const grants = this.grants();
1149
+ // the plan cache's key is the role the plan is held to: the effective one (a default's plans are that role's), and
1150
+ // a user with no role under a file with no default its own key, never a token's ("") — and, where the role filters
1151
+ // rows, the filters bound to the user's values (a plan holds them as literals)
1152
+ const rowsKey = rowsKeyOf(grants);
1153
+ const key = `${grants?.noRole !== undefined ? "\u0000no role" : this.roleName() ?? ""}${rowsKey === "" ? "" : `\u0000${rowsKey}`}`;
1154
+ return this.server.duckPlan(text, paramOids, searchPath, timeZone, wireBound, key, async () => {
1155
+ const plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, searchPath, timeZone, this.server.userTypes(), wireBound, grants);
1156
+ return rowsKey === "" ? plan : { ...plan, rowsKey };
1157
+ });
1158
+ }
1159
+ /**
1160
+ * THE SESSION'S ROLE NOW (src/duck/grants.ts; docs/POSTGRES-API.md "Roles"): what its user's role may read, asked at
1161
+ * every plan, every Execute and every catalog read — the user's role is re-read with the user before each statement
1162
+ * (`stillLive`), the roles file whenever it changes (`PgApiOptions.roles`), so a role given, taken or edited applies at
1163
+ * the next statement, with no restart. undefined: no roles anywhere (nothing to hold, and no reads to record);
1164
+ * null: a session held to nothing beside roles — a token's (its plan still names its reads). FAIL CLOSED: a role the
1165
+ * file no longer names, or a file that cannot be read as it is now, refuses the statement by name, never widens.
1166
+ *
1167
+ * A USER WITH NO ROLE OF ITS OWN IS HELD TO WHAT THE FILE SAYS (stage 1b, never a default in the code): no roles file,
1168
+ * every copied table; the file's `default:`, that role (read here at every statement, so an edit of `default:` applies
1169
+ * at the next one); a file with no `default:`, nothing (`RoleGrants.noRole`: every statement that reads a copied table
1170
+ * refused with the two ways out, src/duck/grants.ts noRoleRefusal). A token, which is no user, reads every copied table.
1171
+ */
1172
+ grants() {
1173
+ const g = this.ownGrants();
1174
+ return g === null || g === undefined ? g : this.withRows(g);
1175
+ }
1176
+ /**
1177
+ * THE ROLE'S ROW FILTERS BOUND TO THIS SESSION'S USER (src/duck/row-filter.ts): each table's `where:` with the user's
1178
+ * attribute values as literals of the types the catalog inferred (`Server.checkRoles`), each `through:` read through
1179
+ * its parent. A USER WITHOUT A VALUE ITS ROLE'S FILTERS READ IS REFUSED, LOUDLY, at every statement — never answered
1180
+ * with no rows (a user added before the roles file gained the attribute): 42501, the attribute and the filter in the
1181
+ * DETAIL, the exact commands in the HINT. A filter not checked yet (the catalog a step behind the file) refuses the
1182
+ * statement, said: fail closed.
1183
+ */
1184
+ withRows(g) {
1185
+ if (g.filters === undefined || g.filters.size === 0)
1186
+ return g;
1187
+ const values = this.token?.attributes ?? {};
1188
+ const cached = this.boundRows;
1189
+ if (cached !== null && cached.of === g && cached.values === values)
1190
+ return cached.bound;
1191
+ const user = this.token?.user ?? this.user;
1192
+ const missing = missingAttributes(g.filters.values(), values);
1193
+ if (missing.length > 0) {
1194
+ const sets = missing.map((m) => `--set ${m.attr}=<value>`).join(" ");
1195
+ const shapes = g.attributes ?? new Map();
1196
+ throw new PgError({ code: "42501", message: `permission denied: the user ${JSON.stringify(user)} has no value for the attribute ${missing.map((m) => m.attr).join(", ")}, which the role ${g.name} filters rows by`,
1197
+ detail: missing.map((m) => `${m.at} reads :${m.attr} (${m.where})${shapes.get(m.attr) === "list" ? `, a list: one --set ${m.attr}=… per value` : ""}`).join("; ") + `; the user was added without it, and Mason refuses rather than answer no rows`,
1198
+ hint: `mason user remove ${user} && mason user add ${user} --role ${g.name} ${sets}` }, 403);
1199
+ }
1200
+ const rows = new Map();
1201
+ for (const f of g.filters.values()) {
1202
+ if (f.where === undefined) {
1203
+ rows.set(f.table, bindFilter(f, new Map(), values));
1204
+ continue;
1205
+ }
1206
+ const types = g.filterTypes?.get(f.table);
1207
+ // a table not copied yet has no types (nothing reads it: the statement is Postgres's 42P01); a copied one must
1208
+ if (types === undefined) {
1209
+ if (g.filterTypes === undefined) {
1210
+ throw new PgError({ code: "57P03", message: `the role ${g.name}'s row filters are being checked by the catalog: run the statement again in a moment`, hint: "the catalog Postgres checks each where: of the roles file as it changes" }, 503);
1211
+ }
1212
+ continue;
1213
+ }
1214
+ rows.set(f.table, bindFilter(f, types, values));
1215
+ }
1216
+ const bound = { ...g, rows, ...(user !== undefined ? { rowsUser: user } : {}) };
1217
+ this.boundRows = { of: g, values, bound };
1218
+ return bound;
1219
+ }
1220
+ boundRows = null;
1221
+ /** the role the session is held to now, its filters unbound (`grants` binds them) */
1222
+ ownGrants() {
1223
+ const live = this.server.rolesChecked();
1224
+ const own = this.token?.role;
1225
+ const user = this.token?.user ?? this.user;
1226
+ if (own === undefined) {
1227
+ if (this.token?.user === undefined || live.file === null)
1228
+ return live.grants.size === 0 ? undefined : null;
1229
+ if (live.problem !== null) {
1230
+ throw new PgError({ code: "57P03", message: `the roles file cannot be read as it is now, so the statements of the user ${JSON.stringify(user)} are refused: the user has no role of its own and the file's default cannot be read: ${live.problem}`,
1231
+ hint: `fix ${live.file}: a user with no role is held to its default: line` }, 503);
1232
+ }
1233
+ if (live.default !== null)
1234
+ return live.grants.get(live.default) ?? this.notInFile(live, live.default, user);
1235
+ return { name: "", file: live.file, tables: new Map(), all: false, noRole: { user, example: exampleRole(live) } };
1236
+ }
1237
+ if (live.problem !== null) {
1238
+ throw new PgError({ code: "57P03", message: `the roles file cannot be read as it is now, so the role ${own}'s statements are refused: ${live.problem}`,
1239
+ hint: `fix ${live.file ?? "the roles file"}: the role ${own} is the user ${JSON.stringify(user)}'s` }, 503);
1240
+ }
1241
+ return live.grants.get(own) ?? this.notInFile(live, own, user);
1242
+ }
1243
+ /** a role the roles file does not name (now): refused by name, never widened */
1244
+ notInFile(live, role, user) {
1245
+ const named = [...live.grants.keys()];
1246
+ throw new PgError({ code: "42501", message: `permission denied: the role ${role} of the user ${JSON.stringify(user)} is not in ${live.file ?? "the project's roles file (mason/roles.yml)"}`,
1247
+ detail: `the user keeps the role it was added with; a role the file no longer names reads nothing until it is added again or the user is made again${named.length ? ` (the roles: ${named.join(", ")})` : " (the file names no role)"}`,
1248
+ hint: `add the role ${role} to ${live.file ?? "mason/roles.yml"}, or mason user remove ${user} and mason user add ${user}${named.length ? ` --role ${named[0]}` : ""}` }, 403);
1249
+ }
1250
+ /**
1251
+ * THE ROLE THE SESSION IS HELD TO NOW, by name — the user's own, else (a user with none) the roles file's `default:`;
1252
+ * undefined for a token, with no roles file, under a file with no default, or one that cannot be read (`grants`
1253
+ * refuses that). Read by the catalog's `SET ROLE` (`catalogAs`), the plan cache's key and the request log's `role`,
1254
+ * so each says the role the statement was held to. Never throws (the request log reads it).
1255
+ */
1256
+ roleName() {
1257
+ const own = this.token?.role;
1258
+ if (own !== undefined)
1259
+ return own;
1260
+ if (this.token?.user === undefined)
1261
+ return undefined;
1262
+ const live = this.server.rolesNow();
1263
+ return live.file !== null && live.problem === null ? live.default ?? undefined : undefined;
1264
+ }
1265
+ /**
1266
+ * THE CATALOG'S ROLE OF THIS SESSION NOW: the effective role (`roleName`), or — for a user the file holds to nothing
1267
+ * (no role, no `default:`) — `NO_ROLE_CATALOG`, a role with no grant, so information_schema lists it nothing and every
1268
+ * privilege function is false, as on Postgres for a login with no grant. Undefined: the catalog's own (a token, no
1269
+ * roles file). Throws where `grants` refuses (fail closed).
1270
+ */
1271
+ catalogRole() {
1272
+ return this.grants()?.noRole !== undefined ? NO_ROLE_CATALOG : this.roleName();
1273
+ }
1274
+ /** a user the roles file holds to nothing (no role of its own, no `default:`), read without throwing: its catalog
1275
+ * statements answer `current_user`, `current_role` and `user` as its own name (`userSpliced`), as Postgres does for
1276
+ * a login with no grant, never the catalog role's */
1277
+ heldToNothing() {
1278
+ if (this.token?.role !== undefined || this.token?.user === undefined)
1279
+ return false;
1280
+ const live = this.server.rolesNow();
1281
+ return live.file !== null && live.problem === null && live.default === null;
1282
+ }
1283
+ /** the statement as the catalog runs it for this session: the user's name spliced for the keywords that name it */
1284
+ spliced(text, raw) {
1285
+ return userSpliced(text, raw.stmt, raw.stmt_location ?? 0, this.user, this.heldToNothing() ? HELD_TO_NOTHING_OPS : SESSION_OPS);
1286
+ }
1287
+ /** a plan's reads held to the session's role of THIS moment (a role given or taken since the Parse): refused as the
1288
+ * Parse would have refused it; a plan made before any role existed has no reads, and is planned again at its next Parse */
1289
+ heldToRole(plan) {
1290
+ const grants = this.grants();
1291
+ if (grants === null || grants === undefined)
1292
+ return;
1293
+ if (plan.reads === undefined && grants.noRole === undefined) {
1294
+ throw new PgError({ code: "42501", message: `permission denied: the statement was prepared before the user ${JSON.stringify(this.token?.user ?? this.user)} had the role ${grants.name}`,
1295
+ hint: "prepare the statement again: its reads are then held to the role" }, 403);
1296
+ }
1297
+ if (plan.reads === undefined) {
1298
+ // a plan made before any roles file existed, for a user the file now holds to nothing: prepared again, held then
1299
+ throw new PgError({ code: "42501", message: `permission denied: the statement was prepared before ${grants.file} held the user ${JSON.stringify(grants.noRole.user)} to a role`,
1300
+ hint: "prepare the statement again: its reads are then held to the roles file" }, 403);
1301
+ }
1302
+ const refused = grantRefusal(plan.reads, grants);
1303
+ if (refused !== null)
1304
+ throw refused;
1305
+ // a plan holds the filters it was made under as literals: one made under others (the roles file edited since its
1306
+ // Parse) never runs — executeDuck plans it again first, so this is the last guard
1307
+ if ((plan.rowsKey ?? "") !== rowsKeyOf(grants)) {
1308
+ throw new PgError({ code: "42501", message: "permission denied: the statement was prepared under other row filters of its role", hint: "prepare the statement again: it is then held to the role's filters as they are now" }, 403);
1309
+ }
1310
+ }
1311
+ /**
1312
+ * A CATALOG REQUEST OF THIS SESSION, WITH ITS SETTINGS AND AS ITS ROLE (src/api/postgres/catalog.ts asSession, asRole):
1313
+ * every request carries the session's settings (`sessionSettings`), so `current_setting(name)` answers what `SHOW`
1314
+ * does, and a session with a role runs its catalog statements under `SET ROLE`, RESET after, each in the one request
1315
+ * the catalog runs whole, so `information_schema`,
1316
+ * `has_table_privilege` and `has_column_privilege` answer for the role and no other session's request meets it. A
1317
+ * role the catalog has not made yet (its rebuild a moment behind the file) refuses the statement, said; a role the
1318
+ * file no longer names is refused before it reaches the catalog (`grants`).
1319
+ */
1320
+ async catalogAs(message) {
1321
+ // fail closed first (a role the file lacks, a file that cannot be read), then the catalog's role of the session
1322
+ // (`catalogRole`; both read the roles once, with no await between)
1323
+ const role = this.catalogRole();
1324
+ const reply = unwrapSession(splitFrames(await this.server.catalog.run(asSession(role === undefined ? message : asRole(message, role), this.sessionSettings()))));
1325
+ if (role === undefined)
1326
+ return reply;
1327
+ const { frames, setRoleRefused } = unwrapRole(reply);
1328
+ if (setRoleRefused !== null) {
1329
+ const e = relayedError(setRoleRefused);
1330
+ throw new PgError({ code: "42501", message: `permission denied: the catalog has no role ${role} yet (${e.message})`, hint: "run the statement again: the catalog is rebuilt with the roles as the roles file changes" }, 403);
1331
+ }
1332
+ return frames;
881
1333
  }
882
1334
  /**
883
1335
  * A ROW COUNT ON THE WIRE IS A BOUND THE PLAN AT PARSE COULD NOT SEE (docs/DESIGN.md "A LEFT lateral of a list"): an
@@ -943,7 +1395,12 @@ class Session {
943
1395
  throw new PgError({ code: "0A000", message: "Mason cannot find the statement this EXPLAIN explains in its text (a Mason gap: report it, with the statement)" });
944
1396
  let plan;
945
1397
  try {
946
- plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), found.text, t.query, base + Buffer.byteLength(text.slice(0, found.offset)), paramOids, this.vars.searchPath, this.vars.timeZone, this.server.userTypes());
1398
+ const grants = this.grants();
1399
+ plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), found.text, t.query, base + Buffer.byteLength(text.slice(0, found.offset)), paramOids, this.vars.searchPath, this.vars.timeZone, this.server.userTypes(), false, grants);
1400
+ // the row filters it was made under, as every plan of a session carries them (Session.duckPlan)
1401
+ const rowsKey = rowsKeyOf(grants);
1402
+ if (rowsKey !== "")
1403
+ plan = { ...plan, rowsKey };
947
1404
  }
948
1405
  catch (e) {
949
1406
  // a refusal of the statement explained is placed as Postgres places it: in the whole text, past `EXPLAIN …`
@@ -956,14 +1413,21 @@ class Session {
956
1413
  }
957
1414
  /** an EXPLAIN of a DuckDB statement, run: its QUERY PLAN rows (ANALYZE runs the statement and sends none of its rows) */
958
1415
  async duckExplain(e, params, resultFormats, clock, controller) {
1416
+ this.heldToRole(e.plan);
1417
+ // ANALYZE RUNS THE STATEMENT AND REPORTS WHAT THE ENGINE DID, which is a count of what it read and what it proved
1418
+ // of the whole table: nothing of it can be said to a filtered user, so there is nothing left to run it for
1419
+ const filtered = e.plan.rowFilters ?? [];
1420
+ if (e.options.analyze && filtered.length > 0) {
1421
+ throw gapRefusal("roles", `EXPLAIN ANALYZE is not answered for a statement that reads a table the role filters (${filtered.map((f) => f.table).join(", ")})`, "EXPLAIN without ANALYZE says the tables and what is sent to the engine; a user with no row filter reads the full plan and its rows");
1422
+ }
959
1423
  await this.readAside();
960
1424
  const runner = await this.duck();
961
1425
  this.running = controller;
962
1426
  try {
963
- const values = await this.duckValues(e.plan, params, clock);
964
- const lines = await runner.explain(e.plan.sql, values, this.duckSession(), controller.signal, { options: e.options, fields: e.plan.fields, exact: e.plan.exact ?? [], planning: e.plan.timing ?? { analysisMs: 0, rewriteMs: 0 },
1427
+ const values = filtered.length > 0 ? [] : await this.duckValues(e.plan, params, clock);
1428
+ const lines = await runner.explain(e.plan.sql, values, await this.duckSession(), controller.signal, { options: e.options, fields: e.plan.fields, exact: e.plan.exact ?? [], planning: e.plan.timing ?? { analysisMs: 0, rewriteMs: 0 },
965
1429
  // how each copy is kept current, as the sync says it to mason status, read now (src/duck/sync.ts KeptCurrent)
966
- copies: copyFacts(this.server.options.duck.copies?.() ?? []) });
1430
+ copies: copyFacts(this.server.options.duck.copies?.() ?? []), ...(filtered.length > 0 ? { rowFilters: filtered, reads: (e.plan.reads ?? []).map((r) => r.table) } : {}) });
967
1431
  return { field: explainField(e.options, formatAt(resultFormats, 0)), rows: lines.map((l) => dataRow([Buffer.from(l)])) };
968
1432
  }
969
1433
  finally {
@@ -984,17 +1448,27 @@ class Session {
984
1448
  this.duckRunner ??= await this.server.options.duck.open();
985
1449
  return this.duckRunner;
986
1450
  }
987
- duckSession() { return { timeZone: this.vars.timeZone, searchPath: this.vars.searchPath }; }
1451
+ /**
1452
+ * WHAT A DUCKDB STATEMENT OF THIS SESSION RUNS UNDER: its TimeZone and search_path, and every setting
1453
+ * `current_setting(name)` reads beside a copied table (src/duck/rewrite.ts) — the catalog Postgres's own
1454
+ * (`Server.catalogSettings`) with the session's laid over (`sessionSettings`), as the catalog's `current_setting`
1455
+ * answers them, never DuckDB's own settings.
1456
+ */
1457
+ async duckSession() {
1458
+ const settings = new Map([...await this.server.catalogSettings(), ...this.sessionSettings()]);
1459
+ return { timeZone: settings.get("timezone") ?? "UTC", searchPath: settings.get("search_path") ?? "", settings: Object.fromEntries(settings) };
1460
+ }
988
1461
  /** A DuckDB statement run: its columns (Postgres's, where it has a plan), and its rows to read, encoded in the
989
1462
  * formats asked */
990
1463
  async duckAnswer(plan, params, resultFormats, clock, controller) {
991
1464
  this.readsOf(plan);
1465
+ this.heldToRole(plan);
992
1466
  await this.readAside();
993
1467
  const runner = await this.duck();
994
1468
  this.running = controller;
995
1469
  try {
996
1470
  const values = await this.duckValues(plan, params, clock);
997
- const a = await runner.run(plan.sql, values, this.duckSession(), controller.signal, plan.fields, plan.exact ?? []);
1471
+ const a = await runner.run(plan.sql, values, await this.duckSession(), controller.signal, plan.fields, plan.exact ?? []);
998
1472
  // a SELECT's analysis always has its RowDescription; without one, DuckDB's own columns are said as they are
999
1473
  const fields = plan.fields !== null
1000
1474
  ? plan.fields.map((f, i) => ({ name: f.name, typeOid: a.columns[i].oid, typeSize: f.size, typeModifier: a.columns[i].oid === f.oid ? f.typmod : -1, format: formatAt(resultFormats, i) }))
@@ -1052,6 +1526,8 @@ class Session {
1052
1526
  if (c.pending.length === 0)
1053
1527
  return;
1054
1528
  c.sent += c.pending.length;
1529
+ if (this.asked !== null)
1530
+ this.asked.rows = c.sent;
1055
1531
  this.sendAll(c.pending);
1056
1532
  c.pending = [];
1057
1533
  }
@@ -1124,7 +1600,7 @@ class Session {
1124
1600
  const c = portal?.cursor;
1125
1601
  if (portal !== undefined && c !== undefined && c.suspended === true && c.recorded !== true && c.failed === undefined) {
1126
1602
  c.recorded = true;
1127
- this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, portal.statement.sql, undefined, { method: "EXECUTE", jsonReads: portal.statement.duck?.jsonReads });
1603
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, portal.statement.sql, undefined, [], { method: "EXECUTE", jsonReads: portal.statement.duck?.jsonReads, carried: portal.bytes });
1128
1604
  }
1129
1605
  c?.answer.close();
1130
1606
  this.portals.delete(name);
@@ -1132,33 +1608,138 @@ class Session {
1132
1608
  dropPortals() { for (const name of [...this.portals.keys()])
1133
1609
  this.dropPortal(name); }
1134
1610
  async forwardSimple(text) {
1135
- const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(text))));
1611
+ const reply = await this.catalogAs(frame("Q", cstr(text)));
1136
1612
  const err = reply.find((f) => f.type === "E");
1137
1613
  if (err !== undefined)
1138
1614
  throw relayedError(err.frame);
1139
1615
  return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1140
1616
  }
1617
+ /** SET and RESET as statements. SET LOCAL and SET TRANSACTION (`local`) outside a block are Postgres's WARNING, said
1618
+ * before the value is read, so an invalid value is refused after it. The value still holds to the end of its own
1619
+ * transaction: to the Sync, in a batch. */
1141
1620
  async setStatement(t) {
1621
+ if (t.local && this.tx === "none" && !this.implicitBlock) {
1622
+ this.send(noticeResponse({ severity: "WARNING", code: "25P01", message: `${t.name === "transaction" ? "SET TRANSACTION" : "SET LOCAL"} can only be used in transaction blocks` }));
1623
+ }
1624
+ return this.applySetting(t);
1625
+ }
1626
+ /** A SET's effect, whoever asked (a statement, `set_config`): `vars` is moved, and a plain SET stays for the
1627
+ * transaction's COMMIT (`settingsTx`, the transaction's first one snapshots it). */
1628
+ async applySetting(t) {
1142
1629
  if (t.name === "all") {
1143
- this.vars = { ...defaultVars(), applicationName: this.vars.applicationName };
1630
+ await this.resetAll();
1144
1631
  return [commandComplete("RESET")];
1145
1632
  }
1146
- const tag = t.value === null ? "RESET" : "SET";
1147
- if (!this.decidesHere(t.name))
1633
+ // SET ROLE and SET SESSION AUTHORIZATION: a session is its user and becomes no one else — RESET, NONE and its own
1634
+ // name are Postgres's no-ops, any other name Postgres's refusal. Never the catalog's to validate: that is one
1635
+ // superuser connection, which accepted `SET ROLE postgres` silently (a review finding of #35)
1636
+ if (t.name === "role" || t.name === "session_authorization") {
1637
+ // its own name, NONE, RESET — and SET ROLE to the role it already is (its own, or the roles file's default: what
1638
+ // current_user says), which Postgres always allows; SET SESSION AUTHORIZATION is the login's name alone
1639
+ const own = t.value === null || t.value === this.user || (t.name === "role" && (t.value.toLowerCase() === "none" || t.value === this.roleName()));
1640
+ if (!own)
1641
+ throw new PgError({ code: "42501", message: `permission denied to set ${t.name === "role" ? "role" : "session authorization"} "${t.value}"` }, 403);
1642
+ return [commandComplete(t.tag)];
1643
+ }
1644
+ // a user's attributes are the users file's (mason user add --set), never the session's to change: Postgres's refusal
1645
+ // of a parameter a role may not set
1646
+ if (t.name.toLowerCase().startsWith("mason.attr."))
1647
+ throw new PgError({ code: "42501", message: `permission denied to set parameter "${t.name}"`, hint: "a user's attributes are set by mason user add --set, never by the session" }, 403);
1648
+ // a placeholder (`myapp.x`) is validated here: a SET the catalog validated left it DEFINED there, with '', for every
1649
+ // session (a `current_setting` of it answered '' where Postgres's is 42704) — its rollback undoes the value, not that
1650
+ const placeholder = await this.isPlaceholder(t.name);
1651
+ if (placeholder)
1652
+ placeholderName(t.name);
1653
+ else if (!this.decidesHere(t.name))
1148
1654
  await this.validateElsewhere(t.name, t.value);
1149
- const status = this.setParameter(t.name, t.value);
1150
- return [...status, commandComplete(tag)];
1655
+ const tx = this.settingsAtStart();
1656
+ if (t.value === null)
1657
+ await this.resetParameter(t.name);
1658
+ else
1659
+ this.setParameter(t.name, t.value);
1660
+ if (!t.local)
1661
+ copySetting(t.name, this.vars, tx.commit);
1662
+ // once named a placeholder stays defined, '', whichever way the transaction ends: its definition is no value it undoes
1663
+ if (placeholder)
1664
+ for (const v of [tx.rollback, tx.commit])
1665
+ if (!v.other.has(t.name))
1666
+ v.other.set(t.name, "");
1667
+ return [commandComplete(t.tag)];
1668
+ }
1669
+ /** what the transaction found, kept at its first SET (`settingsTx`) */
1670
+ settingsAtStart() {
1671
+ return this.settingsTx ??= { rollback: copyVars(this.vars), commit: copyVars(this.vars) };
1672
+ }
1673
+ /** `SELECT set_config(name, value, is_local)` alone (translate.ts setConfig): the session's SET or SET LOCAL, answered
1674
+ * as Postgres answers the function — one text row, the value as `SHOW` then answers it (its own DataRow: one reader),
1675
+ * the column named `set_config` or as aliased. Its RowDescription is the caller's: a Query's, an Execute's Describe's.
1676
+ * Unlike SET LOCAL it warns of nothing outside a block: Postgres's function does not. */
1677
+ async setConfigStatement(t) {
1678
+ await this.applySetting({ kind: "set", name: t.name, value: t.value, local: t.local, tag: "SET" });
1679
+ const row = (await this.showStatement(t.name)).find((f) => f[0] === DATA_ROW);
1680
+ if (row === undefined)
1681
+ throw new PgError({ code: "XX000", message: `SHOW ${t.name} answered no row after set_config (Mason bug: report it)` }, 500);
1682
+ return [row, commandComplete("SELECT 1")];
1683
+ }
1684
+ setConfigDescription(t, format) {
1685
+ return rowDescription([fieldOf(t.column, "text", format)]);
1686
+ }
1687
+ /** DISCARD ALL is Postgres's CLOSE ALL, DEALLOCATE ALL and RESET ALL (its ParameterStatus with it) in one; PLANS,
1688
+ * SEQUENCES and TEMP find nothing of theirs here */
1689
+ async discard(target) {
1690
+ if (target !== "ALL")
1691
+ return [commandComplete(`DISCARD ${target}`)];
1692
+ if (this.tx !== "none" || this.implicitBlock)
1693
+ throw new PgError({ code: "25001", message: "DISCARD ALL cannot run inside a transaction block" });
1694
+ this.statements.clear();
1695
+ this.dropPortals();
1696
+ await this.resetAll();
1697
+ // it commits at once, what follows it in a batch being a transaction of its own (XACT_FLAGS_NEEDIMMEDIATECOMMIT)
1698
+ this.endTransaction(true);
1699
+ return [commandComplete("DISCARD ALL")];
1151
1700
  }
1152
1701
  decidesHere(name) {
1153
1702
  return ["timezone", "time zone", "application_name", "search_path", "client_encoding", "names", "datestyle", "extra_float_digits",
1154
1703
  "standard_conforming_strings", "intervalstyle", "transaction_isolation", "default_transaction_isolation", "transaction", "session characteristics",
1155
1704
  "default_transaction_read_only", "transaction_read_only"].includes(name);
1156
1705
  }
1706
+ /**
1707
+ * WHAT THIS SESSION ANSWERS FOR ONE PARAMETER, by its name in any case: `SHOW <name>`, SHOW ALL's row of that name and
1708
+ * the ParameterStatus the startup reports all read it, so a tool reads ONE string however it asks. The TLS parameters
1709
+ * first (`tlsParameters`), then those this server fixes (`fixedParameter`), then the session's own SETs (`vars.other`);
1710
+ * null for one the catalog Postgres answers as its own.
1711
+ */
1712
+ sessionSetting(name) {
1713
+ const n = name.toLowerCase();
1714
+ return tlsParameters(this.socket instanceof tls.TLSSocket)[n] ?? fixedParameter(n, this.vars, this.user) ?? attributeSetting(n, this.token?.attributes) ?? this.vars.other.get(n) ?? null;
1715
+ }
1716
+ /**
1717
+ * EVERY PARAMETER THIS SESSION HOLDS, each `sessionSetting`'s: what `current_setting(name)` answers, in the catalog
1718
+ * (`catalogAs`, src/api/postgres/catalog.ts asSession) and beside a copied table (`duckSession`) — so it is the string
1719
+ * `SHOW <name>` answers, however a tool asks (GitHub issue 40). The names are those `sessionSetting` reads from.
1720
+ */
1721
+ sessionSettings() {
1722
+ const names = new Set([...Object.keys(tlsParameters(this.socket instanceof tls.TLSSocket)), ...FIXED.keys(), ...this.vars.other.keys(),
1723
+ // the user's attributes, as `mason.attr.<name>` (a list's values joined by commas): what the catalog's row security
1724
+ // policies read (catalog.ts catalogPolicies), as Postgres's read `current_setting('mason.attr.tenant')`
1725
+ ...Object.keys(this.token?.attributes ?? {}).map((a) => `mason.attr.${a}`)]);
1726
+ const out = new Map();
1727
+ for (const n of names) {
1728
+ const v = this.sessionSetting(n);
1729
+ if (v !== null)
1730
+ out.set(n, v);
1731
+ }
1732
+ return out;
1733
+ }
1157
1734
  async showStatement(name) {
1158
1735
  if (name === "all")
1159
- throw new PgError({ code: "0A000", message: "SHOW ALL is not supported: SHOW one parameter" });
1160
- const value = tlsParameters(this.socket instanceof tls.TLSSocket)[name] ?? fixedParameter(name, this.vars, this.user) ?? this.vars.other.get(name) ?? null;
1736
+ return this.showAll();
1737
+ const value = this.sessionSetting(name);
1161
1738
  if (value === null) {
1739
+ // a placeholder this session never SET is none of its settings, whatever the one catalog every session shares
1740
+ // has defined (a forwarded `set_config` defines one there): Postgres's 42704
1741
+ if (await this.isPlaceholder(name))
1742
+ throw new PgError({ code: "42704", message: `unrecognized configuration parameter "${name}"` });
1162
1743
  const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(`SHOW ${quoteIdent(name)}`))));
1163
1744
  const err = reply.find((f) => f.type === "E");
1164
1745
  if (err !== undefined)
@@ -1167,6 +1748,31 @@ class Session {
1167
1748
  }
1168
1749
  return [rowDescription([fieldOf(spell(name), "text", 0)]), dataRow([Buffer.from(value, "utf8")]), commandComplete("SHOW")];
1169
1750
  }
1751
+ /**
1752
+ * SHOW ALL (Postico sends it at every connection): the catalog Postgres's own answer — its RowDescription relayed as it
1753
+ * is (name, setting, description: text, as Postgres's), a row per parameter in its order — with the setting of each row
1754
+ * this session decides replaced by `sessionSetting`'s, so the row and `SHOW <name>` are one string. Mason's settings,
1755
+ * never the source database's (Mason gap show-all). A parameter Postgres's SHOW ALL leaves out is left out here too,
1756
+ * though the session set it or reports it: a placeholder (`SET myapp.x`), session_authorization, is_superuser, role
1757
+ * (GUC_NO_SHOW_ALL) — the catalog, a Postgres, lists exactly the names Postgres lists.
1758
+ */
1759
+ async showAll() {
1760
+ const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr("SHOW ALL"))));
1761
+ const err = reply.find((f) => f.type === "E");
1762
+ if (err !== undefined)
1763
+ throw relayedError(err.frame);
1764
+ return reply.filter((f) => f.type !== "Z").map((f) => {
1765
+ if (f.type !== "D")
1766
+ return f.frame;
1767
+ const [row] = decodeBackend(f.frame);
1768
+ const name = row?.type === "DataRow" && row.values.length === 3 ? row.values[0] : null;
1769
+ if (row?.type !== "DataRow" || name === null || name === undefined) {
1770
+ throw new PgError({ code: "XX000", message: "the catalog Postgres answered SHOW ALL with a row other than name, setting, description (Mason bug: report it)" }, 500);
1771
+ }
1772
+ const value = this.sessionSetting(name.toString("utf8"));
1773
+ return value === null ? f.frame : dataRow([name, Buffer.from(value, "utf8"), row.values[2] ?? null]);
1774
+ });
1775
+ }
1170
1776
  /** a SHOW's RowDescription, for a Describe: the frame its answer starts with (the column is named by the parameter) */
1171
1777
  async showDescription(name) {
1172
1778
  return (await this.showStatement(name)).filter((f) => f[0] === ROW_DESCRIPTION);
@@ -1178,10 +1784,12 @@ class Session {
1178
1784
  this.tx = "open";
1179
1785
  return [commandComplete(tag)];
1180
1786
  }
1787
+ // the implicit transaction's end too: `SET a; ROLLBACK` in one Query undoes the SET, with the same warning
1788
+ const wasFailed = this.tx === "failed";
1789
+ this.endTransaction(tag === "COMMIT" && !wasFailed);
1181
1790
  if (this.tx === "none") {
1182
1791
  return [noticeResponse({ severity: "WARNING", code: "25P01", message: "there is no transaction in progress" }), commandComplete(tag)];
1183
1792
  }
1184
- const wasFailed = this.tx === "failed";
1185
1793
  this.tx = "none";
1186
1794
  // the transaction's "now" ends with it; a statement after it in the same Query begins the next at the Query's arrival
1187
1795
  this.txNow = null;
@@ -1197,7 +1805,7 @@ class Session {
1197
1805
  // an implicit transaction ends at Sync; an open one keeps its "now"
1198
1806
  if (this.tx === "none")
1199
1807
  this.txNow = null;
1200
- this.send(readyForQuery(this.txStatus()));
1808
+ this.send(...this.ready());
1201
1809
  return;
1202
1810
  }
1203
1811
  if (this.skipToSync || m.type === "Flush")
@@ -1210,7 +1818,18 @@ class Session {
1210
1818
  case "Parse": return this.sendAll(await this.parseMessage(m));
1211
1819
  case "Bind": return this.sendAll(await this.bindMessage(m));
1212
1820
  case "Describe": return this.sendAll(await this.describeMessage(m));
1213
- case "Execute": return this.sendAll(await this.executeMessage(m));
1821
+ case "Execute": {
1822
+ const out = await this.executeMessage(m);
1823
+ this.sendAll(out);
1824
+ // AN EXECUTE LEAVES ITS TRANSACTION OPEN UNTIL THE SYNC, and what follows it runs in an implicit block, as
1825
+ // Postgres 18's does, a portal suspended at a row count included: a later SET LOCAL warns of nothing, a later
1826
+ // DISCARD ALL is refused. Not after a transaction statement, which ended it, nor a DISCARD ALL, whose portal
1827
+ // it dropped
1828
+ const kind = this.portals.get(m.portal)?.statement.shape?.kind;
1829
+ if (kind !== undefined && kind !== "transaction")
1830
+ this.implicitBlock = true;
1831
+ return;
1832
+ }
1214
1833
  case "Close": return this.sendAll(await this.closeMessage(m));
1215
1834
  }
1216
1835
  }
@@ -1218,8 +1837,7 @@ class Session {
1218
1837
  const e = asPgError(err, sql);
1219
1838
  this.refuse(e, sql, m.type);
1220
1839
  this.skipToSync = true;
1221
- if (this.tx === "open")
1222
- this.tx = "failed";
1840
+ this.abortTransaction();
1223
1841
  }
1224
1842
  }
1225
1843
  sqlOf(m) {
@@ -1238,7 +1856,7 @@ class Session {
1238
1856
  }
1239
1857
  /** runs frames in the sidecar; throws the first ErrorResponse; returns the rest, less ReadyForQuery */
1240
1858
  async catalogRun(...parts) {
1241
- const reply = splitFrames(await this.server.catalog.run(Buffer.concat(parts)));
1859
+ const reply = await this.catalogAs(Buffer.concat(parts));
1242
1860
  // the sidecar answered NOTHING to a batch: it is ignoring everything until a Sync (an earlier error nobody synced)
1243
1861
  // — a protocol state this server must never leave it in; said, never relayed as an empty answer
1244
1862
  if (reply.length === 0)
@@ -1246,21 +1864,22 @@ class Session {
1246
1864
  const err = reply.find((f) => f.type === "E");
1247
1865
  if (err !== undefined) {
1248
1866
  // AFTER AN ERROR IN THE EXTENDED PROTOCOL POSTGRES IGNORES EVERY MESSAGE UNTIL A SYNC (ignore_till_sync), and the
1249
- // sidecar is one connection every session shares: without this Sync one refused Parse silenced the catalog for
1250
- // every session until a restart (review of #480). The Sync's own ReadyForQuery is consumed here.
1251
- if (!parts.some((p) => p[0] === 0x53 /* S */))
1252
- await this.server.catalog.run(frame("S"));
1867
+ // sidecar is one connection every session shares: one refused Parse with no Sync after it silenced the catalog for
1868
+ // every session until a restart (review of #480). Every request carries one (catalog.ts asSession: the RESET of
1869
+ // the session's settings after it must run), its ReadyForQuery dropped here
1253
1870
  throw relayedError(err.frame);
1254
1871
  }
1255
1872
  return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1256
1873
  }
1257
1874
  async parseMessage(m) {
1258
- if (m.name !== "" && this.statements.has(m.name))
1259
- throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
1260
1875
  const stmts = (await parseSql(m.sql)).stmts ?? [];
1261
1876
  if (stmts.length > 1)
1262
1877
  throw new PgError({ code: "42601", message: "cannot insert multiple commands into a prepared statement" });
1263
1878
  const raw = stmts[0] ?? null;
1879
+ if (raw?.stmt !== undefined)
1880
+ this.heldToFailedBlock(raw.stmt);
1881
+ if (m.name !== "" && this.statements.has(m.name))
1882
+ throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
1264
1883
  const statement = { sql: m.sql, raw, paramOids: [...m.paramTypes], shape: null, catalogName: null };
1265
1884
  if (raw?.stmt !== undefined) {
1266
1885
  statement.shape = this.classify(raw.stmt, statementText(m.sql, raw));
@@ -1285,7 +1904,7 @@ class Session {
1285
1904
  const own = this.statements.get(m.name);
1286
1905
  // the unnamed statement is replaced by a Parse; the sidecar's must be closed first
1287
1906
  const close = own?.catalogName !== null && own !== undefined ? [frame("C", Buffer.from("S"), cstr(own.catalogName))] : m.name === "" ? [frame("C", Buffer.from("S"), cstr(statement.catalogName))] : [];
1288
- const reply = await this.catalogRun(...close, frame("P", cstr(statement.catalogName), cstr(m.sql), int16(m.paramTypes.length), ...m.paramTypes.map(int32)));
1907
+ const reply = await this.catalogRun(...close, frame("P", cstr(statement.catalogName), cstr(this.spliced(m.sql, raw)), int16(m.paramTypes.length), ...m.paramTypes.map(int32)));
1289
1908
  this.statements.set(m.name, statement);
1290
1909
  // the CloseComplete of the close this server added is not the client's
1291
1910
  return reply.slice(close.length);
@@ -1298,6 +1917,8 @@ class Session {
1298
1917
  const statement = this.statements.get(m.statement);
1299
1918
  if (statement === undefined)
1300
1919
  throw new PgError({ code: "26000", message: `prepared statement "${m.statement}" does not exist` });
1920
+ // before anything runs: a catalog statement is run whole at its Bind
1921
+ this.heldToFailedBlock(statement.raw?.stmt);
1301
1922
  if (m.portal !== "" && this.portals.has(m.portal))
1302
1923
  throw new PgError({ code: "42P03", message: `cursor "${m.portal}" already exists` });
1303
1924
  if (statement.catalogName !== null) {
@@ -1314,7 +1935,7 @@ class Session {
1314
1935
  }
1315
1936
  this.dropPortal(m.portal);
1316
1937
  const rows = reply.slice(2, -2);
1317
- this.portals.set(m.portal, { statement, params: [], boundAt: this.server.now(), resultFormats: m.resultFormats, rows: null, sent: 0, fields: null,
1938
+ this.portals.set(m.portal, { statement, params: [], boundAt: this.server.now(), resultFormats: m.resultFormats, rows: null, sent: 0, fields: null, bytes: 0,
1318
1939
  catalog: { description, rows: statement.explainCatalog === undefined ? rows : catalogExplained(rows, statement.explainCatalog), sent: 0, done } });
1319
1940
  return [bound];
1320
1941
  }
@@ -1335,7 +1956,7 @@ class Session {
1335
1956
  const boundAt = this.server.now();
1336
1957
  this.txNow ??= boundAt;
1337
1958
  this.dropPortal(m.portal);
1338
- this.portals.set(m.portal, { statement, params, boundAt, resultFormats: m.resultFormats, rows: null, sent: 0, fields: null });
1959
+ this.portals.set(m.portal, { statement, params, boundAt, resultFormats: m.resultFormats, rows: null, sent: 0, fields: null, bytes: 0 });
1339
1960
  return [bindComplete()];
1340
1961
  }
1341
1962
  async describeMessage(m) {
@@ -1343,11 +1964,14 @@ class Session {
1343
1964
  const statement = this.statements.get(m.name);
1344
1965
  if (statement === undefined)
1345
1966
  throw new PgError({ code: "26000", message: `prepared statement "${m.name}" does not exist` });
1967
+ this.describedInFailedBlock(statement);
1346
1968
  if (statement.catalogName !== null)
1347
1969
  return this.catalogRun(frame("D", Buffer.from("S"), cstr(statement.catalogName)));
1348
1970
  const shape = statement.shape;
1349
1971
  if (shape?.kind === "show")
1350
1972
  return [parameterDescription([]), ...await this.showDescription(shape.name)];
1973
+ if (shape?.kind === "set_config")
1974
+ return [parameterDescription([]), this.setConfigDescription(shape, 0)];
1351
1975
  if (statement.explain !== undefined) {
1352
1976
  const e = statement.explain;
1353
1977
  return [parameterDescription(e.plan.params.map((o, i) => { const given = statement.paramOids[i] ?? 0; return given !== 0 ? given : o; })), rowDescription([explainField(e.options, 0)])];
@@ -1362,11 +1986,14 @@ class Session {
1362
1986
  const portal = this.portals.get(m.name);
1363
1987
  if (portal === undefined)
1364
1988
  throw new PgError({ code: "34000", message: `portal "${m.name}" does not exist` });
1989
+ this.describedInFailedBlock(portal.statement);
1365
1990
  if (portal.catalog !== undefined)
1366
1991
  return [portal.catalog.description];
1367
1992
  const shape = portal.statement.shape;
1368
1993
  if (shape?.kind === "show")
1369
1994
  return this.showDescription(shape.name);
1995
+ if (shape?.kind === "set_config")
1996
+ return [this.setConfigDescription(shape, formatAt(portal.resultFormats, 0))];
1370
1997
  if (portal.statement.explain !== undefined)
1371
1998
  return [rowDescription([explainField(portal.statement.explain.options, formatAt(portal.resultFormats, 0))])];
1372
1999
  const d = portal.statement.duck;
@@ -1378,6 +2005,11 @@ class Session {
1378
2005
  const portal = this.portals.get(m.portal);
1379
2006
  if (portal === undefined)
1380
2007
  throw new PgError({ code: "34000", message: `portal "${m.portal}" does not exist` });
2008
+ const statement = portal.statement;
2009
+ if (statement.raw?.stmt === undefined)
2010
+ return [emptyQueryResponse()];
2011
+ // a catalog portal's rows are held here since its Bind: a block that failed after it answers none of them
2012
+ this.heldToFailedBlock(statement.raw.stmt);
1381
2013
  if (portal.catalog !== undefined) {
1382
2014
  const c = portal.catalog;
1383
2015
  const take = m.maxRows > 0 ? Math.min(m.maxRows, c.rows.length - c.sent) : c.rows.length - c.sent;
@@ -1385,24 +2017,19 @@ class Session {
1385
2017
  c.sent += take;
1386
2018
  return c.sent < c.rows.length ? [...out, portalSuspended()] : [...out, c.done];
1387
2019
  }
1388
- const statement = portal.statement;
1389
- if (statement.raw?.stmt === undefined)
1390
- return [emptyQueryResponse()];
2020
+ // what its earlier Executes sent is this one's row's too (`bytesFor`)
2021
+ if (this.asked !== null)
2022
+ this.asked.carried = portal.bytes;
1391
2023
  const t = statement.shape ?? this.classify(statement.raw.stmt, statementText(statement.sql, statement.raw));
1392
- if (this.tx === "failed" && !(t.kind === "transaction" && t.tag !== "BEGIN" && t.tag !== "START TRANSACTION")) {
1393
- throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
1394
- }
1395
2024
  switch (t.kind) {
1396
2025
  case "empty": return [emptyQueryResponse()];
1397
2026
  case "catalog": throw new PgError({ code: "XX000", message: "a catalog statement bound as a query (Mason bug: report it)" }, 500);
1398
2027
  case "set": return this.setStatement(t);
2028
+ case "set_config": return this.setConfigStatement(t);
1399
2029
  // as Postgres: the extended protocol's Execute sends the rows and the tag, the RowDescription is the Describe's
1400
2030
  case "show": return (await this.showStatement(t.name)).filter((f) => f[0] !== ROW_DESCRIPTION);
1401
2031
  case "transaction": return this.transaction(t.tag);
1402
- case "discard":
1403
- this.statements.clear();
1404
- this.dropPortals();
1405
- return [commandComplete("DISCARD ALL")];
2032
+ case "discard": return this.discard(t.target);
1406
2033
  case "deallocate":
1407
2034
  this.statements.clear();
1408
2035
  return [commandComplete("DEALLOCATE ALL")];
@@ -1436,12 +2063,28 @@ class Session {
1436
2063
  const out = rows.slice(portal.sent, portal.sent + take);
1437
2064
  portal.sent += take;
1438
2065
  if (portal.sent < rows.length)
1439
- return [...out, portalSuspended()];
1440
- this.recordRequest({ rows: rows.length });
1441
- return [...out, commandComplete("EXPLAIN")];
2066
+ return this.pageSent(portal, [...out, portalSuspended()]);
2067
+ const done = [...out, commandComplete("EXPLAIN")];
2068
+ this.recordRequest({ rows: rows.length }, undefined, undefined, done);
2069
+ return done;
1442
2070
  }
1443
2071
  }
1444
2072
  }
2073
+ /**
2074
+ * THE BYTES SENT FOR THE STATEMENT BEING ASKED, `then` (the frames that end it, sent just after) among them: what its
2075
+ * portal's earlier Executes sent (`carried`), and what the session was written since this message arrived — exact,
2076
+ * since one message is handled at a time and a portal read aside meanwhile is read, never sent (`readAside`). Never
2077
+ * the session's bytes since a portal's first Execute: another portal's rows sent between two of its Executes were
2078
+ * counted in both rows.
2079
+ */
2080
+ bytesFor(then, asked = this.asked) {
2081
+ return (asked?.carried ?? 0) + this.sentBytes - (asked?.sentAt ?? this.sentBytes) + then.reduce((n, b) => n + b.length, 0);
2082
+ }
2083
+ /** a page of a portal, suspended: what it sends is kept on the portal (`Portal.bytes`) for its last Execute's row */
2084
+ pageSent(portal, frames) {
2085
+ portal.bytes = this.bytesFor(frames);
2086
+ return frames;
2087
+ }
1445
2088
  /** the statement being answered reads these JSON paths: its request log row says so, answered or failed */
1446
2089
  readsOf(plan) {
1447
2090
  if (this.asked !== null && plan.jsonReads !== undefined)
@@ -1454,7 +2097,17 @@ class Session {
1454
2097
  this.readsOf(statement.duck);
1455
2098
  if (portal.cursor === undefined) {
1456
2099
  if (statement.duck === undefined)
1457
- throw new PgError({ code: "XX000", message: "a DuckDB statement without its plan: report it (Mason's Postgres listener)" });
2100
+ throw new PgError({ code: "XX000", message: "a statement without its plan: report it (Mason's Postgres listener)" });
2101
+ // A ROLE'S ROW FILTERS MOVED SINCE THE PARSE (the roles file edited): planned again under them, as Postgres plans a
2102
+ // prepared statement again after its policies change — where its columns and parameters are what the Parse said
2103
+ if (statement.raw !== null && (statement.duck.rowsKey ?? "") !== rowsKeyOf(this.grants())) {
2104
+ const again = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.vars);
2105
+ const shape = (p) => JSON.stringify([p.params, p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null]);
2106
+ if (shape(again) === shape(statement.duck)) {
2107
+ statement.duck = again;
2108
+ delete statement.duckOnWire;
2109
+ }
2110
+ }
1458
2111
  // a row count on the wire bounds the rows: the plan made for it where it differs (boundOnWire)
1459
2112
  const plan = m.maxRows > 0 ? await this.boundOnWire(statement, statement.duck, controller) : statement.duck;
1460
2113
  const exec = this.txNow ??= this.server.now();
@@ -1476,23 +2129,25 @@ class Session {
1476
2129
  c.answer.close();
1477
2130
  }
1478
2131
  c.recorded = true;
1479
- this.recordRequest({ rows: c.sent, startedAt: c.startedAt });
1480
- return [commandComplete(`SELECT ${c.sent}`)];
2132
+ const done = [commandComplete(`SELECT ${c.sent}`)];
2133
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, undefined, undefined, done);
2134
+ return done;
1481
2135
  }
1482
2136
  // at most maxRows (JDBC's fetch size, a cursor): suspended while a row is left, read one chunk ahead to know it
1483
2137
  while (!c.done && c.pending.length <= m.maxRows)
1484
2138
  await this.fill(c);
1485
2139
  const out = c.pending.splice(0, m.maxRows);
1486
2140
  c.sent += out.length;
1487
- // suspended: its row is written at its last Execute, or when it is dropped (dropPortal)
2141
+ // suspended: its row is written at its last Execute, or when it is dropped (dropPortal), with what its pages sent
1488
2142
  if (c.pending.length > 0) {
1489
2143
  c.suspended = true;
1490
- return [...out, portalSuspended()];
2144
+ return this.pageSent(portal, [...out, portalSuspended()]);
1491
2145
  }
1492
2146
  c.answer.close();
1493
2147
  c.recorded = true;
1494
- this.recordRequest({ rows: c.sent, startedAt: c.startedAt });
1495
- return [...out, commandComplete(`SELECT ${c.sent}`)];
2148
+ const done = [...out, commandComplete(`SELECT ${c.sent}`)];
2149
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, undefined, undefined, done);
2150
+ return done;
1496
2151
  }
1497
2152
  catch (e) {
1498
2153
  // its refusal is its row (refuse)
@@ -1519,15 +2174,18 @@ class Session {
1519
2174
  * refuses this statement only (recorded by `refuse`).
1520
2175
  */
1521
2176
  async stillLive(asked) {
1522
- const recheck = this.server.options.recheck;
2177
+ const recheck = this.door.recheck;
1523
2178
  const user = this.token?.user;
1524
2179
  if (recheck === undefined || user === undefined)
1525
2180
  return null;
1526
2181
  const now = this.server.now();
1527
- if (now - this.verifiedAt < (this.server.options.recheckMs ?? DEFAULT_RECHECK_MS))
2182
+ if (now - this.verifiedAt < this.door.recheckMs)
1528
2183
  return null;
1529
2184
  try {
1530
- await recheck(this.token);
2185
+ // the user as the file has it NOW: its role given or taken since applies to this statement
2186
+ const fresh = await recheck(this.token);
2187
+ if (fresh !== undefined && this.token !== null)
2188
+ this.token = fresh;
1531
2189
  }
1532
2190
  catch (err) {
1533
2191
  const message = err instanceof Error ? err.message : String(err);
@@ -1536,7 +2194,9 @@ class Session {
1536
2194
  return new PgError({ code: "57P03", message, hint: "run the statement again once Mason can read its list of users" }, 503);
1537
2195
  if (err instanceof ApiError)
1538
2196
  this.recordRefusal(asked.toUpperCase(), err);
1539
- this.fatal("28000", `the session's user is no longer accepted: ${message}`, `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
2197
+ this.fatal("28000", `the session's user is no longer accepted: ${message}`, this.door.public ? this.door.loginHint : err instanceof ApiError && err.code === USER_DISABLED
2198
+ ? `a disabled user's sessions end at their next statement; mason user enable ${user} lets it connect again, with the same password`
2199
+ : `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
1540
2200
  return null;
1541
2201
  }
1542
2202
  this.verifiedAt = now;
@@ -1559,6 +2219,84 @@ const formatAt = (formats, i) => (formats.length === 0 ? 0 : formats.length ===
1559
2219
  const isUtf8 = (encoding) => /^(utf-?8|unicode)$/i.test(encoding.trim());
1560
2220
  const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
1561
2221
  const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
2222
+ /** the SQLValueFunction ops that name a user, each with its keyword (the text at its location, and its column's name) */
2223
+ const USER_OPS = { SVFOP_SESSION_USER: "session_user", SVFOP_CURRENT_USER: "current_user", SVFOP_CURRENT_ROLE: "current_role", SVFOP_USER: "user" };
2224
+ /** every session: `session_user` alone (`current_user` is the catalog's role: the session's role under SET ROLE) */
2225
+ const SESSION_OPS = new Set(["SVFOP_SESSION_USER"]);
2226
+ /** a user the roles file holds to nothing: its catalog role is `NO_ROLE_CATALOG`, never a name it should answer */
2227
+ const HELD_TO_NOTHING_OPS = new Set(Object.keys(USER_OPS));
2228
+ /** `userSpliced`'s mark for the `pg_catalog.` of a qualified `current_setting`, never a keyword's text */
2229
+ const SETTING = "\u0000setting";
2230
+ /**
2231
+ * WHAT A STATEMENT THE CATALOG ANSWERS READS OF ITS SESSION, spliced in its text at the parse tree's locations:
2232
+ * - `session_user` IS THIS SESSION'S USER, never the catalog's superuser (it answered `postgres` for every session while
2233
+ * `SHOW session_authorization` said the user's name: a review finding of #35). The keyword cannot be overridden as
2234
+ * `version()` and `current_setting` are, so each one of `ops` is spliced with a scalar subquery named as the keyword
2235
+ * names its column, `(SELECT 'agent'::name AS session_user)`: the name, the type and every expression around it stay
2236
+ * Postgres's. `current_user`, `current_role` and `user` are the catalog's role (the session's role under SET ROLE,
2237
+ * src/api/postgres/catalog.ts asRole), spliced only for a user the roles file holds to nothing
2238
+ * (`HELD_TO_NOTHING_OPS`), whose catalog role is `NO_ROLE_CATALOG`: Postgres answers its own name for all three to a
2239
+ * login with no grant.
2240
+ * - `pg_catalog.current_setting(…)`, so written, is the session's override (`public.current_setting`, catalog.ts
2241
+ * currentSettingSql): Postgres's own is called by its OID's C function whatever its catalog row says, so it cannot be
2242
+ * replaced, and so qualified it answered the catalog's values after a SET (GitHub issue 40).
2243
+ * `base`: the statement's offset in the text the parse tree's locations count from.
2244
+ */
2245
+ function userSpliced(text, stmt, base, user, ops) {
2246
+ // a user keyword by its location, or `setting`: the `pg_catalog.` of a qualified current_setting
2247
+ const at = new Map();
2248
+ const walk = (n) => {
2249
+ if (Array.isArray(n)) {
2250
+ for (const x of n)
2251
+ walk(x);
2252
+ return;
2253
+ }
2254
+ if (typeof n !== "object" || n === null)
2255
+ return;
2256
+ const f = n.SQLValueFunction;
2257
+ if (f !== undefined && f.op !== undefined && ops.has(f.op) && typeof f.location === "number")
2258
+ at.set(f.location, USER_OPS[f.op]);
2259
+ const c = n.FuncCall;
2260
+ const name = c?.funcname?.map((x) => x.String?.sval ?? "");
2261
+ if (c !== undefined && typeof c.location === "number" && name?.length === 2 && name[0] === "pg_catalog" && name[1] === "current_setting")
2262
+ at.set(c.location, SETTING);
2263
+ for (const v of Object.values(n))
2264
+ walk(v);
2265
+ };
2266
+ walk(stmt);
2267
+ if (at.size === 0)
2268
+ return text;
2269
+ const bytes = Buffer.from(text, "utf8");
2270
+ const parts = [];
2271
+ let end = bytes.length;
2272
+ for (const loc of [...at.keys()].sort((a, b) => b - a)) {
2273
+ const keyword = at.get(loc);
2274
+ const from = loc - base;
2275
+ if (from < 0)
2276
+ continue;
2277
+ let written;
2278
+ if (keyword === SETTING) {
2279
+ // the qualifier as written (`pg_catalog.`, `"pg_catalog" .`): what is spliced is its span, never a guess at it
2280
+ const q = /^(?:pg_catalog|"pg_catalog")\s*\.\s*/i.exec(bytes.subarray(from, from + 64).toString("utf8"));
2281
+ if (q === null)
2282
+ continue;
2283
+ written = q[0];
2284
+ }
2285
+ else {
2286
+ if (bytes.subarray(from, from + keyword.length).toString("utf8").toLowerCase() !== keyword)
2287
+ continue;
2288
+ // the keyword must end there (`user` is the start of `user_name`): the parser placed it, so a word character after is not it
2289
+ if (/^[A-Za-z0-9_$]/.test(bytes.subarray(from + keyword.length, from + keyword.length + 1).toString("utf8")))
2290
+ continue;
2291
+ written = keyword;
2292
+ }
2293
+ parts.unshift(bytes.subarray(from + Buffer.byteLength(written), end));
2294
+ parts.unshift(Buffer.from(keyword === SETTING ? "public." : `(SELECT ${quoteLiteral(user)}::name AS ${keyword})`, "utf8"));
2295
+ end = from;
2296
+ }
2297
+ parts.unshift(bytes.subarray(0, end));
2298
+ return Buffer.concat(parts).toString("utf8");
2299
+ }
1562
2300
  /** the text of one statement of a simple query, from the parser's byte offsets */
1563
2301
  function statementText(sql, raw) {
1564
2302
  const bytes = Buffer.from(sql, "utf8");
@@ -1636,11 +2374,14 @@ function relayedError(frame) {
1636
2374
  // the listener
1637
2375
  class Server {
1638
2376
  sessions = new Map();
2377
+ /** the catalog Postgres's own settings, by name (`catalogSettings`), read once */
2378
+ baseSettings = null;
1639
2379
  /** the DuckDB plans by text, search_path, TimeZone, declared parameter types and an Execute's bound (Session.boundOnWire), for
1640
2380
  * the schema they were made under */
1641
2381
  duckPlans = new Map();
1642
2382
  duckPlansOf = null;
1643
2383
  duckPlansAt = 0;
2384
+ duckPlansRoles = null;
1644
2385
  /** the source's OIDs for the types an extension made, by name (a copied citext column's), from the schema */
1645
2386
  userTypes() {
1646
2387
  const out = new Map();
@@ -1676,22 +2417,25 @@ class Server {
1676
2417
  * analysis starts the instance with the new DDL. A plan whose schema or generation moved while it was made is
1677
2418
  * returned to its caller and not kept.
1678
2419
  */
1679
- async duckPlan(text, paramOids, searchPath, timeZone, wireBound, make) {
2420
+ async duckPlan(text, paramOids, searchPath, timeZone, wireBound, role, make) {
1680
2421
  const schema = this.schema();
2422
+ const roles = this.rolesNow();
1681
2423
  const generation = this.options.duck?.generation?.() ?? 0;
1682
- if (schema !== this.duckPlansOf || generation !== this.duckPlansAt) {
2424
+ if (schema !== this.duckPlansOf || generation !== this.duckPlansAt || roles !== this.duckPlansRoles) {
1683
2425
  this.duckPlans.clear();
1684
2426
  this.duckPlansOf = schema;
1685
2427
  this.duckPlansAt = generation;
2428
+ this.duckPlansRoles = roles;
1686
2429
  }
1687
2430
  // the zone too: a plan holds the values Postgres parsed its literals to, under the session's zone
1688
2431
  // and the bound: an Execute's row count plans a LEFT lateral of a list otherwise (Session.boundOnWire), kept beside
1689
- const key = `${searchPath}\u0000${timeZone}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${text}`;
2432
+ // and the role: a plan is held to it as it is made (src/duck/grants.ts), so a role's plans are its own
2433
+ const key = `${searchPath}\u0000${timeZone}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${role}\u0000${text}`;
1690
2434
  const hit = this.duckPlans.get(key);
1691
2435
  if (hit !== undefined)
1692
2436
  return hit;
1693
2437
  const plan = await make();
1694
- if (this.duckPlansOf !== schema || this.duckPlansAt !== generation)
2438
+ if (this.duckPlansOf !== schema || this.duckPlansAt !== generation || this.duckPlansRoles !== roles)
1695
2439
  return plan;
1696
2440
  // bounded: a tool that inlines its values sends a new text per value
1697
2441
  if (this.duckPlans.size >= 2000)
@@ -1699,22 +2443,27 @@ class Server {
1699
2443
  this.duckPlans.set(key, plan);
1700
2444
  return plan;
1701
2445
  }
1702
- tls;
2446
+ /** the run's own listener's door */
2447
+ door;
1703
2448
  catalog;
1704
2449
  lastSchema;
2450
+ lastRoles;
1705
2451
  options;
1706
2452
  constructor(options) {
1707
2453
  this.options = options;
1708
- this.tls = options.tls === null ? null : tls.createSecureContext({ cert: options.tls.cert, key: options.tls.key });
2454
+ this.door = { tls: options.tls === null ? null : tls.createSecureContext({ cert: options.tls.cert, key: options.tls.key }), authenticate: options.authenticate,
2455
+ recheck: options.recheck, recheckMs: options.recheckMs ?? DEFAULT_RECHECK_MS, who: "a mason user", loginHint: USER_LOGIN_HINT, public: false, logins: null };
1709
2456
  this.lastSchema = options.schema();
2457
+ this.lastRoles = options.roles?.() ?? NO_ROLES;
1710
2458
  const version = versionText(options.product ?? "Mason", options.masonVersion);
1711
2459
  this.catalog = new PgCatalog(this.lastSchema, {
2460
+ roles: catalogRolesOf(this.lastRoles),
1712
2461
  log: (line) => this.log(line),
1713
2462
  setup: [
1714
2463
  // `version()` says whose Postgres this is; search_path lists pg_catalog AFTER public so the override wins
1715
2464
  `CREATE OR REPLACE FUNCTION public.version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '${version.replaceAll("'", "''")}' $$`,
1716
- // `current_setting('ssl')` says what every session of this listener is (tlsParameters), as SHOW does
1717
- ...currentSettingSql(tlsParameters(options.tls !== null)),
2465
+ // `current_setting(name)` answers the session's settings, carried in by each of its requests (catalog.ts asSession)
2466
+ ...currentSettingSql(),
1718
2467
  "SET search_path = public, pg_catalog",
1719
2468
  ...databaseNameSql(options.database),
1720
2469
  ],
@@ -1723,47 +2472,264 @@ class Server {
1723
2472
  });
1724
2473
  }
1725
2474
  get database() { return this.options.database; }
2475
+ /**
2476
+ * THE CATALOG POSTGRES'S OWN SETTINGS, every name its `current_setting` knows with the value it answers (`block_size`,
2477
+ * `server_version_num` …), read once (they are the catalog's build's and its setup's, the same at every start): what a
2478
+ * session's `current_setting` answers beside a copied table for a name the session does not hold (`duckSession`).
2479
+ */
2480
+ catalogSettings() {
2481
+ this.baseSettings ??= (async () => {
2482
+ const reply = decodeBackend(Buffer.from(await this.catalog.run(frame("Q", cstr("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings")))));
2483
+ const err = reply.find((m) => m.type === "ErrorResponse");
2484
+ if (err?.type === "ErrorResponse")
2485
+ throw new PgError({ code: "XX000", message: `the catalog Postgres's settings could not be read: ${err.fields.M} (Mason bug: report it)` }, 500);
2486
+ return new Map(reply.flatMap((m) => m.type === "DataRow" ? [[m.values[0].toString("utf8").toLowerCase(), m.values[1]?.toString("utf8") ?? ""]] : []));
2487
+ })();
2488
+ // a read that failed is read again at the next statement, never kept
2489
+ this.baseSettings.catch(() => { this.baseSettings = null; });
2490
+ return this.baseSettings;
2491
+ }
1726
2492
  /** the clock "now" is read from */
1727
2493
  now() { return (this.options.clock ?? Date.now)(); }
1728
2494
  log(line) { (this.options.log ?? ((l) => console.log(l)))(`[postgres] ${line}`); }
1729
- /** the schema as of now; the catalog Postgres is replaced when it changed */
2495
+ /** the schema as of now; the catalog Postgres is replaced when it changed (or the roles did: `rolesNow`) */
1730
2496
  schema() {
1731
2497
  const s = this.options.schema();
1732
- if (s !== this.lastSchema) {
2498
+ const r = this.options.roles?.() ?? NO_ROLES;
2499
+ if (s !== this.lastSchema || r !== this.lastRoles) {
1733
2500
  this.lastSchema = s;
1734
- void this.catalog.replace(s).catch((e) => this.log(`replacing the catalog failed: ${e instanceof Error ? e.message : String(e)}`));
2501
+ this.lastRoles = r;
2502
+ void this.catalog.replace(s, catalogRolesOf(r)).catch((e) => this.log(`replacing the catalog failed: ${e instanceof Error ? e.message : String(e)}`));
1735
2503
  }
1736
2504
  return s;
1737
2505
  }
2506
+ /** the roles as of now (src/duck/grants.ts RolesLive); the catalog is replaced when they changed, as for the schema */
2507
+ rolesNow() {
2508
+ this.schema();
2509
+ return this.lastRoles;
2510
+ }
2511
+ /**
2512
+ * THE ROLES WITH THEIR ROW FILTERS CHECKED BY THE CATALOG POSTGRES (src/duck/row-filter.ts filterTypes): each `where:`
2513
+ * of a copied table analysed as `SELECT 1 FROM <table> WHERE <it>`, the type of each attribute it reads taken from
2514
+ * Postgres's inference (the ONE derivation: every binding and the catalog's policies read it), and a filter Postgres
2515
+ * refuses made the roles' `problem` — said once in the log, at its key, and every statement of a user held to a role
2516
+ * refused until it is fixed (fail closed, as for any mistake of the file). The catalog's row security policies are
2517
+ * written from the same types (catalog.ts catalogPolicies). Once per roles object and copied tables; nothing where no
2518
+ * role filters rows. A check that could not run (the catalog failed) is said as the problem and tried again next time.
2519
+ */
2520
+ async checkRoles() {
2521
+ const raw = this.rolesNow();
2522
+ const schema = this.lastSchema;
2523
+ if (!filtersRows(raw))
2524
+ return raw;
2525
+ if (this.checked?.of === raw && this.checked.schema === schema)
2526
+ return this.checked.live;
2527
+ if (this.checking?.of === raw && this.checking.schema === schema)
2528
+ return this.checking.done;
2529
+ const done = (async () => {
2530
+ const copied = new Set(schema.tables.map((t) => `${t.schema ?? "public"}.${t.name}`));
2531
+ const problems = [];
2532
+ const typed = new Map();
2533
+ try {
2534
+ for (const g of raw.grants.values()) {
2535
+ if (g.filters === undefined) {
2536
+ typed.set(g.name, g);
2537
+ continue;
2538
+ }
2539
+ const types = new Map();
2540
+ for (const f of g.filters.values()) {
2541
+ if (f.where === undefined || !copied.has(f.table))
2542
+ continue;
2543
+ const r = await filterTypes((b) => this.catalog.run(b), f, g.attributes ?? new Map());
2544
+ if ("problem" in r) {
2545
+ problems.push(r.problem);
2546
+ continue;
2547
+ }
2548
+ types.set(f.table, r.types);
2549
+ // Postgres read it; the rewrite must too, or each of the role's users would meet the refusal, statement by statement
2550
+ const probe = filterProbe(f, r.types, g.attributes ?? new Map());
2551
+ const stmt = (await parseSql(probe)).stmts?.[0]?.stmt;
2552
+ if (stmt === undefined)
2553
+ throw new Error(`a filter's read does not parse: ${probe}`);
2554
+ try {
2555
+ await this.options.duck.plan((b) => this.catalog.run(b), probe, stmt, 0, [], "public", "UTC", this.userTypes(), false, undefined);
2556
+ }
2557
+ catch (e) {
2558
+ if (!(e instanceof StatementError))
2559
+ throw e;
2560
+ problems.push(`${f.at}.where: ${e.fields.message}${e.fields.hint !== undefined ? ` (${e.fields.hint})` : ""} — the predicate is read as SELECT 1 FROM ${f.table} WHERE ${f.where.text}`);
2561
+ }
2562
+ }
2563
+ typed.set(g.name, { ...g, filterTypes: types });
2564
+ }
2565
+ await this.catalog.policies(catalogPolicies(schema, catalogRolesOf({ ...raw, grants: typed })));
2566
+ }
2567
+ catch (e) {
2568
+ const problem = `the roles' row filters could not be checked by the catalog: ${e instanceof Error ? e.message : String(e)}`;
2569
+ // tried again at every statement of every session, so said once a streak: the line a streak's end answers
2570
+ if (!this.rolesCheckFailing) {
2571
+ this.rolesCheckFailing = true;
2572
+ this.log(`roles: ${problem} — tried again at each statement, every statement of a user held to a role refused until then`);
2573
+ }
2574
+ return raw.problem !== null ? raw : { ...raw, problem, checkFailed: true };
2575
+ }
2576
+ if (this.rolesCheckFailing) {
2577
+ this.rolesCheckFailing = false;
2578
+ this.log("roles: the catalog checks the row filters again");
2579
+ }
2580
+ const problem = raw.problem ?? (problems.length === 0 ? null : `${raw.file ?? "the roles file"}: ${problems.join("; ")}`);
2581
+ if (problem !== null && raw.problem === null && problem !== this.saidProblem) {
2582
+ this.saidProblem = problem;
2583
+ this.log(`roles: ${problem} — every statement of a user held to a role is refused until it is fixed`);
2584
+ }
2585
+ const live = { ...raw, grants: typed, problem };
2586
+ if (this.lastRoles === raw && this.lastSchema === schema)
2587
+ this.checked = { of: raw, schema, live };
2588
+ return live;
2589
+ })();
2590
+ this.checking = { of: raw, schema, done };
2591
+ try {
2592
+ return await done;
2593
+ }
2594
+ finally {
2595
+ if (this.checking?.done === done)
2596
+ this.checking = null;
2597
+ }
2598
+ }
2599
+ checked = null;
2600
+ checking = null;
2601
+ saidProblem = null;
2602
+ rolesCheckFailing = false;
2603
+ /** the roles now, their row filters checked where the check for them is done (`checkRoles`), else as read: a
2604
+ * session's `grants` refuses a filter not checked yet */
2605
+ rolesChecked() {
2606
+ const raw = this.rolesNow();
2607
+ if (!filtersRows(raw))
2608
+ return raw;
2609
+ return this.checked?.of === raw && this.checked.schema === this.lastSchema ? this.checked.live : raw;
2610
+ }
1738
2611
  forget(s) { this.sessions.delete(s.id); }
1739
- cancel(pid, secret) {
2612
+ /** A CancelRequest: the session's statement stopped when the key is the session's (`interrupt`) and it arrived on
2613
+ * the session's own socket (a driver's cancel opens a connection to the address it logged in at; a public socket
2614
+ * reaches its own sessions alone). */
2615
+ cancel(pid, secret, door) {
1740
2616
  const s = this.sessions.get(pid);
1741
- if (s === undefined || s.secret !== secret)
2617
+ if (s === undefined || s.secret !== secret || s.door !== door)
1742
2618
  return; // as Postgres: a wrong key is ignored, never answered
2619
+ this.interrupt(s);
2620
+ }
2621
+ /**
2622
+ * THE ONE WAY A SESSION'S STATEMENT IS CANCELLED, for a CancelRequest and `mason cancel` alike: its controller aborted
2623
+ * with no reason, so DuckDB is interrupted (or the wait for the client woken) and the client is told 57014 "canceling
2624
+ * statement due to user request", recorded as cancelled. Answers what it stopped, or null when nothing ran.
2625
+ */
2626
+ interrupt(s) {
2627
+ const running = s.runningNow(Date.now());
1743
2628
  s.running?.abort();
2629
+ return running;
2630
+ }
2631
+ /** `mason cancel <session>` (`POST /api/cancel`): the session's statement stopped as a CancelRequest stops it — with
2632
+ * `statement`, only while it is the one the session runs (a listing's statement may have ended, and the session
2633
+ * begun another, by the time the cancel arrives) */
2634
+ cancelSession(id, statement) {
2635
+ const s = this.sessions.get(id);
2636
+ if (s === undefined)
2637
+ return { unknown: id };
2638
+ if (statement !== undefined) {
2639
+ const now = s.runningNow(Date.now());
2640
+ if (now !== null && now.statement !== statement)
2641
+ return { other: now, asked: statement };
2642
+ }
2643
+ const stopped = this.interrupt(s);
2644
+ if (stopped === null)
2645
+ return { idle: id };
2646
+ this.log(`session ${id}: its statement cancelled by mason cancel`);
2647
+ return { cancelled: stopped };
2648
+ }
2649
+ /** The sessions logged in now, by user and client, the most open first (`SessionsOpen`). */
2650
+ openSessions() {
2651
+ const now = Date.now();
2652
+ const by = new Map();
2653
+ let open = 0;
2654
+ for (const s of this.sessions.values()) {
2655
+ if (s.phase !== "ready")
2656
+ continue;
2657
+ open++;
2658
+ const user = s.token?.user ?? "", client = s.vars.applicationName;
2659
+ const k = JSON.stringify([user, client]);
2660
+ const g = by.get(k) ?? { user, client, open: 0, running: 0 };
2661
+ g.open++;
2662
+ if (s.runningNow(now) !== null)
2663
+ g.running++;
2664
+ by.set(k, g);
2665
+ }
2666
+ return { open, byClient: [...by.values()].sort((a, b) => b.open - a.open || (a.user < b.user ? -1 : a.user > b.user ? 1 : a.client < b.client ? -1 : a.client > b.client ? 1 : 0)) };
2667
+ }
2668
+ /** The statements running now, oldest first. */
2669
+ running() {
2670
+ const now = Date.now();
2671
+ return [...this.sessions.values()].map((s) => s.runningNow(now)).filter((r) => r !== null)
2672
+ .sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.session - b.session);
1744
2673
  }
1745
2674
  }
1746
- export async function startPostgresApi(options) {
1747
- const server = new Server(options);
2675
+ /** A socket that takes connections through `door`, listening; its port. Two of these are one server's two sockets. */
2676
+ async function listenOn(server, door, port, host) {
1748
2677
  const listener = net.createServer((socket) => {
1749
2678
  socket.setNoDelay(true);
1750
- const session = new Session(socket, server);
2679
+ const session = new Session(socket, server, door);
1751
2680
  server.sessions.set(session.id, session);
1752
2681
  });
1753
2682
  await new Promise((resolve, reject) => {
1754
2683
  listener.once("error", reject);
1755
- listener.listen(options.port, options.host, () => { listener.off("error", reject); resolve(); });
2684
+ listener.listen(port, host, () => { listener.off("error", reject); resolve(); });
1756
2685
  });
1757
2686
  listener.on("error", (err) => server.log(`listener error: ${err.message}`));
1758
2687
  const address = listener.address();
1759
- const port = typeof address === "object" && address !== null ? address.port : options.port;
2688
+ return { listener, port: typeof address === "object" && address !== null ? address.port : port };
2689
+ }
2690
+ const closeListener = (listener) => new Promise((resolve) => listener.close(() => resolve()));
2691
+ export async function startPostgresApi(options) {
2692
+ const server = new Server(options);
2693
+ const { listener, port } = await listenOn(server, server.door, options.port, options.host);
2694
+ const shares = new Set();
2695
+ const sessionsOf = (door) => [...server.sessions.values()].filter((s) => s.door === door);
1760
2696
  return {
1761
2697
  port,
2698
+ async openShare(o) {
2699
+ const door = { tls: tls.createSecureContext({ cert: o.tls.cert, key: o.tls.key }), authenticate: o.authenticate, recheck: o.recheck, recheckMs: 0, who: "a share",
2700
+ loginHint: o.loginHint, public: true, logins: new LoginQueue(o.pendingLimit ?? DEFAULT_PENDING_LIMIT, o.pendingMs ?? DEFAULT_PENDING_MS, o.dropped), refused: o.refused };
2701
+ // loopback whatever the run's own listener is bound to: a tunnel's client is on this machine
2702
+ const share = await listenOn(server, door, 0, "127.0.0.1");
2703
+ shares.add(share.listener);
2704
+ return {
2705
+ port: share.port,
2706
+ get sessions() { return sessionsOf(door).filter((s) => s.phase === "ready").length; },
2707
+ endSessions: (jti, why) => {
2708
+ const ended = sessionsOf(door).filter((s) => s.token?.jti === jti);
2709
+ for (const s of ended)
2710
+ s.end(why);
2711
+ return ended.length;
2712
+ },
2713
+ async close() {
2714
+ if (!shares.delete(share.listener))
2715
+ return;
2716
+ for (const s of sessionsOf(door))
2717
+ s.end("the share ended");
2718
+ await closeListener(share.listener);
2719
+ },
2720
+ };
2721
+ },
1762
2722
  get sessions() { return server.sessions.size; },
2723
+ roles: () => server.rolesChecked(),
2724
+ checkRoles: () => server.checkRoles(),
2725
+ openSessions: () => server.openSessions(),
2726
+ running: () => server.running(),
2727
+ cancel: (session, statement) => server.cancelSession(session, statement),
1763
2728
  async close() {
1764
2729
  for (const s of [...server.sessions.values()])
1765
2730
  s.end("server closing");
1766
- await new Promise((resolve) => listener.close(() => resolve()));
2731
+ await Promise.all([listener, ...shares].map(closeListener));
2732
+ shares.clear();
1767
2733
  await server.catalog.stop();
1768
2734
  await stopParser();
1769
2735
  },