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