@mason-data/cli 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -1,19 +1,24 @@
1
+ import { copyFacts } from "../../duck/explain.js";
1
2
  import { explainedText, explainOptions } from "./explain.js";
2
3
  import * as crypto from "node:crypto";
3
4
  import * as net from "node:net";
4
5
  import * as tls from "node:tls";
5
- import { ApiError } from "../error.js";
6
+ import { ApiError, USER_DISABLED } from "../error.js";
6
7
  import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
8
+ import { gapMark } from "../../duck/gaps.js";
7
9
  import { canonicalZone } from "../../lib/timezone.js";
8
- 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";
9
14
  import { EncodeError, OID, fieldOf } from "./encode.js";
10
15
  import { numericFromBinary, NumericError } from "../../lib/pg-numeric.js";
11
16
  import { parseSql, PostgresSyntaxError, stopParser } from "./parser.js";
12
- 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";
13
18
  import { typeNameParts } from "./schema.js";
14
- import { Unsupported, duckTranslate } from "./translate.js";
19
+ import { Unsupported, duckTranslate, endsFailedBlock } from "./translate.js";
15
20
  /** what an EXPLAIN of a catalog statement says first: whose plan it is */
16
- 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";
17
22
  /** an EXPLAIN's one column, Postgres's: text, or json for FORMAT JSON (ExplainResultDesc) */
18
23
  const explainField = (o, format) => ({ name: "QUERY PLAN", typeOid: o.format === "json" ? 114 : 25, typeSize: -1, typeModifier: -1, format });
19
24
  /** a DataRow's values (null for NULL) */
@@ -42,7 +47,7 @@ function catalogExplained(frames, o) {
42
47
  return frames;
43
48
  const doc = JSON.parse(value.toString("utf8"));
44
49
  if (doc[0] !== undefined)
45
- 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" };
46
51
  return [...frames.slice(0, first), dataRow([Buffer.from(JSON.stringify(doc, null, 2))]), ...frames.slice(first + 1)];
47
52
  }
48
53
  /** Postgres's names over DuckDB's: each named column renamed, a `*` (null) spanning what DuckDB expanded it to */
@@ -65,6 +70,15 @@ export function postgresNames(columns, names) {
65
70
  }
66
71
  return at === columns.length ? out : [...columns];
67
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
+ }
68
82
  /** a statement_timeout's value in ms, Postgres's units (a bare number is ms; `1.5s`, `5min`), 0 for none; null where
69
83
  * it is no duration */
70
84
  export function timeoutMs(value) {
@@ -79,7 +93,18 @@ export function timeoutMs(value) {
79
93
  export class StatementError extends Error {
80
94
  fields;
81
95
  sql;
82
- 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);
83
108
  }
84
109
  export const SERVER_VERSION = "18.6";
85
110
  export const SERVER_VERSION_NUM = "180006";
@@ -95,10 +120,27 @@ export function versionText(product, version) {
95
120
  * keeps one for hours — ends at its first statement after this long.
96
121
  */
97
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
+ }
98
139
  // ---------------------------------------------------------------------------------------------------------------------
99
140
  // bytes
100
141
  /** the type byte of a RowDescription frame */
101
142
  const ROW_DESCRIPTION = 0x54;
143
+ const DATA_ROW = 0x44;
102
144
  /** the first byte of a TLS handshake record (a ClientHello): never a startup packet's, whose length's high byte is 0
103
145
  * under MAX_STARTUP_LENGTH, and never SSLRequest's (length 8) */
104
146
  const TLS_HANDSHAKE = 0x16;
@@ -111,10 +153,13 @@ class PgError extends Error {
111
153
  fields;
112
154
  /** the HTTP-like status the request log records (400 for a refusal, 401 a refused login, 500 Mason's failure) */
113
155
  status;
114
- 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) {
115
159
  super(fields.message);
116
160
  this.fields = { severity: "ERROR", ...fields };
117
161
  this.status = status;
162
+ this.gap = gap;
118
163
  }
119
164
  }
120
165
  /** The SQLSTATE an ApiError's status is told as (a refused login's, src/duck/users.ts). */
@@ -133,12 +178,14 @@ function sqlstateOf(status, code) {
133
178
  }
134
179
  }
135
180
  /**
136
- * A refused or failed statement as its log line (`Session.refuse`): `failed` where the fault is Mason's or the
137
- * machine's (SQLSTATE classes XX internal, 53 resources, 57 a timeout or cancel, 58 system), `refused` for the
138
- * 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.
139
185
  */
140
186
  export function refusalLine(session, f, sql) {
141
- const word = outcomeOf(f.code) === "failed" ? "failed" : "refused";
187
+ const o = outcomeOf(f.code);
188
+ const word = o === "ok" ? "refused" : o;
142
189
  const statement = sql.replace(/\s+/g, " ").trim();
143
190
  return `session ${session} ${word}: ${f.code} ${f.message}${f.detail ? `; detail: ${f.detail}` : ""}${f.hint ? `; hint: ${f.hint}` : ""}` +
144
191
  ` — ${statement === "" ? "(no statement)" : statement}`;
@@ -147,9 +194,9 @@ function asPgError(err, sql) {
147
194
  if (err instanceof PgError)
148
195
  return err;
149
196
  if (err instanceof StatementError)
150
- 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);
151
198
  if (err instanceof Unsupported) {
152
- 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);
153
200
  }
154
201
  if (err instanceof PostgresSyntaxError)
155
202
  return new PgError({ code: "42601", message: err.message, position: positionOf(sql, err.offset) });
@@ -157,11 +204,11 @@ function asPgError(err, sql) {
157
204
  return new PgError({ code: sqlstateOf(err.status, err.code), message: err.message, detail: `Mason: ${err.code}` }, err.status);
158
205
  // Postgres's own error for a numeric (a division by zero, 22012; a field overflow, 22003), raised as Postgres raises it
159
206
  if (err instanceof NumericError)
160
- 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);
161
208
  if (err instanceof EncodeError)
162
209
  return err.code === "XX000"
163
210
  ? new PgError({ code: "XX000", message: err.message, detail: "Mason: a value the column's Postgres type cannot carry; report it" }, 500)
164
- : new PgError({ code: err.code, message: err.message }, 400);
211
+ : new PgError({ code: err.code, message: err.message }, 400, err.gap);
165
212
  if (err instanceof ProtocolError)
166
213
  return new PgError({ code: "08P01", message: err.message }, 500);
167
214
  const message = err instanceof Error ? err.message : String(err);
@@ -175,32 +222,48 @@ const REPORTED = new Set(["application_name", "client_encoding", "datestyle", "i
175
222
  const SPELLING = { datestyle: "DateStyle", intervalstyle: "IntervalStyle", timezone: "TimeZone" };
176
223
  const spell = (name) => SPELLING[name] ?? name;
177
224
  const defaultVars = () => ({ timeZone: "UTC", applicationName: "", searchPath: '"$user", public', other: new Map() });
178
- /** What `SHOW` / `current_setting` answer for the parameters this server fixes. */
179
- 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) {
180
228
  switch (name) {
181
- case "server_version": return SERVER_VERSION;
182
- case "server_version_num": return SERVER_VERSION_NUM;
183
- case "server_encoding":
184
- case "client_encoding": return "UTF8";
185
- case "datestyle": return "ISO, MDY";
186
- case "intervalstyle": return "postgres";
187
- case "timezone": return vars.timeZone;
188
- case "application_name": return vars.applicationName;
189
- case "search_path": return vars.searchPath;
190
- case "integer_datetimes":
191
- case "standard_conforming_strings":
192
- case "default_transaction_read_only":
193
- case "transaction_read_only":
194
- case "in_hot_standby": return "on";
195
- case "is_superuser": return "off";
196
- case "session_authorization":
197
- case "role": return user;
198
- case "transaction_isolation":
199
- case "default_transaction_isolation": return "read committed";
200
- case "extra_float_digits": return "1";
201
- case "max_identifier_length": return "63";
202
- 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;
203
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;
204
267
  }
205
268
  /**
206
269
  * WHAT `SHOW` / `current_setting` SAY ABOUT TLS (`SHOW ssl` once said `off` on a TLS session, the catalog
@@ -218,18 +281,44 @@ export function tlsParameters(on) {
218
281
  ssl_ciphers: tls.DEFAULT_CIPHERS, ssl_groups: tls.DEFAULT_ECDH_CURVE, ssl_prefer_server_ciphers: "off",
219
282
  };
220
283
  }
221
- /** The catalog's `current_setting(name[, missing_ok])`, answering the TLS parameters first (`tlsParameters`) and the rest
222
- * as Postgres's own: the `version()` override's way (search_path lists pg_catalog after public), so a statement reading
223
- * no table and calling it is forwarded as ever, and one table answers SHOW and current_setting alike. */
224
- export function currentSettingSql(params) {
225
- const cases = Object.entries(params).map(([name, value]) => `WHEN ${quoteLiteral(name)} THEN ${quoteLiteral(value)}`).join(" ");
226
- return [
227
- `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 $$`,
228
- `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 $$`,
229
- ];
230
- }
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" });
231
286
  // ---------------------------------------------------------------------------------------------------------------------
232
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
+ }
233
322
  let nextSessionId = 1;
234
323
  class Session {
235
324
  id = nextSessionId++;
@@ -240,10 +329,25 @@ class Session {
240
329
  user = "";
241
330
  token = null;
242
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;
243
334
  /** when the user was last found live (a user's session is asked again, `recheckMs`) */
244
335
  verifiedAt = 0;
245
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();
246
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;
247
351
  /** the instant the transaction began (Postgres's `now()`), null between transactions */
248
352
  txNow = null;
249
353
  /** the instant of the statement being run (`statement_timestamp()`): its Query's arrival or its Bind */
@@ -254,6 +358,8 @@ class Session {
254
358
  skipToSync = false;
255
359
  /** the query running now, for a CancelRequest and the socket closing */
256
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;
257
363
  /** this session's DuckDB connection, opened at its first statement over a table */
258
364
  duckRunner = null;
259
365
  /** Closes the session's DuckDB connection, when it has one: only ever with no statement running on it (`end`, `settled`). */
@@ -262,7 +368,7 @@ class Session {
262
368
  return;
263
369
  const runner = this.duckRunner;
264
370
  this.duckRunner = null;
265
- 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)}`); });
266
372
  }
267
373
  /** A statement ended, however: the session's next one may run, and a session closed meanwhile closes its connection now. */
268
374
  settled() {
@@ -279,10 +385,14 @@ class Session {
279
385
  sniffed = false;
280
386
  closedBy = null;
281
387
  server;
282
- 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) {
283
391
  this.server = server;
392
+ this.door = door;
284
393
  this.socket = socket;
285
394
  this.attach(socket);
395
+ door.logins?.admit(this);
286
396
  }
287
397
  attach(socket) {
288
398
  socket.on("data", (chunk) => {
@@ -306,7 +416,18 @@ class Session {
306
416
  sendAll(parts) {
307
417
  if (this.phase === "closed")
308
418
  return;
309
- 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 };
310
431
  }
311
432
  end(why) {
312
433
  if (this.phase === "closed")
@@ -314,6 +435,7 @@ class Session {
314
435
  const wasReady = this.phase === "ready";
315
436
  this.phase = "closed";
316
437
  this.closedBy = why;
438
+ this.door.logins?.settled(this);
317
439
  if (wasReady)
318
440
  this.server.log(`session ${this.id} closed: ${why}`);
319
441
  this.running?.abort();
@@ -381,20 +503,24 @@ class Session {
381
503
  this.server.log(`trace ${this.id} ${JSON.stringify(this.vars.applicationName)} ${m.type}: ${JSON.stringify(m.sql)}`);
382
504
  }
383
505
  if (m.type === "Query" || m.type === "Parse" || m.type === "Bind" || m.type === "Describe" || m.type === "Execute") {
384
- 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 };
385
507
  }
386
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
387
509
  // keep-alive, its catalog reads, SET and SHOW go on for ever after a revocation)
388
510
  if ((m.type === "Query" || m.type === "Parse" || m.type === "Execute") && !(m.type !== "Query" && this.skipToSync)) {
389
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();
390
517
  if (this.phase === "closed")
391
518
  return;
392
519
  if (refused !== null) {
393
520
  this.refuse(refused, m.type === "Execute" ? this.sqlOf(m) : m.sql);
394
- if (this.tx === "open")
395
- this.tx = "failed";
521
+ this.abortTransaction();
396
522
  if (m.type === "Query")
397
- this.send(readyForQuery(this.txStatus()));
523
+ this.send(...this.ready());
398
524
  else
399
525
  this.skipToSync = true;
400
526
  return;
@@ -432,19 +558,19 @@ class Session {
432
558
  startup(m) {
433
559
  switch (m.type) {
434
560
  case "SSLRequest": {
435
- if (this.server.tls === null) {
561
+ if (this.door.tls === null) {
436
562
  this.send(sslResponse(false));
437
563
  return;
438
564
  }
439
565
  this.send(sslResponse(true));
440
- this.startTls(this.server.tls, null);
566
+ this.startTls(this.door.tls, null);
441
567
  return;
442
568
  }
443
569
  case "GSSENCRequest":
444
570
  this.send(sslResponse(false));
445
571
  return;
446
572
  case "CancelRequest": {
447
- this.server.cancel(m.pid, m.secret);
573
+ this.server.cancel(m.pid, m.secret, this.door);
448
574
  this.end("cancel request");
449
575
  return;
450
576
  }
@@ -453,11 +579,12 @@ class Session {
453
579
  this.fatal("0A000", `unsupported frontend protocol ${m.major}.${m.minor}: server supports 3.0`);
454
580
  return;
455
581
  }
456
- if (this.server.tls !== null && !(this.socket instanceof tls.TLSSocket)) {
582
+ if (this.door.tls !== null && !(this.socket instanceof tls.TLSSocket)) {
457
583
  this.fatal("28000", "this server requires TLS: the password must not travel in clear (connect with sslmode=require)");
458
584
  return;
459
585
  }
460
- 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;
461
588
  if (user === undefined || user === "") {
462
589
  this.fatal("28000", "no PostgreSQL user name specified in startup packet");
463
590
  return;
@@ -465,8 +592,13 @@ class Session {
465
592
  this.user = user;
466
593
  const database = m.params.database ?? user;
467
594
  if (database !== this.server.database) {
468
- this.fatal("3D000", `database "${database}" does not exist`, `this Mason serves the project "${this.server.database}": connect with database=${this.server.database}`);
469
- 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;
470
602
  }
471
603
  const encoding = m.params.client_encoding;
472
604
  if (encoding !== undefined && !isUtf8(encoding)) {
@@ -474,7 +606,7 @@ class Session {
474
606
  return;
475
607
  }
476
608
  if (m.params.application_name !== undefined)
477
- this.vars.applicationName = m.params.application_name;
609
+ this.vars.applicationName = (this.door.public ? nameLength(m.params.application_name) : m.params.application_name);
478
610
  try {
479
611
  if (m.params.options !== undefined)
480
612
  this.commandLineOptions(m.params.options);
@@ -491,6 +623,8 @@ class Session {
491
623
  this.fatal(e.fields.code, e.message, e.fields.hint);
492
624
  return;
493
625
  }
626
+ // what RESET and RESET ALL restore: the startup packet's values beside the defaults
627
+ this.startupVars = copyVars(this.vars);
494
628
  this.phase = "password";
495
629
  this.send(authenticationCleartextPassword());
496
630
  return;
@@ -526,14 +660,14 @@ class Session {
526
660
  * and this listener has no certificate to send), so it is refused in the log, naming the way out, and closed.
527
661
  */
528
662
  directTls(first) {
529
- if (this.server.tls === null) {
663
+ if (this.door.tls === null) {
530
664
  this.server.log(`session ${this.id} refused: a direct TLS start (a TLS ClientHello as its first bytes: sslnegotiation=direct, or a TLS ` +
531
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 ` +
532
666
  `in front and send Mason the plain protocol (docs/POSTGRES-API.md "Connecting")`);
533
667
  this.end("a direct TLS start on a plain listener");
534
668
  return;
535
669
  }
536
- this.startTls(this.server.tls, first);
670
+ this.startTls(this.door.tls, first);
537
671
  }
538
672
  /** libpq's `options=-c name=value …` (JDBC's `options`): each a SET before the session starts */
539
673
  commandLineOptions(options) {
@@ -554,25 +688,38 @@ class Session {
554
688
  }
555
689
  let token;
556
690
  try {
557
- token = await this.server.options.authenticate(m.password, this.user);
691
+ token = await this.door.authenticate(m.password, this.user);
558
692
  }
559
693
  catch (err) {
560
694
  const message = err instanceof Error ? err.message : String(err);
561
695
  // a revocation record that could not be read is no wrong password: the server could not tell, said as such
562
696
  const unknown = err instanceof ApiError && err.status === 503;
563
- const refused = new PgError({ code: unknown ? "57P03" : "28P01",
564
- message: unknown ? `the password could not be checked for user "${this.user}": ${message}` : `password authentication failed for user "${this.user}": ${message}`,
565
- hint: unknown ? "connect again once Mason can read its list of users"
566
- : "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);
567
707
  // a tool that cannot connect is the first thing to look for in the request log
568
- this.asked = { sql: "", method: "CONNECT", startedAt: Date.now() };
569
- this.recordRequest(refused, undefined, err instanceof ApiError ? err.userName : undefined);
570
- 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);
571
713
  this.end("authentication failed");
572
714
  return;
573
715
  }
574
716
  if (this.phase !== "password")
575
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
+ }
576
723
  this.token = token;
577
724
  this.presented = m.password;
578
725
  this.verifiedAt = this.server.now();
@@ -580,28 +727,24 @@ class Session {
580
727
  this.decoder.maxLength = MAX_MESSAGE_LENGTH; // authenticated: a statement may be as long as the protocol allows
581
728
  // what is happening, as it happens (mason logs once showed little): who connected, and (end) who left
582
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)}` +
583
- (token.user !== undefined ? " (a mason user)" : ""));
584
- const status = [authenticationOk()];
585
- for (const [name, value] of this.reportedParameters())
586
- status.push(parameterStatus(name, value));
587
- status.push(backendKeyData(this.id, this.secret), readyForQuery("I"));
588
- this.sendAll(status);
730
+ (token.user !== undefined ? ` (${this.door.who})` : ""));
731
+ this.sendAll([authenticationOk(), ...this.reportChanges(), backendKeyData(this.id, this.secret), readyForQuery("I")]);
589
732
  }
590
733
  reportedParameters() {
591
- return [
592
- ["application_name", this.vars.applicationName], ["client_encoding", "UTF8"], ["DateStyle", "ISO, MDY"],
593
- ["default_transaction_read_only", "on"], ["in_hot_standby", "on"], ["integer_datetimes", "on"], ["IntervalStyle", "postgres"],
594
- ["is_superuser", "off"], ["search_path", this.vars.searchPath], ["server_encoding", "UTF8"], ["server_version", SERVER_VERSION],
595
- ["session_authorization", this.user], ["standard_conforming_strings", "on"], ["TimeZone", this.vars.timeZone],
596
- ];
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
+ });
597
741
  }
598
742
  // ---------------------------------------------------------------- session parameters
599
743
  /**
600
- * SET of one parameter. Returns the ParameterStatus to send when it is one Postgres reports. The parameters this
601
- * 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.
602
746
  */
603
747
  setParameter(name, value, atStartup = false) {
604
- const report = (n, v) => REPORTED.has(n) ? [parameterStatus(spell(n), v)] : [];
605
748
  switch (name) {
606
749
  case "timezone":
607
750
  case "time zone": {
@@ -609,48 +752,48 @@ class Session {
609
752
  if (zone === null)
610
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" });
611
754
  this.vars.timeZone = zone;
612
- return report("timezone", zone);
755
+ return;
613
756
  }
614
757
  case "application_name":
615
758
  this.vars.applicationName = value ?? "";
616
- return report(name, this.vars.applicationName);
759
+ return;
617
760
  case "search_path":
618
761
  this.vars.searchPath = value ?? '"$user", public';
619
- return report(name, this.vars.searchPath);
762
+ return;
620
763
  case "client_encoding":
621
764
  case "names":
622
765
  if (value !== null && !isUtf8(value))
623
766
  throw new PgError({ code: "22023", message: `invalid value for parameter "client_encoding": "${value}"`, hint: "this API sends UTF8 only" });
624
- return report("client_encoding", "UTF8");
767
+ return;
625
768
  case "datestyle":
626
769
  if (value !== null && !/^\s*iso\b/i.test(value) && !/,\s*iso\s*$/i.test(value) && value.trim() !== "") {
627
770
  throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
628
771
  }
629
- return report("datestyle", "ISO, MDY");
772
+ return;
630
773
  case "extra_float_digits": {
631
774
  const n = value === null ? 1 : Number(value);
632
775
  if (!Number.isInteger(n) || n < -15 || n > 3)
633
776
  throw new PgError({ code: "22023", message: `${n} is outside the valid range for parameter "extra_float_digits" (-15 .. 3)` });
634
777
  if (n < 1)
635
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` });
636
- return [];
779
+ return;
637
780
  }
638
781
  case "standard_conforming_strings":
639
782
  if (value !== null && !/^(on|true|yes|1)$/i.test(value))
640
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" });
641
- return report(name, "on");
642
- case "intervalstyle": return report(name, "postgres");
784
+ return;
785
+ case "intervalstyle": return;
643
786
  case "transaction_isolation":
644
787
  case "default_transaction_isolation":
645
788
  case "transaction":
646
789
  case "session characteristics":
647
790
  // read committed on a store that is never written: every level answers the same rows
648
- return [];
791
+ return;
649
792
  case "default_transaction_read_only":
650
793
  case "transaction_read_only":
651
794
  if (value !== null && /^(off|false|no|0)$/i.test(value))
652
795
  throw new PgError({ code: "25006", message: "cannot set transaction read-write mode: this API is read-only" });
653
- return report(name, "on");
796
+ return;
654
797
  case "statement_timeout":
655
798
  case "lock_timeout":
656
799
  case "idle_in_transaction_session_timeout":
@@ -679,7 +822,7 @@ class Session {
679
822
  this.vars.other.delete(name);
680
823
  else
681
824
  this.vars.other.set(name, value);
682
- return [];
825
+ return;
683
826
  default: {
684
827
  // a parameter Mason knows nothing of: the catalog Postgres decides whether it exists
685
828
  if (atStartup)
@@ -688,10 +831,124 @@ class Session {
688
831
  this.vars.other.delete(name);
689
832
  else
690
833
  this.vars.other.set(name, value);
691
- return [];
834
+ return;
692
835
  }
693
836
  }
694
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
+ }
695
952
  /** The catalog Postgres validates a parameter this server has no opinion on; its own error is the answer. Inside a
696
953
  * transaction that is ROLLED BACK: the sidecar is one connection every session shares, and a SET that stuck there
697
954
  * was every session's (review of #480). The value itself lives in this session's `vars.other`. */
@@ -715,6 +972,7 @@ class Session {
715
972
  finally {
716
973
  if (this.tx === "none")
717
974
  this.txNow = null;
975
+ this.implicitBlock = false;
718
976
  }
719
977
  }
720
978
  /** the transaction's "now", the transaction begun at `statementNow` when none is open */
@@ -730,18 +988,20 @@ class Session {
730
988
  catch (err) {
731
989
  const e = asPgError(err, sql);
732
990
  this.refuse(e, sql);
733
- if (this.tx === "open")
734
- this.tx = "failed";
735
- this.send(readyForQuery(this.txStatus()));
991
+ this.abortTransaction();
992
+ this.send(...this.ready());
736
993
  return;
737
994
  }
738
995
  if (stmts.length === 0) {
739
- this.send(emptyQueryResponse(), readyForQuery(this.txStatus()));
996
+ this.send(emptyQueryResponse(), ...this.ready());
740
997
  return;
741
998
  }
742
999
  for (const raw of stmts) {
743
1000
  const text = statementText(sql, raw);
744
- 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;
745
1005
  try {
746
1006
  const out = await this.runSimple(raw, text, sql);
747
1007
  this.sendAll(out);
@@ -749,12 +1009,11 @@ class Session {
749
1009
  catch (err) {
750
1010
  const e = asPgError(err, sql);
751
1011
  this.refuse(e, text);
752
- if (this.tx === "open")
753
- this.tx = "failed";
1012
+ this.abortTransaction();
754
1013
  break;
755
1014
  }
756
1015
  }
757
- this.send(readyForQuery(this.txStatus()));
1016
+ this.send(...this.ready());
758
1017
  }
759
1018
  /**
760
1019
  * EVERY STATEMENT'S ErrorResponse goes out here, and is logged here at the normal level (a BI tool met an
@@ -765,9 +1024,10 @@ class Session {
765
1024
  */
766
1025
  refuse(e, sql, at) {
767
1026
  this.server.log(refusalLine(this.id, e.fields, sql));
768
- this.recordRequest(e, sql);
1027
+ const response = errorResponse(e.fields);
1028
+ this.recordRequest(e, sql, undefined, [response]);
769
1029
  this.traceRefusal(e, at);
770
- this.send(errorResponse(e.fields));
1030
+ this.send(response);
771
1031
  }
772
1032
  traceRefusal(e, at) {
773
1033
  if (this.server.options.trace !== true)
@@ -781,21 +1041,30 @@ class Session {
781
1041
  * statement's ErrorResponse goes out), and for a refused password (CONNECT), through `options.record`. A catalog
782
1042
  * read, a SET or a SHOW answered is the protocol's chatter, not recorded. Never throws: `record` buffers.
783
1043
  */
784
- recordRequest(answer, sql, refusedUser) {
785
- const asked = this.asked;
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 } : {}) };
786
1049
  const startedAt = (!(answer instanceof PgError) ? answer.startedAt : undefined) ?? asked?.startedAt ?? Date.now();
1050
+ const bytesSent = this.bytesFor(then, asked);
787
1051
  // a refusal's statement is the one refuse() names (a simple Query's own statement, an Execute's portal's)
788
1052
  const stored = storedPayload(sql ?? asked?.sql ?? "");
789
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();
790
1056
  this.server.options.record({
791
1057
  requestId: crypto.randomUUID(), at: new Date(startedAt), method: asked?.method ?? "QUERY", durationMs: Date.now() - startedAt,
792
1058
  client: `postgres:${this.vars.applicationName || "unnamed"}`, session: this.id, payload: stored.text, payloadTruncated: stored.truncated,
793
- caller: hashCaller(this.presented),
1059
+ caller: hashCaller(this.presented), bytesSent,
794
1060
  ...(user !== undefined ? { user } : {}),
1061
+ ...(role !== undefined ? { role } : {}),
1062
+ ...(this.token?.attributes !== undefined && Object.keys(this.token.attributes).length > 0 ? { attributes: this.token.attributes } : {}),
795
1063
  ...(asked?.jsonReads !== undefined && asked.jsonReads.length > 0 ? { jsonReads: asked.jsonReads } : {}),
796
1064
  ...(answer instanceof PgError
797
1065
  ? { status: answer.status, sqlstate: answer.fields.code, error: answer.fields.message,
798
- ...(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 } : {}) }
799
1068
  : { status: 200, rows: answer.rows }),
800
1069
  });
801
1070
  }
@@ -811,36 +1080,35 @@ class Session {
811
1080
  if (raw.stmt === undefined)
812
1081
  return [emptyQueryResponse()];
813
1082
  const now = this.txStart(this.statementNow);
1083
+ this.heldToFailedBlock(raw.stmt);
814
1084
  const t = this.classify(raw.stmt, text);
815
- if (this.tx === "failed" && !(t.kind === "transaction" && t.tag !== "BEGIN" && t.tag !== "START TRANSACTION")) {
816
- throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
817
- }
818
1085
  switch (t.kind) {
819
1086
  case "empty": return [emptyQueryResponse()];
820
1087
  case "explain": {
821
1088
  const e = await this.explaining(raw, whole, t, []);
822
1089
  if (e.plan === null)
823
- return catalogExplained(await this.forwardSimple(text), e.options);
1090
+ return catalogExplained(await this.forwardSimple(this.spliced(text, raw)), e.options);
824
1091
  const controller = new AbortController();
825
1092
  const disarm = this.armTimeout(controller);
826
1093
  try {
827
1094
  const { field, rows } = await this.duckExplain({ options: e.options, plan: e.plan }, [], [], { parse: now, exec: now }, controller);
828
- this.recordRequest({ rows: rows.length });
829
- 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;
830
1098
  }
831
1099
  finally {
832
1100
  disarm();
833
1101
  }
834
1102
  }
835
- case "catalog": return this.forwardSimple(text);
1103
+ case "catalog": return this.forwardSimple(this.spliced(text, raw));
836
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);
837
1109
  case "show": return this.showStatement(t.name);
838
1110
  case "transaction": return [...this.transaction(t.tag)];
839
- case "discard":
840
- this.statements.clear();
841
- this.dropPortals();
842
- this.vars = { ...defaultVars(), applicationName: this.vars.applicationName };
843
- return [commandComplete("DISCARD ALL")];
1111
+ case "discard": return this.discard(t.target);
844
1112
  case "deallocate":
845
1113
  this.statements.clear();
846
1114
  return [commandComplete("DEALLOCATE ALL")];
@@ -858,8 +1126,9 @@ class Session {
858
1126
  finally {
859
1127
  cursor.answer.close();
860
1128
  }
861
- this.recordRequest({ rows: cursor.sent });
862
- return [commandComplete(`SELECT ${cursor.sent}`)];
1129
+ const done = [commandComplete(`SELECT ${cursor.sent}`)];
1130
+ this.recordRequest({ rows: cursor.sent }, undefined, undefined, done);
1131
+ return done;
863
1132
  }
864
1133
  finally {
865
1134
  disarm();
@@ -867,14 +1136,244 @@ class Session {
867
1136
  }
868
1137
  }
869
1138
  }
870
- /** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone and
871
- * declared parameter types until the schema changes. Every statement DuckDB runs has one: never its text as written. */
872
- async duckPlan(raw, whole, paramOids) {
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. */
1142
+ async duckPlan(raw, whole, paramOids, under = this.vars, wireBound = false) {
873
1143
  const stmt = raw.stmt;
874
1144
  const bytes = Buffer.from(whole, "utf8");
875
1145
  const base = raw.stmt_location ?? 0;
876
1146
  const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
877
- return this.server.duckPlan(text, paramOids, this.vars.searchPath, this.vars.timeZone, () => this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, this.vars.searchPath, this.vars.timeZone, this.server.userTypes()));
1147
+ const { searchPath, timeZone } = under;
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;
1333
+ }
1334
+ /**
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
1336
+ * Execute's row count (JDBC's setMaxRows or fetch size, DBeaver's, a native question's) arrives after the Parse, so a
1337
+ * LEFT lateral of a list with no LIMIT in its SQL was left DuckDB's delimiter join, which reads the whole base before
1338
+ * the first row and hands its rows back in its own order — the first rows sent were not Postgres's. Where the plan
1339
+ * says such a lateral was left so (PlannedSql.unboundedLateral), the statement is planned again with the bound
1340
+ * asserted, under the search_path and TimeZone of its Parse, kept on the statement and in the plans by text. The
1341
+ * portal's result is begun ONCE, from whichever plan its first Execute picked; an Execute after a PortalSuspended
1342
+ * reads on from that result, never runs it again. A plan made again over a schema moved since the Parse, whose
1343
+ * result or parameters are not the ones the Parse described, is not run: the Parse's plan is (what a client was told
1344
+ * of the result stands). Planning again is the statement's own time: a CancelRequest landing meanwhile aborts its
1345
+ * controller (`running`, answered 57014 as the statement begins), and a session closed meanwhile runs nothing. A
1346
+ * plan made again that Postgres or the rewrite refuses (a StatementError, never a cancel's or a timeout's 57014) is
1347
+ * logged and the Parse's plan runs, kept: the Parse answered, and a row count must not make it fail.
1348
+ */
1349
+ async boundOnWire(statement, plan, controller) {
1350
+ if (plan.sql.unboundedLateral !== true || statement.raw === null)
1351
+ return plan;
1352
+ if (statement.duckOnWire === undefined) {
1353
+ this.running = controller;
1354
+ let bounded;
1355
+ try {
1356
+ bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.vars, true);
1357
+ }
1358
+ catch (e) {
1359
+ if (!(e instanceof StatementError) || e.fields.code === "57014")
1360
+ throw e;
1361
+ this.server.log(`session ${this.id}: a statement planned again for an Execute's row count was refused (${e.fields.code}: ${e.fields.message}): its Parse's plan runs`);
1362
+ bounded = plan;
1363
+ }
1364
+ finally {
1365
+ this.settled();
1366
+ }
1367
+ const shape = (p) => JSON.stringify([p.params, p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null]);
1368
+ statement.duckOnWire = shape(bounded) === shape(plan) ? bounded : plan;
1369
+ if (statement.duckOnWire === plan && bounded !== plan)
1370
+ this.server.log(`session ${this.id}: a statement planned again for an Execute's row count answers other columns than its Parse said (the schema moved): its Parse's plan runs`);
1371
+ }
1372
+ if (this.phase === "closed") {
1373
+ this.server.log(`session ${this.id}: closed while its statement was planned again for an Execute's row count: it runs nothing`);
1374
+ throw new PgError({ code: "08006", message: "the session closed while its statement was planned" });
1375
+ }
1376
+ return statement.duckOnWire;
878
1377
  }
879
1378
  /**
880
1379
  * AN EXPLAIN (src/api/postgres/explain.ts, src/duck/explain.ts): its options read as Postgres reads them, then the
@@ -894,17 +1393,41 @@ class Session {
894
1393
  const found = await explainedText(text, t.query);
895
1394
  if (found === null)
896
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)" });
897
- const 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());
1396
+ let plan;
1397
+ try {
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 };
1404
+ }
1405
+ catch (e) {
1406
+ // a refusal of the statement explained is placed as Postgres places it: in the whole text, past `EXPLAIN …`
1407
+ if (!(e instanceof StatementError) || e.fields.position === undefined)
1408
+ throw e;
1409
+ const before = bytes.subarray(0, base).toString("utf8").length + found.offset;
1410
+ throw new StatementError({ ...e.fields, position: e.fields.position + before }, whole);
1411
+ }
898
1412
  return { options, plan };
899
1413
  }
900
1414
  /** an EXPLAIN of a DuckDB statement, run: its QUERY PLAN rows (ANALYZE runs the statement and sends none of its rows) */
901
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
+ }
902
1423
  await this.readAside();
903
1424
  const runner = await this.duck();
904
1425
  this.running = controller;
905
1426
  try {
906
- const values = await this.duckValues(e.plan, params, clock);
907
- 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 },
1429
+ // how each copy is kept current, as the sync says it to mason status, read now (src/duck/sync.ts KeptCurrent)
1430
+ copies: copyFacts(this.server.options.duck.copies?.() ?? []), ...(filtered.length > 0 ? { rowFilters: filtered, reads: (e.plan.reads ?? []).map((r) => r.table) } : {}) });
908
1431
  return { field: explainField(e.options, formatAt(resultFormats, 0)), rows: lines.map((l) => dataRow([Buffer.from(l)])) };
909
1432
  }
910
1433
  finally {
@@ -925,17 +1448,27 @@ class Session {
925
1448
  this.duckRunner ??= await this.server.options.duck.open();
926
1449
  return this.duckRunner;
927
1450
  }
928
- 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
+ }
929
1461
  /** A DuckDB statement run: its columns (Postgres's, where it has a plan), and its rows to read, encoded in the
930
1462
  * formats asked */
931
1463
  async duckAnswer(plan, params, resultFormats, clock, controller) {
932
1464
  this.readsOf(plan);
1465
+ this.heldToRole(plan);
933
1466
  await this.readAside();
934
1467
  const runner = await this.duck();
935
1468
  this.running = controller;
936
1469
  try {
937
1470
  const values = await this.duckValues(plan, params, clock);
938
- 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 ?? []);
939
1472
  // a SELECT's analysis always has its RowDescription; without one, DuckDB's own columns are said as they are
940
1473
  const fields = plan.fields !== null
941
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) }))
@@ -993,6 +1526,8 @@ class Session {
993
1526
  if (c.pending.length === 0)
994
1527
  return;
995
1528
  c.sent += c.pending.length;
1529
+ if (this.asked !== null)
1530
+ this.asked.rows = c.sent;
996
1531
  this.sendAll(c.pending);
997
1532
  c.pending = [];
998
1533
  }
@@ -1054,41 +1589,157 @@ class Session {
1054
1589
  }
1055
1590
  }
1056
1591
  }
1057
- /** a portal gone (Close, Sync, a Bind of its name, DISCARD, the session's end): its DuckDB result with it */
1592
+ /**
1593
+ * A portal gone (Close, Sync, a Bind of its name, DISCARD, the session's end): its DuckDB result with it. One the
1594
+ * client stopped reading at a row count (an Execute's: JDBC's setMaxRows, `mason mcp`'s max_rows) was never read to
1595
+ * its end, so its row of the request log is written here, with the rows it sent: a statement answered is in the log
1596
+ * however far it was read.
1597
+ */
1058
1598
  dropPortal(name) {
1059
- this.portals.get(name)?.cursor?.answer.close();
1599
+ const portal = this.portals.get(name);
1600
+ const c = portal?.cursor;
1601
+ if (portal !== undefined && c !== undefined && c.suspended === true && c.recorded !== true && c.failed === undefined) {
1602
+ c.recorded = true;
1603
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, portal.statement.sql, undefined, [], { method: "EXECUTE", jsonReads: portal.statement.duck?.jsonReads, carried: portal.bytes });
1604
+ }
1605
+ c?.answer.close();
1060
1606
  this.portals.delete(name);
1061
1607
  }
1062
1608
  dropPortals() { for (const name of [...this.portals.keys()])
1063
1609
  this.dropPortal(name); }
1064
1610
  async forwardSimple(text) {
1065
- const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(text))));
1611
+ const reply = await this.catalogAs(frame("Q", cstr(text)));
1066
1612
  const err = reply.find((f) => f.type === "E");
1067
1613
  if (err !== undefined)
1068
1614
  throw relayedError(err.frame);
1069
1615
  return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1070
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. */
1071
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) {
1072
1629
  if (t.name === "all") {
1073
- this.vars = { ...defaultVars(), applicationName: this.vars.applicationName };
1630
+ await this.resetAll();
1074
1631
  return [commandComplete("RESET")];
1075
1632
  }
1076
- const tag = t.value === null ? "RESET" : "SET";
1077
- 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))
1078
1654
  await this.validateElsewhere(t.name, t.value);
1079
- const status = this.setParameter(t.name, t.value);
1080
- 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")];
1081
1700
  }
1082
1701
  decidesHere(name) {
1083
1702
  return ["timezone", "time zone", "application_name", "search_path", "client_encoding", "names", "datestyle", "extra_float_digits",
1084
1703
  "standard_conforming_strings", "intervalstyle", "transaction_isolation", "default_transaction_isolation", "transaction", "session characteristics",
1085
1704
  "default_transaction_read_only", "transaction_read_only"].includes(name);
1086
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
+ }
1087
1734
  async showStatement(name) {
1088
1735
  if (name === "all")
1089
- throw new PgError({ code: "0A000", message: "SHOW ALL is not supported: SHOW one parameter" });
1090
- 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);
1091
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}"` });
1092
1743
  const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(`SHOW ${quoteIdent(name)}`))));
1093
1744
  const err = reply.find((f) => f.type === "E");
1094
1745
  if (err !== undefined)
@@ -1097,6 +1748,31 @@ class Session {
1097
1748
  }
1098
1749
  return [rowDescription([fieldOf(spell(name), "text", 0)]), dataRow([Buffer.from(value, "utf8")]), commandComplete("SHOW")];
1099
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
+ }
1100
1776
  /** a SHOW's RowDescription, for a Describe: the frame its answer starts with (the column is named by the parameter) */
1101
1777
  async showDescription(name) {
1102
1778
  return (await this.showStatement(name)).filter((f) => f[0] === ROW_DESCRIPTION);
@@ -1108,10 +1784,12 @@ class Session {
1108
1784
  this.tx = "open";
1109
1785
  return [commandComplete(tag)];
1110
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);
1111
1790
  if (this.tx === "none") {
1112
1791
  return [noticeResponse({ severity: "WARNING", code: "25P01", message: "there is no transaction in progress" }), commandComplete(tag)];
1113
1792
  }
1114
- const wasFailed = this.tx === "failed";
1115
1793
  this.tx = "none";
1116
1794
  // the transaction's "now" ends with it; a statement after it in the same Query begins the next at the Query's arrival
1117
1795
  this.txNow = null;
@@ -1127,27 +1805,39 @@ class Session {
1127
1805
  // an implicit transaction ends at Sync; an open one keeps its "now"
1128
1806
  if (this.tx === "none")
1129
1807
  this.txNow = null;
1130
- this.send(readyForQuery(this.txStatus()));
1808
+ this.send(...this.ready());
1131
1809
  return;
1132
1810
  }
1133
1811
  if (this.skipToSync || m.type === "Flush")
1134
1812
  return;
1813
+ // its statement, read as the message arrives: a portal dropped while it runs (the session ended under a cancelled
1814
+ // statement) leaves the refusal its statement, never ""
1815
+ const sql = m.type === "Parse" ? m.sql : this.sqlOf(m);
1135
1816
  try {
1136
1817
  switch (m.type) {
1137
1818
  case "Parse": return this.sendAll(await this.parseMessage(m));
1138
1819
  case "Bind": return this.sendAll(await this.bindMessage(m));
1139
1820
  case "Describe": return this.sendAll(await this.describeMessage(m));
1140
- 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
+ }
1141
1833
  case "Close": return this.sendAll(await this.closeMessage(m));
1142
1834
  }
1143
1835
  }
1144
1836
  catch (err) {
1145
- const sql = m.type === "Parse" ? m.sql : this.sqlOf(m);
1146
1837
  const e = asPgError(err, sql);
1147
1838
  this.refuse(e, sql, m.type);
1148
1839
  this.skipToSync = true;
1149
- if (this.tx === "open")
1150
- this.tx = "failed";
1840
+ this.abortTransaction();
1151
1841
  }
1152
1842
  }
1153
1843
  sqlOf(m) {
@@ -1166,7 +1856,7 @@ class Session {
1166
1856
  }
1167
1857
  /** runs frames in the sidecar; throws the first ErrorResponse; returns the rest, less ReadyForQuery */
1168
1858
  async catalogRun(...parts) {
1169
- const reply = splitFrames(await this.server.catalog.run(Buffer.concat(parts)));
1859
+ const reply = await this.catalogAs(Buffer.concat(parts));
1170
1860
  // the sidecar answered NOTHING to a batch: it is ignoring everything until a Sync (an earlier error nobody synced)
1171
1861
  // — a protocol state this server must never leave it in; said, never relayed as an empty answer
1172
1862
  if (reply.length === 0)
@@ -1174,21 +1864,22 @@ class Session {
1174
1864
  const err = reply.find((f) => f.type === "E");
1175
1865
  if (err !== undefined) {
1176
1866
  // AFTER AN ERROR IN THE EXTENDED PROTOCOL POSTGRES IGNORES EVERY MESSAGE UNTIL A SYNC (ignore_till_sync), and the
1177
- // sidecar is one connection every session shares: without this Sync one refused Parse silenced the catalog for
1178
- // every session until a restart (review of #480). The Sync's own ReadyForQuery is consumed here.
1179
- if (!parts.some((p) => p[0] === 0x53 /* S */))
1180
- 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
1181
1870
  throw relayedError(err.frame);
1182
1871
  }
1183
1872
  return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1184
1873
  }
1185
1874
  async parseMessage(m) {
1186
- if (m.name !== "" && this.statements.has(m.name))
1187
- throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
1188
1875
  const stmts = (await parseSql(m.sql)).stmts ?? [];
1189
1876
  if (stmts.length > 1)
1190
1877
  throw new PgError({ code: "42601", message: "cannot insert multiple commands into a prepared statement" });
1191
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` });
1192
1883
  const statement = { sql: m.sql, raw, paramOids: [...m.paramTypes], shape: null, catalogName: null };
1193
1884
  if (raw?.stmt !== undefined) {
1194
1885
  statement.shape = this.classify(raw.stmt, statementText(m.sql, raw));
@@ -1204,6 +1895,7 @@ class Session {
1204
1895
  // Postgres's analysis says the result's columns and the parameters' types, and DuckDB's SQL is made from it
1205
1896
  if (statement.shape.kind === "duckdb") {
1206
1897
  statement.duck = await this.duckPlan(raw, m.sql, m.paramTypes);
1898
+ statement.plannedUnder = { searchPath: this.vars.searchPath, timeZone: this.vars.timeZone };
1207
1899
  // the implicit transaction begins with the Parse, as Postgres's does (start_xact_command): its literals read it
1208
1900
  statement.parsedNow = this.txNow ??= this.server.now();
1209
1901
  }
@@ -1212,7 +1904,7 @@ class Session {
1212
1904
  const own = this.statements.get(m.name);
1213
1905
  // the unnamed statement is replaced by a Parse; the sidecar's must be closed first
1214
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))] : [];
1215
- 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)));
1216
1908
  this.statements.set(m.name, statement);
1217
1909
  // the CloseComplete of the close this server added is not the client's
1218
1910
  return reply.slice(close.length);
@@ -1225,6 +1917,8 @@ class Session {
1225
1917
  const statement = this.statements.get(m.statement);
1226
1918
  if (statement === undefined)
1227
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);
1228
1922
  if (m.portal !== "" && this.portals.has(m.portal))
1229
1923
  throw new PgError({ code: "42P03", message: `cursor "${m.portal}" already exists` });
1230
1924
  if (statement.catalogName !== null) {
@@ -1241,7 +1935,7 @@ class Session {
1241
1935
  }
1242
1936
  this.dropPortal(m.portal);
1243
1937
  const rows = reply.slice(2, -2);
1244
- 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,
1245
1939
  catalog: { description, rows: statement.explainCatalog === undefined ? rows : catalogExplained(rows, statement.explainCatalog), sent: 0, done } });
1246
1940
  return [bound];
1247
1941
  }
@@ -1262,7 +1956,7 @@ class Session {
1262
1956
  const boundAt = this.server.now();
1263
1957
  this.txNow ??= boundAt;
1264
1958
  this.dropPortal(m.portal);
1265
- 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 });
1266
1960
  return [bindComplete()];
1267
1961
  }
1268
1962
  async describeMessage(m) {
@@ -1270,11 +1964,14 @@ class Session {
1270
1964
  const statement = this.statements.get(m.name);
1271
1965
  if (statement === undefined)
1272
1966
  throw new PgError({ code: "26000", message: `prepared statement "${m.name}" does not exist` });
1967
+ this.describedInFailedBlock(statement);
1273
1968
  if (statement.catalogName !== null)
1274
1969
  return this.catalogRun(frame("D", Buffer.from("S"), cstr(statement.catalogName)));
1275
1970
  const shape = statement.shape;
1276
1971
  if (shape?.kind === "show")
1277
1972
  return [parameterDescription([]), ...await this.showDescription(shape.name)];
1973
+ if (shape?.kind === "set_config")
1974
+ return [parameterDescription([]), this.setConfigDescription(shape, 0)];
1278
1975
  if (statement.explain !== undefined) {
1279
1976
  const e = statement.explain;
1280
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)])];
@@ -1289,11 +1986,14 @@ class Session {
1289
1986
  const portal = this.portals.get(m.name);
1290
1987
  if (portal === undefined)
1291
1988
  throw new PgError({ code: "34000", message: `portal "${m.name}" does not exist` });
1989
+ this.describedInFailedBlock(portal.statement);
1292
1990
  if (portal.catalog !== undefined)
1293
1991
  return [portal.catalog.description];
1294
1992
  const shape = portal.statement.shape;
1295
1993
  if (shape?.kind === "show")
1296
1994
  return this.showDescription(shape.name);
1995
+ if (shape?.kind === "set_config")
1996
+ return [this.setConfigDescription(shape, formatAt(portal.resultFormats, 0))];
1297
1997
  if (portal.statement.explain !== undefined)
1298
1998
  return [rowDescription([explainField(portal.statement.explain.options, formatAt(portal.resultFormats, 0))])];
1299
1999
  const d = portal.statement.duck;
@@ -1305,6 +2005,11 @@ class Session {
1305
2005
  const portal = this.portals.get(m.portal);
1306
2006
  if (portal === undefined)
1307
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);
1308
2013
  if (portal.catalog !== undefined) {
1309
2014
  const c = portal.catalog;
1310
2015
  const take = m.maxRows > 0 ? Math.min(m.maxRows, c.rows.length - c.sent) : c.rows.length - c.sent;
@@ -1312,24 +2017,19 @@ class Session {
1312
2017
  c.sent += take;
1313
2018
  return c.sent < c.rows.length ? [...out, portalSuspended()] : [...out, c.done];
1314
2019
  }
1315
- const statement = portal.statement;
1316
- if (statement.raw?.stmt === undefined)
1317
- 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;
1318
2023
  const t = statement.shape ?? this.classify(statement.raw.stmt, statementText(statement.sql, statement.raw));
1319
- if (this.tx === "failed" && !(t.kind === "transaction" && t.tag !== "BEGIN" && t.tag !== "START TRANSACTION")) {
1320
- throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
1321
- }
1322
2024
  switch (t.kind) {
1323
2025
  case "empty": return [emptyQueryResponse()];
1324
2026
  case "catalog": throw new PgError({ code: "XX000", message: "a catalog statement bound as a query (Mason bug: report it)" }, 500);
1325
2027
  case "set": return this.setStatement(t);
2028
+ case "set_config": return this.setConfigStatement(t);
1326
2029
  // as Postgres: the extended protocol's Execute sends the rows and the tag, the RowDescription is the Describe's
1327
2030
  case "show": return (await this.showStatement(t.name)).filter((f) => f[0] !== ROW_DESCRIPTION);
1328
2031
  case "transaction": return this.transaction(t.tag);
1329
- case "discard":
1330
- this.statements.clear();
1331
- this.dropPortals();
1332
- return [commandComplete("DISCARD ALL")];
2032
+ case "discard": return this.discard(t.target);
1333
2033
  case "deallocate":
1334
2034
  this.statements.clear();
1335
2035
  return [commandComplete("DEALLOCATE ALL")];
@@ -1363,12 +2063,28 @@ class Session {
1363
2063
  const out = rows.slice(portal.sent, portal.sent + take);
1364
2064
  portal.sent += take;
1365
2065
  if (portal.sent < rows.length)
1366
- return [...out, portalSuspended()];
1367
- this.recordRequest({ rows: rows.length });
1368
- 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;
1369
2070
  }
1370
2071
  }
1371
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
+ }
1372
2088
  /** the statement being answered reads these JSON paths: its request log row says so, answered or failed */
1373
2089
  readsOf(plan) {
1374
2090
  if (this.asked !== null && plan.jsonReads !== undefined)
@@ -1381,36 +2097,63 @@ class Session {
1381
2097
  this.readsOf(statement.duck);
1382
2098
  if (portal.cursor === undefined) {
1383
2099
  if (statement.duck === undefined)
1384
- 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
+ }
2111
+ // a row count on the wire bounds the rows: the plan made for it where it differs (boundOnWire)
2112
+ const plan = m.maxRows > 0 ? await this.boundOnWire(statement, statement.duck, controller) : statement.duck;
1385
2113
  const exec = this.txNow ??= this.server.now();
1386
- const answer = await this.duckAnswer(statement.duck, portal.params, portal.resultFormats, { parse: statement.parsedNow ?? exec, exec }, controller);
2114
+ const answer = await this.duckAnswer(plan, portal.params, portal.resultFormats, { parse: statement.parsedNow ?? exec, exec }, controller);
1387
2115
  portal.cursor = answer.cursor;
1388
2116
  portal.fields = answer.fields;
1389
2117
  }
1390
2118
  const c = portal.cursor;
1391
2119
  if (c.failed !== undefined)
1392
2120
  throw c.failed;
1393
- // every row: streamed
1394
- if (m.maxRows <= 0) {
1395
- try {
1396
- await this.streamRest(c);
2121
+ c.suspended = false;
2122
+ try {
2123
+ // every row: streamed
2124
+ if (m.maxRows <= 0) {
2125
+ try {
2126
+ await this.streamRest(c);
2127
+ }
2128
+ finally {
2129
+ c.answer.close();
2130
+ }
2131
+ c.recorded = true;
2132
+ const done = [commandComplete(`SELECT ${c.sent}`)];
2133
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, undefined, undefined, done);
2134
+ return done;
1397
2135
  }
1398
- finally {
1399
- c.answer.close();
2136
+ // at most maxRows (JDBC's fetch size, a cursor): suspended while a row is left, read one chunk ahead to know it
2137
+ while (!c.done && c.pending.length <= m.maxRows)
2138
+ await this.fill(c);
2139
+ const out = c.pending.splice(0, m.maxRows);
2140
+ c.sent += out.length;
2141
+ // suspended: its row is written at its last Execute, or when it is dropped (dropPortal), with what its pages sent
2142
+ if (c.pending.length > 0) {
2143
+ c.suspended = true;
2144
+ return this.pageSent(portal, [...out, portalSuspended()]);
1400
2145
  }
1401
- this.recordRequest({ rows: c.sent, startedAt: c.startedAt });
1402
- return [commandComplete(`SELECT ${c.sent}`)];
1403
- }
1404
- // at most maxRows (JDBC's fetch size, a cursor): suspended while a row is left, read one chunk ahead to know it
1405
- while (!c.done && c.pending.length <= m.maxRows)
1406
- await this.fill(c);
1407
- const out = c.pending.splice(0, m.maxRows);
1408
- c.sent += out.length;
1409
- if (c.pending.length > 0)
1410
- return [...out, portalSuspended()];
1411
- c.answer.close();
1412
- this.recordRequest({ rows: c.sent, startedAt: c.startedAt });
1413
- return [...out, commandComplete(`SELECT ${c.sent}`)];
2146
+ c.answer.close();
2147
+ c.recorded = true;
2148
+ const done = [...out, commandComplete(`SELECT ${c.sent}`)];
2149
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, undefined, undefined, done);
2150
+ return done;
2151
+ }
2152
+ catch (e) {
2153
+ // its refusal is its row (refuse)
2154
+ c.recorded = true;
2155
+ throw e;
2156
+ }
1414
2157
  }
1415
2158
  async closeMessage(m) {
1416
2159
  if (m.kind === "S") {
@@ -1431,15 +2174,18 @@ class Session {
1431
2174
  * refuses this statement only (recorded by `refuse`).
1432
2175
  */
1433
2176
  async stillLive(asked) {
1434
- const recheck = this.server.options.recheck;
2177
+ const recheck = this.door.recheck;
1435
2178
  const user = this.token?.user;
1436
2179
  if (recheck === undefined || user === undefined)
1437
2180
  return null;
1438
2181
  const now = this.server.now();
1439
- if (now - this.verifiedAt < (this.server.options.recheckMs ?? DEFAULT_RECHECK_MS))
2182
+ if (now - this.verifiedAt < this.door.recheckMs)
1440
2183
  return null;
1441
2184
  try {
1442
- 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;
1443
2189
  }
1444
2190
  catch (err) {
1445
2191
  const message = err instanceof Error ? err.message : String(err);
@@ -1448,7 +2194,9 @@ class Session {
1448
2194
  return new PgError({ code: "57P03", message, hint: "run the statement again once Mason can read its list of users" }, 503);
1449
2195
  if (err instanceof ApiError)
1450
2196
  this.recordRefusal(asked.toUpperCase(), err);
1451
- 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`);
1452
2200
  return null;
1453
2201
  }
1454
2202
  this.verifiedAt = now;
@@ -1471,6 +2219,84 @@ const formatAt = (formats, i) => (formats.length === 0 ? 0 : formats.length ===
1471
2219
  const isUtf8 = (encoding) => /^(utf-?8|unicode)$/i.test(encoding.trim());
1472
2220
  const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
1473
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
+ }
1474
2300
  /** the text of one statement of a simple query, from the parser's byte offsets */
1475
2301
  function statementText(sql, raw) {
1476
2302
  const bytes = Buffer.from(sql, "utf8");
@@ -1548,10 +2374,14 @@ function relayedError(frame) {
1548
2374
  // the listener
1549
2375
  class Server {
1550
2376
  sessions = new Map();
1551
- /** the DuckDB plans by text, search_path and declared parameter types, for the schema they were made under */
2377
+ /** the catalog Postgres's own settings, by name (`catalogSettings`), read once */
2378
+ baseSettings = null;
2379
+ /** the DuckDB plans by text, search_path, TimeZone, declared parameter types and an Execute's bound (Session.boundOnWire), for
2380
+ * the schema they were made under */
1552
2381
  duckPlans = new Map();
1553
2382
  duckPlansOf = null;
1554
2383
  duckPlansAt = 0;
2384
+ duckPlansRoles = null;
1555
2385
  /** the source's OIDs for the types an extension made, by name (a copied citext column's), from the schema */
1556
2386
  userTypes() {
1557
2387
  const out = new Map();
@@ -1578,42 +2408,62 @@ class Server {
1578
2408
  }
1579
2409
  return out;
1580
2410
  }
1581
- async duckPlan(text, paramOids, searchPath, timeZone, make) {
1582
- const schema = this.options.schema();
2411
+ /**
2412
+ * EVERY PLAN IS MADE OVER THE CATALOG OF THE SCHEMA IT IS KEPT UNDER: the catalog is replaced here (`schema`), not
2413
+ * only at a statement's classify, because a plan is also made at an Execute (Session.boundOnWire) — a re-plan that
2414
+ * read the schema raw analysed against the old catalog and was kept under the new one (a review finding of #23).
2415
+ * The ordering relied on: `schema()` calls `catalog.replace` synchronously, which queues the old instance's close on
2416
+ * the catalog's one chain (src/api/postgres/catalog.ts "SERIALISED") before `make` queues its analysis, so the
2417
+ * analysis starts the instance with the new DDL. A plan whose schema or generation moved while it was made is
2418
+ * returned to its caller and not kept.
2419
+ */
2420
+ async duckPlan(text, paramOids, searchPath, timeZone, wireBound, role, make) {
2421
+ const schema = this.schema();
2422
+ const roles = this.rolesNow();
1583
2423
  const generation = this.options.duck?.generation?.() ?? 0;
1584
- if (schema !== this.duckPlansOf || generation !== this.duckPlansAt) {
2424
+ if (schema !== this.duckPlansOf || generation !== this.duckPlansAt || roles !== this.duckPlansRoles) {
1585
2425
  this.duckPlans.clear();
1586
2426
  this.duckPlansOf = schema;
1587
2427
  this.duckPlansAt = generation;
2428
+ this.duckPlansRoles = roles;
1588
2429
  }
1589
2430
  // the zone too: a plan holds the values Postgres parsed its literals to, under the session's zone
1590
- const key = `${searchPath}\u0000${timeZone}\u0000${paramOids.join(",")}\u0000${text}`;
2431
+ // and the bound: an Execute's row count plans a LEFT lateral of a list otherwise (Session.boundOnWire), kept beside
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}`;
1591
2434
  const hit = this.duckPlans.get(key);
1592
2435
  if (hit !== undefined)
1593
2436
  return hit;
1594
2437
  const plan = await make();
2438
+ if (this.duckPlansOf !== schema || this.duckPlansAt !== generation || this.duckPlansRoles !== roles)
2439
+ return plan;
1595
2440
  // bounded: a tool that inlines its values sends a new text per value
1596
2441
  if (this.duckPlans.size >= 2000)
1597
2442
  this.duckPlans.delete(this.duckPlans.keys().next().value);
1598
2443
  this.duckPlans.set(key, plan);
1599
2444
  return plan;
1600
2445
  }
1601
- tls;
2446
+ /** the run's own listener's door */
2447
+ door;
1602
2448
  catalog;
1603
2449
  lastSchema;
2450
+ lastRoles;
1604
2451
  options;
1605
2452
  constructor(options) {
1606
2453
  this.options = options;
1607
- 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 };
1608
2456
  this.lastSchema = options.schema();
2457
+ this.lastRoles = options.roles?.() ?? NO_ROLES;
1609
2458
  const version = versionText(options.product ?? "Mason", options.masonVersion);
1610
2459
  this.catalog = new PgCatalog(this.lastSchema, {
2460
+ roles: catalogRolesOf(this.lastRoles),
1611
2461
  log: (line) => this.log(line),
1612
2462
  setup: [
1613
2463
  // `version()` says whose Postgres this is; search_path lists pg_catalog AFTER public so the override wins
1614
2464
  `CREATE OR REPLACE FUNCTION public.version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '${version.replaceAll("'", "''")}' $$`,
1615
- // `current_setting('ssl')` says what every session of this listener is (tlsParameters), as SHOW does
1616
- ...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(),
1617
2467
  "SET search_path = public, pg_catalog",
1618
2468
  ...databaseNameSql(options.database),
1619
2469
  ],
@@ -1622,47 +2472,264 @@ class Server {
1622
2472
  });
1623
2473
  }
1624
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
+ }
1625
2492
  /** the clock "now" is read from */
1626
2493
  now() { return (this.options.clock ?? Date.now)(); }
1627
2494
  log(line) { (this.options.log ?? ((l) => console.log(l)))(`[postgres] ${line}`); }
1628
- /** 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`) */
1629
2496
  schema() {
1630
2497
  const s = this.options.schema();
1631
- if (s !== this.lastSchema) {
2498
+ const r = this.options.roles?.() ?? NO_ROLES;
2499
+ if (s !== this.lastSchema || r !== this.lastRoles) {
1632
2500
  this.lastSchema = s;
1633
- 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)}`));
1634
2503
  }
1635
2504
  return s;
1636
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
+ }
1637
2611
  forget(s) { this.sessions.delete(s.id); }
1638
- 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) {
1639
2616
  const s = this.sessions.get(pid);
1640
- if (s === undefined || s.secret !== secret)
2617
+ if (s === undefined || s.secret !== secret || s.door !== door)
1641
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());
1642
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);
1643
2673
  }
1644
2674
  }
1645
- export async function startPostgresApi(options) {
1646
- 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) {
1647
2677
  const listener = net.createServer((socket) => {
1648
2678
  socket.setNoDelay(true);
1649
- const session = new Session(socket, server);
2679
+ const session = new Session(socket, server, door);
1650
2680
  server.sessions.set(session.id, session);
1651
2681
  });
1652
2682
  await new Promise((resolve, reject) => {
1653
2683
  listener.once("error", reject);
1654
- listener.listen(options.port, options.host, () => { listener.off("error", reject); resolve(); });
2684
+ listener.listen(port, host, () => { listener.off("error", reject); resolve(); });
1655
2685
  });
1656
2686
  listener.on("error", (err) => server.log(`listener error: ${err.message}`));
1657
2687
  const address = listener.address();
1658
- 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);
1659
2696
  return {
1660
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
+ },
1661
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),
1662
2728
  async close() {
1663
2729
  for (const s of [...server.sessions.values()])
1664
2730
  s.end("server closing");
1665
- await new Promise((resolve) => listener.close(() => resolve()));
2731
+ await Promise.all([listener, ...shares].map(closeListener));
2732
+ shares.clear();
1666
2733
  await server.catalog.stop();
1667
2734
  await stopParser();
1668
2735
  },