@mason-data/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- 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 +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- 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 +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -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 +12 -4
|
@@ -3,18 +3,25 @@ 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 {
|
|
6
|
+
import { catalogRows } from "../../duck/analyze.js";
|
|
7
|
+
import { ApiError, USER_DISABLED } from "../error.js";
|
|
7
8
|
import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
|
|
9
|
+
import { gapRefusal, StatementError } from "../../duck/statement.js";
|
|
8
10
|
import { canonicalZone } from "../../lib/timezone.js";
|
|
9
|
-
import {
|
|
11
|
+
import { catalogRolesOf, exampleRole, grantRefusal, NO_ROLE_CATALOG, NO_ROLES } from "../../duck/grants.js";
|
|
12
|
+
import { asRole, asSession, CATALOG_BASE_SQL, catalogPolicies, currentSettingSql, databaseNameSql, LATEST_SQL, PgCatalog, unwrapRole, unwrapSession } from "./catalog.js";
|
|
13
|
+
import { wrongDatabaseHint } from "../../lib/database-name.js";
|
|
14
|
+
import { attributeSetting, bindFilter, filterProbe, filterTypes, missingAttributes } from "../../duck/row-filter.js";
|
|
10
15
|
import { EncodeError, OID, fieldOf } from "./encode.js";
|
|
11
16
|
import { numericFromBinary, NumericError } from "../../lib/pg-numeric.js";
|
|
17
|
+
import { instantText, nowMicros } from "../../lib/instant.js";
|
|
12
18
|
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";
|
|
14
|
-
import { typeNameParts } from "./schema.js";
|
|
15
|
-
import { Unsupported, duckTranslate } from "./translate.js";
|
|
19
|
+
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";
|
|
20
|
+
import { madeTypeName, typeNameParts } from "./schema.js";
|
|
21
|
+
import { Unsupported, duckTranslate, endsFailedBlock } from "./translate.js";
|
|
22
|
+
import { clientAddress, PROXY_HEADER_MS, PROXY_SETTING, proxyNames, readProxyHeader, startsProxyHeader } from "./proxy.js";
|
|
16
23
|
/** 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
|
|
24
|
+
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
25
|
/** an EXPLAIN's one column, Postgres's: text, or json for FORMAT JSON (ExplainResultDesc) */
|
|
19
26
|
const explainField = (o, format) => ({ name: "QUERY PLAN", typeOid: o.format === "json" ? 114 : 25, typeSize: -1, typeModifier: -1, format });
|
|
20
27
|
/** a DataRow's values (null for NULL) */
|
|
@@ -43,7 +50,7 @@ function catalogExplained(frames, o) {
|
|
|
43
50
|
return frames;
|
|
44
51
|
const doc = JSON.parse(value.toString("utf8"));
|
|
45
52
|
if (doc[0] !== undefined)
|
|
46
|
-
doc[0].Mason = { "Answered By": "the catalog Postgres (Mason's own, PGlite), not
|
|
53
|
+
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
54
|
return [...frames.slice(0, first), dataRow([Buffer.from(JSON.stringify(doc, null, 2))]), ...frames.slice(first + 1)];
|
|
48
55
|
}
|
|
49
56
|
/** Postgres's names over DuckDB's: each named column renamed, a `*` (null) spanning what DuckDB expanded it to */
|
|
@@ -66,6 +73,15 @@ export function postgresNames(columns, names) {
|
|
|
66
73
|
}
|
|
67
74
|
return at === columns.length ? out : [...columns];
|
|
68
75
|
}
|
|
76
|
+
/** whether any role of the file filters rows (src/duck/row-filter.ts): nothing is checked, bound or keyed where none does */
|
|
77
|
+
const filtersRows = (live) => [...live.grants.values()].some((g) => g.filters !== undefined && g.filters.size > 0);
|
|
78
|
+
/** the row filters a session's role holds, bound to its user's values, as one string: a plan's key and its `rowsKey`;
|
|
79
|
+
* "" where it holds none */
|
|
80
|
+
function rowsKeyOf(g) {
|
|
81
|
+
if (g?.rows === undefined || g.rows.size === 0)
|
|
82
|
+
return "";
|
|
83
|
+
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]))));
|
|
84
|
+
}
|
|
69
85
|
/** a statement_timeout's value in ms, Postgres's units (a bare number is ms; `1.5s`, `5min`), 0 for none; null where
|
|
70
86
|
* it is no duration */
|
|
71
87
|
export function timeoutMs(value) {
|
|
@@ -75,13 +91,6 @@ export function timeoutMs(value) {
|
|
|
75
91
|
const per = { us: 0.001, ms: 1, s: 1000, min: 60_000, h: 3_600_000, d: 86_400_000 }[(m[2] ?? "ms").toLowerCase()];
|
|
76
92
|
return Math.round(Number(m[1]) * per);
|
|
77
93
|
}
|
|
78
|
-
/** A statement's error as Postgres raises it, from outside this module (the DuckDB runner's): the fields, and the SQL
|
|
79
|
-
* its position is in. */
|
|
80
|
-
export class StatementError extends Error {
|
|
81
|
-
fields;
|
|
82
|
-
sql;
|
|
83
|
-
constructor(fields, sql = "") { super(fields.message); this.fields = fields; this.sql = sql; }
|
|
84
|
-
}
|
|
85
94
|
export const SERVER_VERSION = "18.6";
|
|
86
95
|
export const SERVER_VERSION_NUM = "180006";
|
|
87
96
|
/** `version()`'s whole text: `PostgreSQL 18.6` FIRST, since tools parse it (as Redshift and YugabyteDB keep it), then
|
|
@@ -96,10 +105,27 @@ export function versionText(product, version) {
|
|
|
96
105
|
* keeps one for hours — ends at its first statement after this long.
|
|
97
106
|
*/
|
|
98
107
|
export const DEFAULT_RECHECK_MS = 30_000;
|
|
108
|
+
/** How many connections may be logging in on a share's socket at once: more than a pool opens at a time. */
|
|
109
|
+
export const DEFAULT_PENDING_LIMIT = 32;
|
|
110
|
+
/** How long one may take to log in: a tunnel's round trips and a TLS handshake are seconds, Postgres allows 60. */
|
|
111
|
+
export const DEFAULT_PENDING_MS = 15_000;
|
|
112
|
+
/** EACH STATEMENT'S NUMBER IN THIS RUN, by the controller that runs it — the thing a cancel aborts, so a cursor's is one
|
|
113
|
+
* number across the Executes that page its rows — given when first asked (`runningNow`), never reused */
|
|
114
|
+
const statementIds = new WeakMap();
|
|
115
|
+
let nextStatementId = 1;
|
|
116
|
+
function statementIdOf(c) {
|
|
117
|
+
let id = statementIds.get(c);
|
|
118
|
+
if (id === undefined) {
|
|
119
|
+
id = nextStatementId++;
|
|
120
|
+
statementIds.set(c, id);
|
|
121
|
+
}
|
|
122
|
+
return id;
|
|
123
|
+
}
|
|
99
124
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
100
125
|
// bytes
|
|
101
126
|
/** the type byte of a RowDescription frame */
|
|
102
127
|
const ROW_DESCRIPTION = 0x54;
|
|
128
|
+
const DATA_ROW = 0x44;
|
|
103
129
|
/** the first byte of a TLS handshake record (a ClientHello): never a startup packet's, whose length's high byte is 0
|
|
104
130
|
* under MAX_STARTUP_LENGTH, and never SSLRequest's (length 8) */
|
|
105
131
|
const TLS_HANDSHAKE = 0x16;
|
|
@@ -112,10 +138,13 @@ class PgError extends Error {
|
|
|
112
138
|
fields;
|
|
113
139
|
/** the HTTP-like status the request log records (400 for a refusal, 401 a refused login, 500 Mason's failure) */
|
|
114
140
|
status;
|
|
115
|
-
|
|
141
|
+
/** the registry entry that refused it (StatementError.gap): the request log's `gap` */
|
|
142
|
+
gap;
|
|
143
|
+
constructor(fields, status = 400, gap = null) {
|
|
116
144
|
super(fields.message);
|
|
117
145
|
this.fields = { severity: "ERROR", ...fields };
|
|
118
146
|
this.status = status;
|
|
147
|
+
this.gap = gap;
|
|
119
148
|
}
|
|
120
149
|
}
|
|
121
150
|
/** The SQLSTATE an ApiError's status is told as (a refused login's, src/duck/users.ts). */
|
|
@@ -134,12 +163,14 @@ function sqlstateOf(status, code) {
|
|
|
134
163
|
}
|
|
135
164
|
}
|
|
136
165
|
/**
|
|
137
|
-
* A refused or failed statement as its log line (`Session.refuse`):
|
|
138
|
-
*
|
|
139
|
-
*
|
|
166
|
+
* A refused or failed statement as its log line (`Session.refuse`): its outcome (src/api/request-log.ts outcomeOf) —
|
|
167
|
+
* `cancelled` for 57014 (a cancel or a timeout), `failed` where the fault is Mason's or the machine's (SQLSTATE classes
|
|
168
|
+
* XX internal, 53 resources, 57, 58 system), `refused` for the statement's own; the detail and the hint where there
|
|
169
|
+
* are, and the statement on one line, as the answered lines carry it.
|
|
140
170
|
*/
|
|
141
171
|
export function refusalLine(session, f, sql) {
|
|
142
|
-
const
|
|
172
|
+
const o = outcomeOf(f.code);
|
|
173
|
+
const word = o === "ok" ? "refused" : o;
|
|
143
174
|
const statement = sql.replace(/\s+/g, " ").trim();
|
|
144
175
|
return `session ${session} ${word}: ${f.code} ${f.message}${f.detail ? `; detail: ${f.detail}` : ""}${f.hint ? `; hint: ${f.hint}` : ""}` +
|
|
145
176
|
` — ${statement === "" ? "(no statement)" : statement}`;
|
|
@@ -148,9 +179,9 @@ function asPgError(err, sql) {
|
|
|
148
179
|
if (err instanceof PgError)
|
|
149
180
|
return err;
|
|
150
181
|
if (err instanceof StatementError)
|
|
151
|
-
return new PgError({ ...err.fields, position: err.fields.position ?? undefined }, err.fields.code.startsWith("XX") ? 500 : 400);
|
|
182
|
+
return new PgError({ ...err.fields, position: err.fields.position ?? undefined }, err.fields.code.startsWith("XX") ? 500 : 400, err.gap);
|
|
152
183
|
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" });
|
|
184
|
+
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
185
|
}
|
|
155
186
|
if (err instanceof PostgresSyntaxError)
|
|
156
187
|
return new PgError({ code: "42601", message: err.message, position: positionOf(sql, err.offset) });
|
|
@@ -158,11 +189,11 @@ function asPgError(err, sql) {
|
|
|
158
189
|
return new PgError({ code: sqlstateOf(err.status, err.code), message: err.message, detail: `Mason: ${err.code}` }, err.status);
|
|
159
190
|
// Postgres's own error for a numeric (a division by zero, 22012; a field overflow, 22003), raised as Postgres raises it
|
|
160
191
|
if (err instanceof NumericError)
|
|
161
|
-
return new PgError({ code: err.sqlstate, message: err.message }, err.sqlstate === "XX000" ? 500 : 400);
|
|
192
|
+
return new PgError({ code: err.sqlstate, message: err.message, ...(err.detail === null ? {} : { detail: err.detail }) }, err.sqlstate === "XX000" ? 500 : 400, err.gap);
|
|
162
193
|
if (err instanceof EncodeError)
|
|
163
194
|
return err.code === "XX000"
|
|
164
195
|
? 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);
|
|
196
|
+
: new PgError({ code: err.code, message: err.message }, 400, err.gap);
|
|
166
197
|
if (err instanceof ProtocolError)
|
|
167
198
|
return new PgError({ code: "08P01", message: err.message }, 500);
|
|
168
199
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -175,33 +206,71 @@ const REPORTED = new Set(["application_name", "client_encoding", "datestyle", "i
|
|
|
175
206
|
/** Postgres's spelling of the parameters it reports, for the ParameterStatus name. */
|
|
176
207
|
const SPELLING = { datestyle: "DateStyle", intervalstyle: "IntervalStyle", timezone: "TimeZone" };
|
|
177
208
|
const spell = (name) => SPELLING[name] ?? name;
|
|
178
|
-
const defaultVars = () => ({ timeZone: "UTC", applicationName: "", searchPath: '"$user", public', other: new Map() });
|
|
179
|
-
|
|
180
|
-
|
|
209
|
+
const defaultVars = () => ({ timeZone: "UTC", dateStyle: "ISO, MDY", applicationName: "", searchPath: '"$user", public', other: new Map() });
|
|
210
|
+
const copyVars = (v) => ({ ...v, other: new Map(v.other) });
|
|
211
|
+
/** a generated table's plan is made under no session: UTC, under `public`, ISO with month-day-year */
|
|
212
|
+
const GENERATED_SESSION = { timeZone: "UTC", searchPath: "public", dateStyle: "ISO, MDY" };
|
|
213
|
+
/** One parameter's value carried from one set of values to another, from where `setParameter` keeps it. */
|
|
214
|
+
function copySetting(name, from, to) {
|
|
181
215
|
switch (name) {
|
|
182
|
-
case "
|
|
183
|
-
case "
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
case "datestyle":
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
case "application_name":
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
case "
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
216
|
+
case "timezone":
|
|
217
|
+
case "time zone":
|
|
218
|
+
to.timeZone = from.timeZone;
|
|
219
|
+
return;
|
|
220
|
+
case "datestyle":
|
|
221
|
+
to.dateStyle = from.dateStyle;
|
|
222
|
+
return;
|
|
223
|
+
case "application_name":
|
|
224
|
+
to.applicationName = from.applicationName;
|
|
225
|
+
return;
|
|
226
|
+
case "search_path":
|
|
227
|
+
to.searchPath = from.searchPath;
|
|
228
|
+
return;
|
|
204
229
|
}
|
|
230
|
+
const key = SETTING_KEY[name] ?? name;
|
|
231
|
+
const value = from.other.get(key);
|
|
232
|
+
if (value === undefined)
|
|
233
|
+
to.other.delete(key);
|
|
234
|
+
else
|
|
235
|
+
to.other.set(key, value);
|
|
236
|
+
}
|
|
237
|
+
/** the parameter a SET of another name moves: SET TRANSACTION and SET SESSION CHARACTERISTICS are Postgres's spellings of
|
|
238
|
+
* transaction_isolation and default_transaction_isolation (translate.ts setStatement) */
|
|
239
|
+
const SETTING_KEY = { transaction: "transaction_isolation", "session characteristics": "default_transaction_isolation" };
|
|
240
|
+
/** the isolation levels Postgres knows, as SHOW answers them */
|
|
241
|
+
const ISOLATION_LEVELS = ["serializable", "repeatable read", "read committed", "read uncommitted"];
|
|
242
|
+
/** the one refusal of a DateStyle that is Mason's own: an output style other than ISO (its value as the catalog
|
|
243
|
+
* Postgres canonicalised it, `German, DMY`), which this API never writes */
|
|
244
|
+
function isoDateStyle(canonical, value) {
|
|
245
|
+
if (!/^ISO,/.test(canonical))
|
|
246
|
+
throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
|
|
247
|
+
return canonical;
|
|
248
|
+
}
|
|
249
|
+
/** A placeholder's name as Postgres's `valid_custom_variable_name` reads it: two or more simple identifiers, dotted. */
|
|
250
|
+
function placeholderName(name) {
|
|
251
|
+
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))
|
|
252
|
+
return;
|
|
253
|
+
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." });
|
|
254
|
+
}
|
|
255
|
+
/** What `SHOW` / `current_setting` answer for the parameters this server fixes, by name: read by `fixedParameter` alone. */
|
|
256
|
+
const FIXED = new Map([
|
|
257
|
+
["server_version", () => SERVER_VERSION], ["server_version_num", () => SERVER_VERSION_NUM],
|
|
258
|
+
["server_encoding", () => "UTF8"], ["client_encoding", () => "UTF8"],
|
|
259
|
+
["datestyle", (v) => v.dateStyle], ["intervalstyle", () => "postgres"],
|
|
260
|
+
["timezone", (v) => v.timeZone], ["application_name", (v) => v.applicationName], ["search_path", (v) => v.searchPath],
|
|
261
|
+
...["integer_datetimes", "standard_conforming_strings", "default_transaction_read_only", "transaction_read_only", "in_hot_standby"].map((n) => [n, () => "on"]),
|
|
262
|
+
["is_superuser", () => "off"],
|
|
263
|
+
// the session's user, and no role SET over it (SET ROLE is its own name or NONE, setStatement): Postgres's `none`
|
|
264
|
+
["session_authorization", (_v, user) => user], ["role", () => "none"],
|
|
265
|
+
// what SET TRANSACTION and SET SESSION CHARACTERISTICS said, remembered (`setParameter`): nothing is ever written, so
|
|
266
|
+
// every level answers the same rows
|
|
267
|
+
["transaction_isolation", (v) => v.other.get("transaction_isolation") ?? v.other.get("default_transaction_isolation") ?? "read committed"],
|
|
268
|
+
["default_transaction_isolation", (v) => v.other.get("default_transaction_isolation") ?? "read committed"],
|
|
269
|
+
// remembered: every float is sent as the shortest text that reads back exactly, Postgres's output at 1 and above
|
|
270
|
+
["extra_float_digits", (v) => v.other.get("extra_float_digits") ?? "1"], ["max_identifier_length", () => "63"],
|
|
271
|
+
]);
|
|
272
|
+
function fixedParameter(name, vars, user) {
|
|
273
|
+
return FIXED.get(name)?.(vars, user) ?? null;
|
|
205
274
|
}
|
|
206
275
|
/**
|
|
207
276
|
* WHAT `SHOW` / `current_setting` SAY ABOUT TLS (`SHOW ssl` once said `off` on a TLS session, the catalog
|
|
@@ -219,18 +288,44 @@ export function tlsParameters(on) {
|
|
|
219
288
|
ssl_ciphers: tls.DEFAULT_CIPHERS, ssl_groups: tls.DEFAULT_ECDH_CURVE, ssl_prefer_server_ciphers: "off",
|
|
220
289
|
};
|
|
221
290
|
}
|
|
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
|
-
}
|
|
291
|
+
/** Postgres's answer to anything but a failed block's end */
|
|
292
|
+
const aborted = () => new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
|
|
232
293
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
233
294
|
// one connection
|
|
295
|
+
/** A name as Postgres takes one from a startup packet: its first 63 bytes (NAMEDATALEN - 1). */
|
|
296
|
+
const nameLength = (name) => name === undefined ? undefined : Buffer.from(name, "utf8").subarray(0, 63).toString("utf8");
|
|
297
|
+
/** The hint a refused login is told on the run's own listener. */
|
|
298
|
+
const USER_LOGIN_HINT = "a tool that keeps one password connects as a user: mason user add <name> prints its password and URL";
|
|
299
|
+
/**
|
|
300
|
+
* THE CONNECTIONS STILL LOGGING IN ON A PUBLIC SOCKET (`ShareDoor`): at most `limit`, each for at most `ms`, the oldest
|
|
301
|
+
* making way for a new one. A limit that refused the NEW connection would hand the socket to whoever opened `limit`
|
|
302
|
+
* connections first and sat on them, which is the attack; every connection through a tunnel comes from 127.0.0.1, so
|
|
303
|
+
* a limit per source cannot exist.
|
|
304
|
+
*/
|
|
305
|
+
class LoginQueue {
|
|
306
|
+
waiting = new Map();
|
|
307
|
+
limit;
|
|
308
|
+
ms;
|
|
309
|
+
dropped;
|
|
310
|
+
constructor(limit, ms, dropped) { this.limit = limit; this.ms = ms; this.dropped = dropped; }
|
|
311
|
+
admit(s) {
|
|
312
|
+
if (this.waiting.size >= this.limit) {
|
|
313
|
+
const oldest = this.waiting.keys().next().value;
|
|
314
|
+
this.dropped?.("made way for a newer connection");
|
|
315
|
+
oldest.end("made way for a newer connection");
|
|
316
|
+
}
|
|
317
|
+
const timer = setTimeout(() => { this.dropped?.("did not log in in time"); s.end("did not log in in time"); }, this.ms);
|
|
318
|
+
timer.unref();
|
|
319
|
+
this.waiting.set(s, timer);
|
|
320
|
+
}
|
|
321
|
+
settled(s) {
|
|
322
|
+
const timer = this.waiting.get(s);
|
|
323
|
+
if (timer === undefined)
|
|
324
|
+
return;
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
this.waiting.delete(s);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
234
329
|
let nextSessionId = 1;
|
|
235
330
|
class Session {
|
|
236
331
|
id = nextSessionId++;
|
|
@@ -241,13 +336,32 @@ class Session {
|
|
|
241
336
|
user = "";
|
|
242
337
|
token = null;
|
|
243
338
|
presented = null;
|
|
244
|
-
/**
|
|
339
|
+
/** the database a public socket's startup named that is not this one: refused once the login is good */
|
|
340
|
+
wrongDatabase = null;
|
|
341
|
+
/** when the user was last found live, in microseconds as the session's clock (a user's session is asked again,
|
|
342
|
+
* `recheckMs`) */
|
|
245
343
|
verifiedAt = 0;
|
|
246
344
|
vars = defaultVars();
|
|
345
|
+
/** the session's parameters as its startup left them (its packet's `application_name` and `options`): what RESET restores */
|
|
346
|
+
startupVars = defaultVars();
|
|
347
|
+
/** a startup packet's DateStyle, canonicalised by the catalog Postgres once the password is read (`password`) */
|
|
348
|
+
startupDateStyle = null;
|
|
349
|
+
/** each reported parameter's value as the client was last told it (`reportChanges`) */
|
|
350
|
+
reported = new Map();
|
|
247
351
|
tx = "none";
|
|
248
|
-
/**
|
|
352
|
+
/**
|
|
353
|
+
* WHAT THE TRANSACTION WOULD LEAVE OF THE SESSION'S SETTINGS, from its first SET: `rollback`, the values it found, and
|
|
354
|
+
* `commit`, those with its SETs and none of its LOCALs. Neither is ever read for an answer: `vars` is what is in force,
|
|
355
|
+
* and `endTransaction` replaces it with one of these. Null: nothing was set since the transaction began.
|
|
356
|
+
*/
|
|
357
|
+
settingsTx = null;
|
|
358
|
+
/** the statement runs in an IMPLICIT transaction block, as Postgres's IsTransactionBlock reads one: a Query of several
|
|
359
|
+
* statements, or a Sync's batch once an Execute completed (`extended`). A plain transaction of one statement is none */
|
|
360
|
+
implicitBlock = false;
|
|
361
|
+
/** the instant the transaction began (Postgres's `now()`), in microseconds since the epoch (src/lib/instant.ts),
|
|
362
|
+
* null between transactions */
|
|
249
363
|
txNow = null;
|
|
250
|
-
/** the instant of the statement being run (`statement_timestamp()`): its Query's arrival or its Bind */
|
|
364
|
+
/** the instant of the statement being run (`statement_timestamp()`), in microseconds: its Query's arrival or its Bind */
|
|
251
365
|
statementNow = 0;
|
|
252
366
|
statements = new Map();
|
|
253
367
|
portals = new Map();
|
|
@@ -255,6 +369,8 @@ class Session {
|
|
|
255
369
|
skipToSync = false;
|
|
256
370
|
/** the query running now, for a CancelRequest and the socket closing */
|
|
257
371
|
running = null;
|
|
372
|
+
/** the bytes written to the client so far: a message's are this less its mark (`Asked.sentAt`; `bytesFor`) */
|
|
373
|
+
sentBytes = 0;
|
|
258
374
|
/** this session's DuckDB connection, opened at its first statement over a table */
|
|
259
375
|
duckRunner = null;
|
|
260
376
|
/** Closes the session's DuckDB connection, when it has one: only ever with no statement running on it (`end`, `settled`). */
|
|
@@ -263,7 +379,7 @@ class Session {
|
|
|
263
379
|
return;
|
|
264
380
|
const runner = this.duckRunner;
|
|
265
381
|
this.duckRunner = null;
|
|
266
|
-
runner.close().catch((e) => { this.server.log(`session ${this.id}: closing its
|
|
382
|
+
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
383
|
}
|
|
268
384
|
/** A statement ended, however: the session's next one may run, and a session closed meanwhile closes its connection now. */
|
|
269
385
|
settled() {
|
|
@@ -280,21 +396,58 @@ class Session {
|
|
|
280
396
|
sniffed = false;
|
|
281
397
|
closedBy = null;
|
|
282
398
|
server;
|
|
283
|
-
|
|
399
|
+
/** the socket it arrived on: how it may start, and who may log in */
|
|
400
|
+
door;
|
|
401
|
+
/**
|
|
402
|
+
* THE CLIENT'S ADDRESS (proxy.ts `clientAddress`, its one reader): the socket's peer, or the source the PROXY header
|
|
403
|
+
* of a proxy MASON_PG_PROXY_FROM names carries, once read; null on a public socket (a share's: its peer is the
|
|
404
|
+
* tunnel's, never the client's) and where the peer could not be read. The request log's `client_addr` and the log's
|
|
405
|
+
* connect line read it.
|
|
406
|
+
*/
|
|
407
|
+
clientAddr;
|
|
408
|
+
/** the PROXY header a named proxy's connection begins with, while it is read: its bytes so far and its deadline */
|
|
409
|
+
proxyHead = null;
|
|
410
|
+
/** a named proxy's header was read: a second one is refused as one */
|
|
411
|
+
proxied = false;
|
|
412
|
+
constructor(socket, server, door) {
|
|
284
413
|
this.server = server;
|
|
414
|
+
this.door = door;
|
|
285
415
|
this.socket = socket;
|
|
416
|
+
this.clientAddr = door.public ? null : clientAddress(socket.remoteAddress, null);
|
|
417
|
+
if (door.proxyFrom !== null) {
|
|
418
|
+
const peer = clientAddress(socket.remoteAddress, null);
|
|
419
|
+
if (peer === null) {
|
|
420
|
+
// fail closed: an address that cannot be read cannot be told from a named proxy's
|
|
421
|
+
this.server.log(`session ${this.id} refused: its address could not be read, so whether ${PROXY_SETTING} names it could not be told`);
|
|
422
|
+
process.nextTick(() => this.end("its address could not be read"));
|
|
423
|
+
}
|
|
424
|
+
else if (proxyNames(door.proxyFrom, peer)) {
|
|
425
|
+
const timer = setTimeout(() => this.proxyRefused(`no whole PROXY protocol v2 header in ${PROXY_HEADER_MS / 1000} s`), PROXY_HEADER_MS);
|
|
426
|
+
timer.unref();
|
|
427
|
+
this.proxyHead = { bytes: Buffer.alloc(0), timer };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
286
430
|
this.attach(socket);
|
|
431
|
+
door.logins?.admit(this);
|
|
287
432
|
}
|
|
288
433
|
attach(socket) {
|
|
289
|
-
socket.on("data", (
|
|
290
|
-
//
|
|
291
|
-
|
|
434
|
+
socket.on("data", (data) => {
|
|
435
|
+
// a named proxy's PROXY header comes first, before anything else on the connection is looked at
|
|
436
|
+
const chunk = this.proxyHead === null ? data : this.readProxy(data);
|
|
437
|
+
if (chunk === null || chunk.length === 0)
|
|
438
|
+
return;
|
|
439
|
+
// the connection's first byte says how it starts: a TLS ClientHello is TLS at once (`directTls`), a PROXY header
|
|
440
|
+
// where none is believed is refused, anything else the protocol's startup phase, SSLRequest included
|
|
292
441
|
if (!this.sniffed) {
|
|
293
442
|
this.sniffed = true;
|
|
294
443
|
if (chunk[0] === TLS_HANDSHAKE) {
|
|
295
444
|
this.directTls(chunk);
|
|
296
445
|
return;
|
|
297
446
|
}
|
|
447
|
+
if (startsProxyHeader(chunk[0])) {
|
|
448
|
+
this.unbelievedProxy();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
298
451
|
}
|
|
299
452
|
this.decoder.push(chunk);
|
|
300
453
|
void this.drain();
|
|
@@ -307,14 +460,69 @@ class Session {
|
|
|
307
460
|
sendAll(parts) {
|
|
308
461
|
if (this.phase === "closed")
|
|
309
462
|
return;
|
|
310
|
-
|
|
463
|
+
const bytes = Buffer.concat(parts);
|
|
464
|
+
this.sentBytes += bytes.length;
|
|
465
|
+
this.socket.write(bytes);
|
|
466
|
+
}
|
|
467
|
+
/** The statement running now (RunningStatement), or null: one DuckDB runs or sends, so a cancel would stop it. */
|
|
468
|
+
runningNow(now) {
|
|
469
|
+
if (this.running === null || this.asked === null || this.phase !== "ready")
|
|
470
|
+
return null;
|
|
471
|
+
const stored = storedPayload(this.asked.sql);
|
|
472
|
+
return { session: this.id, statement: statementIdOf(this.running), user: this.token?.user ?? "", applicationName: this.vars.applicationName, method: this.asked.method,
|
|
473
|
+
startedAt: new Date(this.asked.startedAt).toISOString(), runningMs: now - this.asked.startedAt, rows: this.asked.rows ?? 0,
|
|
474
|
+
query: stored.text, queryTruncated: stored.truncated };
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The PROXY header of a named proxy's connection, as its bytes arrive (proxy.ts `readProxyHeader`): null while it is
|
|
478
|
+
* not whole or once it is refused, else the bytes after it, which are the client's. Bounded: past its 16 fixed bytes
|
|
479
|
+
* the header says its length, and one past MAX_PROXY_BODY is refused there.
|
|
480
|
+
*/
|
|
481
|
+
readProxy(data) {
|
|
482
|
+
const head = this.proxyHead;
|
|
483
|
+
head.bytes = head.bytes.length === 0 ? data : Buffer.concat([head.bytes, data]);
|
|
484
|
+
const read = readProxyHeader(head.bytes);
|
|
485
|
+
if ("more" in read)
|
|
486
|
+
return null;
|
|
487
|
+
clearTimeout(head.timer);
|
|
488
|
+
this.proxyHead = null;
|
|
489
|
+
if ("refused" in read) {
|
|
490
|
+
this.proxyRefused(read.refused);
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
this.clientAddr = clientAddress(this.socket.remoteAddress, read.header);
|
|
494
|
+
this.proxied = true;
|
|
495
|
+
return head.bytes.subarray(read.length);
|
|
496
|
+
}
|
|
497
|
+
/** A named proxy's connection that did not begin with a valid PROXY v2 header: refused, said in the log with why. */
|
|
498
|
+
proxyRefused(why) {
|
|
499
|
+
if (this.phase === "closed")
|
|
500
|
+
return;
|
|
501
|
+
this.server.log(`session ${this.id} refused: ${why}, from ${this.clientAddr ?? "an address that could not be read"}, which ${PROXY_SETTING} names, ` +
|
|
502
|
+
`so it must begin with one — set the proxy to send the PROXY protocol v2 (HAProxy: send-proxy-v2), or take its address out of ${PROXY_SETTING} (docs/POSTGRES-API.md "Behind a TCP proxy")`);
|
|
503
|
+
this.fatal("08P01", "the connection did not begin with a valid PROXY protocol v2 header");
|
|
504
|
+
}
|
|
505
|
+
/** A PROXY header where none is believed: from an address MASON_PG_PROXY_FROM does not name, or a second one. Refused,
|
|
506
|
+
* never read: a header is a claim about who connected, and only a named proxy may make it. */
|
|
507
|
+
unbelievedProxy() {
|
|
508
|
+
// a share's socket is public: a stranger's bytes write no line of the log there
|
|
509
|
+
if (!this.door.public)
|
|
510
|
+
this.server.log(`session ${this.id} refused: a PROXY protocol header from ${this.clientAddr ?? "an address that is not shown"}, ` +
|
|
511
|
+
(this.proxied ? "after the header its proxy sent: one header a connection" : this.door.proxyFrom === null ? "and this listener believes none" : `which ${PROXY_SETTING} does not name`) +
|
|
512
|
+
` — a header is believed only from the addresses ${PROXY_SETTING} names: the proxy's own (docs/POSTGRES-API.md "Behind a TCP proxy")`);
|
|
513
|
+
this.fatal("08P01", "a PROXY protocol header, which this server takes only from the addresses of its own proxy");
|
|
311
514
|
}
|
|
312
515
|
end(why) {
|
|
313
516
|
if (this.phase === "closed")
|
|
314
517
|
return;
|
|
315
518
|
const wasReady = this.phase === "ready";
|
|
316
519
|
this.phase = "closed";
|
|
520
|
+
if (this.proxyHead !== null) {
|
|
521
|
+
clearTimeout(this.proxyHead.timer);
|
|
522
|
+
this.proxyHead = null;
|
|
523
|
+
}
|
|
317
524
|
this.closedBy = why;
|
|
525
|
+
this.door.logins?.settled(this);
|
|
318
526
|
if (wasReady)
|
|
319
527
|
this.server.log(`session ${this.id} closed: ${why}`);
|
|
320
528
|
this.running?.abort();
|
|
@@ -382,20 +590,24 @@ class Session {
|
|
|
382
590
|
this.server.log(`trace ${this.id} ${JSON.stringify(this.vars.applicationName)} ${m.type}: ${JSON.stringify(m.sql)}`);
|
|
383
591
|
}
|
|
384
592
|
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() };
|
|
593
|
+
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
594
|
}
|
|
387
595
|
// 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
596
|
// keep-alive, its catalog reads, SET and SHOW go on for ever after a revocation)
|
|
389
597
|
if ((m.type === "Query" || m.type === "Parse" || m.type === "Execute") && !(m.type !== "Query" && this.skipToSync)) {
|
|
390
598
|
const refused = await this.stillLive(m.type);
|
|
599
|
+
if (this.phase === "closed")
|
|
600
|
+
return;
|
|
601
|
+
// the roles' row filters checked by the catalog Postgres for the roles as they are now, before any statement is
|
|
602
|
+
// held to them (Server.checkRoles: once per roles file and copied tables; nothing to do where no role filters rows)
|
|
603
|
+
await this.server.checkRoles();
|
|
391
604
|
if (this.phase === "closed")
|
|
392
605
|
return;
|
|
393
606
|
if (refused !== null) {
|
|
394
607
|
this.refuse(refused, m.type === "Execute" ? this.sqlOf(m) : m.sql);
|
|
395
|
-
|
|
396
|
-
this.tx = "failed";
|
|
608
|
+
this.abortTransaction();
|
|
397
609
|
if (m.type === "Query")
|
|
398
|
-
this.send(
|
|
610
|
+
this.send(...this.ready());
|
|
399
611
|
else
|
|
400
612
|
this.skipToSync = true;
|
|
401
613
|
return;
|
|
@@ -433,19 +645,19 @@ class Session {
|
|
|
433
645
|
startup(m) {
|
|
434
646
|
switch (m.type) {
|
|
435
647
|
case "SSLRequest": {
|
|
436
|
-
if (this.
|
|
648
|
+
if (this.door.tls === null) {
|
|
437
649
|
this.send(sslResponse(false));
|
|
438
650
|
return;
|
|
439
651
|
}
|
|
440
652
|
this.send(sslResponse(true));
|
|
441
|
-
this.startTls(this.
|
|
653
|
+
this.startTls(this.door.tls, null);
|
|
442
654
|
return;
|
|
443
655
|
}
|
|
444
656
|
case "GSSENCRequest":
|
|
445
657
|
this.send(sslResponse(false));
|
|
446
658
|
return;
|
|
447
659
|
case "CancelRequest": {
|
|
448
|
-
this.server.cancel(m.pid, m.secret);
|
|
660
|
+
this.server.cancel(m.pid, m.secret, this.door);
|
|
449
661
|
this.end("cancel request");
|
|
450
662
|
return;
|
|
451
663
|
}
|
|
@@ -454,11 +666,12 @@ class Session {
|
|
|
454
666
|
this.fatal("0A000", `unsupported frontend protocol ${m.major}.${m.minor}: server supports 3.0`);
|
|
455
667
|
return;
|
|
456
668
|
}
|
|
457
|
-
if (this.
|
|
669
|
+
if (this.door.tls !== null && !(this.socket instanceof tls.TLSSocket)) {
|
|
458
670
|
this.fatal("28000", "this server requires TLS: the password must not travel in clear (connect with sslmode=require)");
|
|
459
671
|
return;
|
|
460
672
|
}
|
|
461
|
-
|
|
673
|
+
// a public socket's refused logins are rows of the request log: what a stranger names is a name's length there
|
|
674
|
+
const user = this.door.public ? nameLength(m.params.user) : m.params.user;
|
|
462
675
|
if (user === undefined || user === "") {
|
|
463
676
|
this.fatal("28000", "no PostgreSQL user name specified in startup packet");
|
|
464
677
|
return;
|
|
@@ -466,8 +679,13 @@ class Session {
|
|
|
466
679
|
this.user = user;
|
|
467
680
|
const database = m.params.database ?? user;
|
|
468
681
|
if (database !== this.server.database) {
|
|
469
|
-
|
|
470
|
-
|
|
682
|
+
// a share's socket is public: refused here, a wrong name and the right one would answer apart before any
|
|
683
|
+
// password, so a stranger could guess the project's. It is refused after the login, as Postgres does
|
|
684
|
+
if (!this.door.public) {
|
|
685
|
+
this.fatal("3D000", `database "${database}" does not exist`, wrongDatabaseHint(this.server.database));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.wrongDatabase = database;
|
|
471
689
|
}
|
|
472
690
|
const encoding = m.params.client_encoding;
|
|
473
691
|
if (encoding !== undefined && !isUtf8(encoding)) {
|
|
@@ -475,7 +693,7 @@ class Session {
|
|
|
475
693
|
return;
|
|
476
694
|
}
|
|
477
695
|
if (m.params.application_name !== undefined)
|
|
478
|
-
this.vars.applicationName = m.params.application_name;
|
|
696
|
+
this.vars.applicationName = (this.door.public ? nameLength(m.params.application_name) : m.params.application_name);
|
|
479
697
|
try {
|
|
480
698
|
if (m.params.options !== undefined)
|
|
481
699
|
this.commandLineOptions(m.params.options);
|
|
@@ -492,6 +710,8 @@ class Session {
|
|
|
492
710
|
this.fatal(e.fields.code, e.message, e.fields.hint);
|
|
493
711
|
return;
|
|
494
712
|
}
|
|
713
|
+
// what RESET and RESET ALL restore: the startup packet's values beside the defaults
|
|
714
|
+
this.startupVars = copyVars(this.vars);
|
|
495
715
|
this.phase = "password";
|
|
496
716
|
this.send(authenticationCleartextPassword());
|
|
497
717
|
return;
|
|
@@ -527,14 +747,14 @@ class Session {
|
|
|
527
747
|
* and this listener has no certificate to send), so it is refused in the log, naming the way out, and closed.
|
|
528
748
|
*/
|
|
529
749
|
directTls(first) {
|
|
530
|
-
if (this.
|
|
750
|
+
if (this.door.tls === null) {
|
|
531
751
|
this.server.log(`session ${this.id} refused: a direct TLS start (a TLS ClientHello as its first bytes: sslnegotiation=direct, or a TLS ` +
|
|
532
752
|
`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
753
|
`in front and send Mason the plain protocol (docs/POSTGRES-API.md "Connecting")`);
|
|
534
754
|
this.end("a direct TLS start on a plain listener");
|
|
535
755
|
return;
|
|
536
756
|
}
|
|
537
|
-
this.startTls(this.
|
|
757
|
+
this.startTls(this.door.tls, first);
|
|
538
758
|
}
|
|
539
759
|
/** libpq's `options=-c name=value …` (JDBC's `options`): each a SET before the session starts */
|
|
540
760
|
commandLineOptions(options) {
|
|
@@ -555,25 +775,54 @@ class Session {
|
|
|
555
775
|
}
|
|
556
776
|
let token;
|
|
557
777
|
try {
|
|
558
|
-
token = await this.
|
|
778
|
+
token = await this.door.authenticate(m.password, this.user);
|
|
559
779
|
}
|
|
560
780
|
catch (err) {
|
|
561
781
|
const message = err instanceof Error ? err.message : String(err);
|
|
562
782
|
// a revocation record that could not be read is no wrong password: the server could not tell, said as such
|
|
563
783
|
const unknown = err instanceof ApiError && err.status === 503;
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
784
|
+
// a disabled user (`mason user disable`): its password was right, so it is no wrong password but a role that may
|
|
785
|
+
// not log in, which Postgres refuses 28000 "role … is not permitted to log in": that sentence alone, why in DETAIL,
|
|
786
|
+
// the way out in HINT, as Postgres splits them
|
|
787
|
+
const disabled = err instanceof ApiError && err.code === USER_DISABLED;
|
|
788
|
+
const refused = disabled
|
|
789
|
+
? 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)
|
|
790
|
+
: new PgError({ code: unknown ? "57P03" : "28P01",
|
|
791
|
+
message: unknown ? `the password could not be checked for user "${this.user}": ${message}` : `password authentication failed for user "${this.user}": ${message}`,
|
|
792
|
+
hint: unknown ? "connect again once Mason can read its list of users"
|
|
793
|
+
: this.door.loginHint }, 401);
|
|
568
794
|
// 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.
|
|
795
|
+
this.asked = { sql: "", method: "CONNECT", startedAt: Date.now(), sentAt: this.sentBytes };
|
|
796
|
+
const response = errorResponse({ ...refused.fields, severity: "FATAL" });
|
|
797
|
+
if (this.door.refused?.(this.user) !== false)
|
|
798
|
+
this.recordRequest(refused, undefined, err instanceof ApiError ? err.userName : undefined, [response]);
|
|
799
|
+
this.send(response);
|
|
572
800
|
this.end("authentication failed");
|
|
573
801
|
return;
|
|
574
802
|
}
|
|
575
803
|
if (this.phase !== "password")
|
|
576
804
|
return; // the socket closed while the ledger was read
|
|
805
|
+
this.door.logins?.settled(this);
|
|
806
|
+
if (this.wrongDatabase !== null) {
|
|
807
|
+
this.fatal("3D000", `database "${this.wrongDatabase}" does not exist`, wrongDatabaseHint(this.server.database));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// a startup packet's DateStyle, read as Postgres reads it (`canonicalElsewhere`): refused as the startup's other
|
|
811
|
+
// parameters are, before the session begins
|
|
812
|
+
if (this.startupDateStyle !== null) {
|
|
813
|
+
try {
|
|
814
|
+
const canonical = await this.canonicalElsewhere("datestyle", this.startupDateStyle, this.sessionState().dateStyle);
|
|
815
|
+
this.setParameter("datestyle", isoDateStyle(canonical, this.startupDateStyle));
|
|
816
|
+
this.startupVars = copyVars(this.vars);
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
const e = asPgError(err, "");
|
|
820
|
+
this.fatal(e.fields.code, e.message, e.fields.hint);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (this.phase !== "password")
|
|
824
|
+
return; // the socket closed while the catalog was asked
|
|
825
|
+
}
|
|
577
826
|
this.token = token;
|
|
578
827
|
this.presented = m.password;
|
|
579
828
|
this.verifiedAt = this.server.now();
|
|
@@ -581,28 +830,24 @@ class Session {
|
|
|
581
830
|
this.decoder.maxLength = MAX_MESSAGE_LENGTH; // authenticated: a statement may be as long as the protocol allows
|
|
582
831
|
// what is happening, as it happens (mason logs once showed little): who connected, and (end) who left
|
|
583
832
|
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);
|
|
833
|
+
(token.user !== undefined ? ` (${this.door.who})` : "") + (this.clientAddr !== null ? ` from ${this.clientAddr}` : ""));
|
|
834
|
+
this.sendAll([authenticationOk(), ...this.reportChanges(), backendKeyData(this.id, this.secret), readyForQuery("I")]);
|
|
590
835
|
}
|
|
591
836
|
reportedParameters() {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
837
|
+
// each value `sessionSetting`'s, the one SHOW and SHOW ALL answer: a reported value and a shown one never part
|
|
838
|
+
return [...REPORTED].sort().map((name) => {
|
|
839
|
+
const value = this.sessionSetting(name);
|
|
840
|
+
if (value === null)
|
|
841
|
+
throw new PgError({ code: "XX000", message: `the reported parameter ${name} has no value of this session's (Mason bug: report it)` }, 500);
|
|
842
|
+
return [spell(name), value];
|
|
843
|
+
});
|
|
598
844
|
}
|
|
599
845
|
// ---------------------------------------------------------------- session parameters
|
|
600
846
|
/**
|
|
601
|
-
* SET of one parameter
|
|
602
|
-
*
|
|
847
|
+
* SET of one parameter (`value` null: its default). The parameters this server has an opinion on are decided here;
|
|
848
|
+
* the rest go to the catalog Postgres for validation and are kept. What a client is told of it is `reportChanges`'s.
|
|
603
849
|
*/
|
|
604
850
|
setParameter(name, value, atStartup = false) {
|
|
605
|
-
const report = (n, v) => REPORTED.has(n) ? [parameterStatus(spell(n), v)] : [];
|
|
606
851
|
switch (name) {
|
|
607
852
|
case "timezone":
|
|
608
853
|
case "time zone": {
|
|
@@ -610,48 +855,78 @@ class Session {
|
|
|
610
855
|
if (zone === null)
|
|
611
856
|
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
857
|
this.vars.timeZone = zone;
|
|
613
|
-
return
|
|
858
|
+
return;
|
|
614
859
|
}
|
|
615
860
|
case "application_name":
|
|
616
861
|
this.vars.applicationName = value ?? "";
|
|
617
|
-
return
|
|
862
|
+
return;
|
|
618
863
|
case "search_path":
|
|
619
864
|
this.vars.searchPath = value ?? '"$user", public';
|
|
620
|
-
return
|
|
865
|
+
return;
|
|
621
866
|
case "client_encoding":
|
|
622
867
|
case "names":
|
|
623
868
|
if (value !== null && !isUtf8(value))
|
|
624
869
|
throw new PgError({ code: "22023", message: `invalid value for parameter "client_encoding": "${value}"`, hint: "this API sends UTF8 only" });
|
|
625
|
-
return
|
|
870
|
+
return;
|
|
626
871
|
case "datestyle":
|
|
627
|
-
|
|
628
|
-
|
|
872
|
+
// the value is the catalog Postgres's canonical one (`canonicalElsewhere`): its validity and the order a part not
|
|
873
|
+
// named keeps are Postgres's check_datestyle, never derived here. A startup packet's is read after the password
|
|
874
|
+
// (`startupDateStyle`), where the catalog can be asked
|
|
875
|
+
if (atStartup) {
|
|
876
|
+
this.startupDateStyle = value;
|
|
877
|
+
return;
|
|
629
878
|
}
|
|
630
|
-
|
|
879
|
+
this.vars.dateStyle = value === null ? "ISO, MDY" : isoDateStyle(value, value);
|
|
880
|
+
return;
|
|
631
881
|
case "extra_float_digits": {
|
|
632
882
|
const n = value === null ? 1 : Number(value);
|
|
633
883
|
if (!Number.isInteger(n) || n < -15 || n > 3)
|
|
634
884
|
throw new PgError({ code: "22023", message: `${n} is outside the valid range for parameter "extra_float_digits" (-15 .. 3)` });
|
|
635
885
|
if (n < 1)
|
|
636
886
|
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
|
-
|
|
887
|
+
if (value === null)
|
|
888
|
+
this.vars.other.delete(name);
|
|
889
|
+
else
|
|
890
|
+
this.vars.other.set(name, String(n));
|
|
891
|
+
return;
|
|
638
892
|
}
|
|
639
893
|
case "standard_conforming_strings":
|
|
640
894
|
if (value !== null && !/^(on|true|yes|1)$/i.test(value))
|
|
641
895
|
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":
|
|
896
|
+
return;
|
|
897
|
+
case "intervalstyle": {
|
|
898
|
+
const style = value?.trim().toLowerCase() ?? "postgres";
|
|
899
|
+
if (style === "postgres")
|
|
900
|
+
return;
|
|
901
|
+
if (["postgres_verbose", "sql_standard", "iso_8601"].includes(style)) {
|
|
902
|
+
throw gapRefusal("interval-style", `IntervalStyle ${value} is not supported: every interval is written in Postgres's default style`, "SET IntervalStyle = postgres");
|
|
903
|
+
}
|
|
904
|
+
throw new PgError({ code: "22023", message: `invalid value for parameter "IntervalStyle": "${value}"`, hint: "Available values: postgres, postgres_verbose, sql_standard, iso_8601." });
|
|
905
|
+
}
|
|
644
906
|
case "transaction_isolation":
|
|
645
907
|
case "default_transaction_isolation":
|
|
646
908
|
case "transaction":
|
|
647
|
-
case "session characteristics":
|
|
648
|
-
// read
|
|
649
|
-
|
|
909
|
+
case "session characteristics": {
|
|
910
|
+
// remembered, so SHOW and current_setting read back what was set; a store that is never written answers the same
|
|
911
|
+
// rows at every level. SET TRANSACTION … READ ONLY and the like name no level: nothing to remember
|
|
912
|
+
const key = SETTING_KEY[name] ?? name;
|
|
913
|
+
if (value === null) {
|
|
914
|
+
this.vars.other.delete(key);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const level = value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
918
|
+
if (level === "" && (name === "transaction" || name === "session characteristics"))
|
|
919
|
+
return;
|
|
920
|
+
if (!ISOLATION_LEVELS.includes(level))
|
|
921
|
+
throw new PgError({ code: "22023", message: `invalid value for parameter "${key}": "${value}"`, hint: `Available values: ${ISOLATION_LEVELS.join(", ")}.` });
|
|
922
|
+
this.vars.other.set(key, level);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
650
925
|
case "default_transaction_read_only":
|
|
651
926
|
case "transaction_read_only":
|
|
652
927
|
if (value !== null && /^(off|false|no|0)$/i.test(value))
|
|
653
928
|
throw new PgError({ code: "25006", message: "cannot set transaction read-write mode: this API is read-only" });
|
|
654
|
-
return
|
|
929
|
+
return;
|
|
655
930
|
case "statement_timeout":
|
|
656
931
|
case "lock_timeout":
|
|
657
932
|
case "idle_in_transaction_session_timeout":
|
|
@@ -680,7 +955,7 @@ class Session {
|
|
|
680
955
|
this.vars.other.delete(name);
|
|
681
956
|
else
|
|
682
957
|
this.vars.other.set(name, value);
|
|
683
|
-
return
|
|
958
|
+
return;
|
|
684
959
|
default: {
|
|
685
960
|
// a parameter Mason knows nothing of: the catalog Postgres decides whether it exists
|
|
686
961
|
if (atStartup)
|
|
@@ -689,13 +964,148 @@ class Session {
|
|
|
689
964
|
this.vars.other.delete(name);
|
|
690
965
|
else
|
|
691
966
|
this.vars.other.set(name, value);
|
|
692
|
-
return
|
|
967
|
+
return;
|
|
693
968
|
}
|
|
694
969
|
}
|
|
695
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* RESET of one parameter: the value the session STARTED with — its startup packet's (`application_name`, `options`'s
|
|
973
|
+
* `-c`) where it set one, else the default — as Postgres's RESET restores a parameter's session default. A placeholder
|
|
974
|
+
* (`myapp.x`, a dotted name Postgres does not list) has '' for its default and stays defined, as on Postgres.
|
|
975
|
+
*/
|
|
976
|
+
async resetParameter(name) {
|
|
977
|
+
const start = this.startupVars;
|
|
978
|
+
switch (name) {
|
|
979
|
+
case "timezone":
|
|
980
|
+
case "time zone":
|
|
981
|
+
this.vars.timeZone = start.timeZone;
|
|
982
|
+
return;
|
|
983
|
+
case "datestyle":
|
|
984
|
+
this.vars.dateStyle = start.dateStyle;
|
|
985
|
+
return;
|
|
986
|
+
case "application_name":
|
|
987
|
+
this.vars.applicationName = start.applicationName;
|
|
988
|
+
return;
|
|
989
|
+
case "search_path":
|
|
990
|
+
this.vars.searchPath = start.searchPath;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.setParameter(name, null);
|
|
994
|
+
const was = start.other.get(name) ?? (await this.isPlaceholder(name) ? "" : undefined);
|
|
995
|
+
if (was !== undefined)
|
|
996
|
+
this.vars.other.set(name, was);
|
|
997
|
+
}
|
|
998
|
+
/** RESET ALL (and DISCARD ALL's): every parameter back to the session's start, the startup packet's included; a
|
|
999
|
+
* placeholder the session SET stays defined, '' (`resetParameter`) */
|
|
1000
|
+
async resetAll() {
|
|
1001
|
+
const placeholders = [];
|
|
1002
|
+
for (const name of this.vars.other.keys())
|
|
1003
|
+
if (await this.isPlaceholder(name))
|
|
1004
|
+
placeholders.push(name);
|
|
1005
|
+
const tx = this.settingsAtStart();
|
|
1006
|
+
this.vars = copyVars(this.startupVars);
|
|
1007
|
+
for (const name of placeholders)
|
|
1008
|
+
if (!this.vars.other.has(name))
|
|
1009
|
+
this.vars.other.set(name, "");
|
|
1010
|
+
// a SET of every parameter: it overrides each LOCAL before it, and a ROLLBACK undoes it
|
|
1011
|
+
tx.commit = copyVars(this.vars);
|
|
1012
|
+
}
|
|
1013
|
+
/** a custom placeholder (`SET myapp.x`): a dotted name the catalog Postgres does not list (an extension's, `plpgsql.*`, it lists) */
|
|
1014
|
+
async isPlaceholder(name) {
|
|
1015
|
+
return name.includes(".") && !(await this.server.catalogSettings()).has(name.toLowerCase());
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* WHAT THE CLIENT IS TOLD OF ITS REPORTED PARAMETERS (GUC_REPORT): a ParameterStatus for each whose value is not the one
|
|
1019
|
+
* last reported — all of them at the startup, those a SET, a RESET, RESET ALL or DISCARD ALL moved after it — as
|
|
1020
|
+
* Postgres reports a changed one (`ReportChangedGUCOptions`), each value `sessionSetting`'s through `reportedParameters`.
|
|
1021
|
+
* A driver that caches them (pgjdbc, npgsql) was left with a SET's value after RESET ALL (GitHub issue 41). After the
|
|
1022
|
+
* startup, `ready` alone calls it: what a request moved is told once, before its ReadyForQuery.
|
|
1023
|
+
*/
|
|
1024
|
+
reportChanges() {
|
|
1025
|
+
const out = [];
|
|
1026
|
+
for (const [name, value] of this.reportedParameters()) {
|
|
1027
|
+
if (this.reported.get(name) === value)
|
|
1028
|
+
continue;
|
|
1029
|
+
this.reported.set(name, value);
|
|
1030
|
+
out.push(parameterStatus(name, value));
|
|
1031
|
+
}
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* A REQUEST'S END: the reported parameters it changed, then its ReadyForQuery — where Postgres 14+ reports them
|
|
1036
|
+
* (`ReportChangedGUCOptions` in PostgresMain, just before ReadyForQuery): after the statement's CommandComplete, and
|
|
1037
|
+
* ONCE per parameter per Query string or Sync, its last value (`SET application_name = 'a'; SET application_name =
|
|
1038
|
+
* 'b'` reports 'b' alone). With no block open the request's implicit transaction ends here, so what it reverted is
|
|
1039
|
+
* what is reported: a SET LOCAL told nothing, a block's revert is told by the ReadyForQuery that ends the block.
|
|
1040
|
+
*/
|
|
1041
|
+
ready() {
|
|
1042
|
+
if (this.tx === "none")
|
|
1043
|
+
this.endTransaction(true);
|
|
1044
|
+
return [...this.reportChanges(), readyForQuery(this.txStatus())];
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* A TRANSACTION'S END, explicit or implicit: the session's settings become what it leaves (`settingsTx`), as Postgres's
|
|
1048
|
+
* AtEOXact_GUC restores them, one level, since a SAVEPOINT is refused here. A placeholder a transaction defined stays
|
|
1049
|
+
* defined (`applySetting`); a transaction that set nothing changes nothing.
|
|
1050
|
+
*/
|
|
1051
|
+
endTransaction(committed) {
|
|
1052
|
+
this.implicitBlock = false;
|
|
1053
|
+
if (this.settingsTx === null)
|
|
1054
|
+
return;
|
|
1055
|
+
this.vars = committed ? this.settingsTx.commit : this.settingsTx.rollback;
|
|
1056
|
+
this.settingsTx = null;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* A FAILED BLOCK ACCEPTS NOTHING BUT ITS END, on every message: Postgres checks a Query's statement, a Parse, a Bind and
|
|
1060
|
+
* an Execute (`IsTransactionExitStmt`), each before the statement is classified or sent anywhere, so 25P02 is said
|
|
1061
|
+
* where Mason's own refusal of it (an INSERT's 25006) or the catalog's answer would be. Undefined: an empty statement,
|
|
1062
|
+
* which a Bind refuses as it refuses any other. A Parse of an empty string passes (`parseMessage`), a Describe has its
|
|
1063
|
+
* own rule (`describedInFailedBlock`), and a Close always runs.
|
|
1064
|
+
*/
|
|
1065
|
+
heldToFailedBlock(stmt) {
|
|
1066
|
+
if (this.tx === "failed" && (stmt === undefined || !endsFailedBlock(stmt)))
|
|
1067
|
+
throw aborted();
|
|
1068
|
+
}
|
|
1069
|
+
/** A Describe in a failed block: refused where it would describe a result (Postgres reads the catalog for a
|
|
1070
|
+
* RowDescription, which an aborted transaction cannot), answered otherwise, so a driver that describes all it sends
|
|
1071
|
+
* can still send its ROLLBACK. */
|
|
1072
|
+
describedInFailedBlock(statement) {
|
|
1073
|
+
const kind = statement.shape?.kind;
|
|
1074
|
+
if (this.tx === "failed" && kind !== undefined && ["catalog", "duckdb", "explain", "show", "set_config"].includes(kind))
|
|
1075
|
+
throw aborted();
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* A STATEMENT FAILED, the one way a transaction fails: an open block is failed (25P02 until its end), and in every
|
|
1079
|
+
* case what the transaction set is undone NOW, as Postgres's AbortTransaction does at the error, never at the ROLLBACK
|
|
1080
|
+
* that follows — so the failed block's own ReadyForQuery reports the revert, and an implicit transaction's SET (`SET a;
|
|
1081
|
+
* SELECT 1/0`, or a pipeline's before its Sync) never took.
|
|
1082
|
+
*/
|
|
1083
|
+
abortTransaction() {
|
|
1084
|
+
if (this.tx === "open")
|
|
1085
|
+
this.tx = "failed";
|
|
1086
|
+
this.endTransaction(false);
|
|
1087
|
+
}
|
|
696
1088
|
/** The catalog Postgres validates a parameter this server has no opinion on; its own error is the answer. Inside a
|
|
697
1089
|
* transaction that is ROLLED BACK: the sidecar is one connection every session shares, and a SET that stuck there
|
|
698
1090
|
* was every session's (review of #480). The value itself lives in this session's `vars.other`. */
|
|
1091
|
+
/**
|
|
1092
|
+
* A PARAMETER'S VALUE AS THE CATALOG POSTGRES READS IT, from the session's `current` one: SET LOCAL to `current`, then
|
|
1093
|
+
* to `value`, then SHOW, in a transaction rolled back (as `validateElsewhere`, nothing stays on the shared connection).
|
|
1094
|
+
* Postgres's refusal (22023 and its DETAIL) is relayed as its own; its answer is the canonical value SHOW gives.
|
|
1095
|
+
*/
|
|
1096
|
+
async canonicalElsewhere(name, value, current) {
|
|
1097
|
+
const sql = `BEGIN; SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(current)}; SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}; SHOW ${quoteIdent(name)}; ROLLBACK`;
|
|
1098
|
+
const messages = decodeBackend(Buffer.from(await this.server.catalog.run(Buffer.concat([frame("Q", cstr(sql)), frame("Q", cstr("ROLLBACK"))]))));
|
|
1099
|
+
const err = messages.find((x) => x.type === "ErrorResponse");
|
|
1100
|
+
if (err?.type === "ErrorResponse") {
|
|
1101
|
+
throw new PgError({ code: err.fields.C ?? "22023", message: err.fields.M ?? `invalid value for parameter "${name}"`, ...(err.fields.D !== undefined ? { detail: err.fields.D } : {}), ...(err.fields.H !== undefined ? { hint: err.fields.H } : {}) });
|
|
1102
|
+
}
|
|
1103
|
+
const row = messages.find((x) => x.type === "DataRow");
|
|
1104
|
+
const shown = row?.type === "DataRow" ? row.values[0]?.toString("utf8") : undefined;
|
|
1105
|
+
if (shown === undefined)
|
|
1106
|
+
throw new PgError({ code: "XX000", message: `SHOW ${name} answered no row in the catalog (Mason bug: report it)` }, 500);
|
|
1107
|
+
return shown;
|
|
1108
|
+
}
|
|
699
1109
|
async validateElsewhere(name, value) {
|
|
700
1110
|
const sql = value === null ? `RESET ${quoteIdent(name)}` : `SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}`;
|
|
701
1111
|
// one batch, so no other session's statement lands inside the transaction: the ROLLBACK in the first query is not
|
|
@@ -716,6 +1126,7 @@ class Session {
|
|
|
716
1126
|
finally {
|
|
717
1127
|
if (this.tx === "none")
|
|
718
1128
|
this.txNow = null;
|
|
1129
|
+
this.implicitBlock = false;
|
|
719
1130
|
}
|
|
720
1131
|
}
|
|
721
1132
|
/** the transaction's "now", the transaction begun at `statementNow` when none is open */
|
|
@@ -731,18 +1142,20 @@ class Session {
|
|
|
731
1142
|
catch (err) {
|
|
732
1143
|
const e = asPgError(err, sql);
|
|
733
1144
|
this.refuse(e, sql);
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
this.send(readyForQuery(this.txStatus()));
|
|
1145
|
+
this.abortTransaction();
|
|
1146
|
+
this.send(...this.ready());
|
|
737
1147
|
return;
|
|
738
1148
|
}
|
|
739
1149
|
if (stmts.length === 0) {
|
|
740
|
-
this.send(emptyQueryResponse(),
|
|
1150
|
+
this.send(emptyQueryResponse(), ...this.ready());
|
|
741
1151
|
return;
|
|
742
1152
|
}
|
|
743
1153
|
for (const raw of stmts) {
|
|
744
1154
|
const text = statementText(sql, raw);
|
|
745
|
-
this.asked = { sql: text, method: "QUERY", startedAt: Date.now() };
|
|
1155
|
+
this.asked = { sql: text, method: "QUERY", startedAt: Date.now(), sentAt: this.sentBytes };
|
|
1156
|
+
// at every statement, as Postgres's BeginImplicitTransactionBlock: a COMMIT among them ended the block before it
|
|
1157
|
+
if (stmts.length > 1)
|
|
1158
|
+
this.implicitBlock = true;
|
|
746
1159
|
try {
|
|
747
1160
|
const out = await this.runSimple(raw, text, sql);
|
|
748
1161
|
this.sendAll(out);
|
|
@@ -750,12 +1163,11 @@ class Session {
|
|
|
750
1163
|
catch (err) {
|
|
751
1164
|
const e = asPgError(err, sql);
|
|
752
1165
|
this.refuse(e, text);
|
|
753
|
-
|
|
754
|
-
this.tx = "failed";
|
|
1166
|
+
this.abortTransaction();
|
|
755
1167
|
break;
|
|
756
1168
|
}
|
|
757
1169
|
}
|
|
758
|
-
this.send(
|
|
1170
|
+
this.send(...this.ready());
|
|
759
1171
|
}
|
|
760
1172
|
/**
|
|
761
1173
|
* EVERY STATEMENT'S ErrorResponse goes out here, and is logged here at the normal level (a BI tool met an
|
|
@@ -766,9 +1178,10 @@ class Session {
|
|
|
766
1178
|
*/
|
|
767
1179
|
refuse(e, sql, at) {
|
|
768
1180
|
this.server.log(refusalLine(this.id, e.fields, sql));
|
|
769
|
-
|
|
1181
|
+
const response = errorResponse(e.fields);
|
|
1182
|
+
this.recordRequest(e, sql, undefined, [response]);
|
|
770
1183
|
this.traceRefusal(e, at);
|
|
771
|
-
this.send(
|
|
1184
|
+
this.send(response);
|
|
772
1185
|
}
|
|
773
1186
|
traceRefusal(e, at) {
|
|
774
1187
|
if (this.server.options.trace !== true)
|
|
@@ -782,22 +1195,31 @@ class Session {
|
|
|
782
1195
|
* statement's ErrorResponse goes out), and for a refused password (CONNECT), through `options.record`. A catalog
|
|
783
1196
|
* read, a SET or a SHOW answered is the protocol's chatter, not recorded. Never throws: `record` buffers.
|
|
784
1197
|
*/
|
|
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
|
-
|
|
1198
|
+
recordRequest(answer, sql, refusedUser, then = [], of) {
|
|
1199
|
+
// `of`: a statement recorded after its own messages (a portal dropped suspended), not the one being asked now: what
|
|
1200
|
+
// it sent is what its pages sent (`carried`), nothing since
|
|
1201
|
+
const asked = of === undefined ? this.asked
|
|
1202
|
+
: { sql: sql ?? "", method: of.method, startedAt: Date.now(), sentAt: this.sentBytes, carried: of.carried, ...(of.jsonReads !== undefined ? { jsonReads: of.jsonReads } : {}) };
|
|
788
1203
|
const startedAt = (!(answer instanceof PgError) ? answer.startedAt : undefined) ?? asked?.startedAt ?? Date.now();
|
|
1204
|
+
const bytesSent = this.bytesFor(then, asked);
|
|
789
1205
|
// a refusal's statement is the one refuse() names (a simple Query's own statement, an Execute's portal's)
|
|
790
1206
|
const stored = storedPayload(sql ?? asked?.sql ?? "");
|
|
791
1207
|
const user = this.token?.user ?? refusedUser;
|
|
1208
|
+
// the EFFECTIVE role: the user's own, else the roles file's default (what the statement was held to)
|
|
1209
|
+
const role = this.roleName();
|
|
792
1210
|
this.server.options.record({
|
|
793
1211
|
requestId: crypto.randomUUID(), at: new Date(startedAt), method: asked?.method ?? "QUERY", durationMs: Date.now() - startedAt,
|
|
794
1212
|
client: `postgres:${this.vars.applicationName || "unnamed"}`, session: this.id, payload: stored.text, payloadTruncated: stored.truncated,
|
|
795
|
-
caller: hashCaller(this.presented),
|
|
1213
|
+
caller: hashCaller(this.presented), bytesSent,
|
|
1214
|
+
...(this.clientAddr !== null ? { clientAddress: this.clientAddr } : {}),
|
|
796
1215
|
...(user !== undefined ? { user } : {}),
|
|
1216
|
+
...(role !== undefined ? { role } : {}),
|
|
1217
|
+
...(this.token?.attributes !== undefined && Object.keys(this.token.attributes).length > 0 ? { attributes: this.token.attributes } : {}),
|
|
797
1218
|
...(asked?.jsonReads !== undefined && asked.jsonReads.length > 0 ? { jsonReads: asked.jsonReads } : {}),
|
|
798
1219
|
...(answer instanceof PgError
|
|
799
1220
|
? { 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 } : {})
|
|
1221
|
+
...(answer.fields.detail ? { detail: answer.fields.detail } : {}), ...(answer.fields.hint ? { hint: answer.fields.hint } : {}),
|
|
1222
|
+
...(answer.gap !== null ? { gap: answer.gap } : {}) }
|
|
801
1223
|
: { status: 200, rows: answer.rows }),
|
|
802
1224
|
});
|
|
803
1225
|
}
|
|
@@ -808,41 +1230,101 @@ class Session {
|
|
|
808
1230
|
this.server.schema();
|
|
809
1231
|
return duckTranslate(stmt, text);
|
|
810
1232
|
}
|
|
1233
|
+
/**
|
|
1234
|
+
* A SELECT OF NO TABLE THAT ORDERS TEXT (issue 122): the catalog's database is C, so one whose analysis orders text
|
|
1235
|
+
* under a collation that is not byte order is the synced data's (src/duck/plan.ts `ordersText`, decided by the
|
|
1236
|
+
* analysis, never the text), planned and refused like any other; every other stays the catalog's (SELECT 1, version(),
|
|
1237
|
+
* 'a' = 'A', anything under C).
|
|
1238
|
+
* ONE THAT MAPS CASE (upper, lower, initcap: issue 406), which the catalog answered before by its own C.UTF-8's
|
|
1239
|
+
* mapping, is the synced data's only where the synced data's RUN answers it (`answersCase`): the route's probe is the
|
|
1240
|
+
* run, never the plan alone, since a construct the rewrite leaves as written (`pg_catalog.lower(…)`, `char 'abc'`,
|
|
1241
|
+
* `FROM (SELECT)`) plans and fails only in the engine; where it fails, the catalog answers, as before.
|
|
1242
|
+
*/
|
|
1243
|
+
async routed(t, text, paramOids, bindable) {
|
|
1244
|
+
const ask = this.server.options.duck.ordersText;
|
|
1245
|
+
if (t.kind !== "catalog" || t.fromless === undefined || ask === undefined)
|
|
1246
|
+
return t;
|
|
1247
|
+
const why = await ask((b) => this.server.catalog.run(b), text, paramOids, this.sessionState(), bindable);
|
|
1248
|
+
if (why === null || why === "case" && !await this.answersCase(text, paramOids))
|
|
1249
|
+
return t;
|
|
1250
|
+
return { kind: "duckdb", sql: text, names: t.fromless.names };
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* WHETHER THE SYNCED DATA ANSWERS A STATEMENT OF NO TABLE THAT MAPS CASE: its plan made and RUN on this session's
|
|
1254
|
+
* connection, under its state and clock, every row read and none sent. A statement the plan or the run refuses, or
|
|
1255
|
+
* one with a parameter (no value before its Bind), is the catalog's, which answered it before (Mason gap
|
|
1256
|
+
* fromless-numeric-text-order). Only a statement's failure is read so; any other is thrown.
|
|
1257
|
+
*/
|
|
1258
|
+
async answersCase(text, paramOids) {
|
|
1259
|
+
const raw = (await parseSql(text)).stmts?.[0];
|
|
1260
|
+
if (raw?.stmt === undefined)
|
|
1261
|
+
return false;
|
|
1262
|
+
const controller = new AbortController();
|
|
1263
|
+
const disarm = this.armTimeout(controller);
|
|
1264
|
+
try {
|
|
1265
|
+
const plan = await this.duckPlan(raw, text, paramOids);
|
|
1266
|
+
if (plan.params.length > 0)
|
|
1267
|
+
return false;
|
|
1268
|
+
const now = this.txNow ?? this.statementNow;
|
|
1269
|
+
const { cursor } = await this.duckAnswer(plan, [], [], { parse: now, exec: now }, controller);
|
|
1270
|
+
try {
|
|
1271
|
+
while (!cursor.done) {
|
|
1272
|
+
await this.fill(cursor);
|
|
1273
|
+
cursor.pending = [];
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
finally {
|
|
1277
|
+
cursor.answer.close();
|
|
1278
|
+
}
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1281
|
+
catch (e) {
|
|
1282
|
+
// the statement's own failure (Postgres's analysis, the rewrite's refusal, the engine's error at the run): said in
|
|
1283
|
+
// the log and answered by the catalog, as before this route existed
|
|
1284
|
+
// (a cancel or the statement's timeout is the statement's answer, never a reason to run it again on the catalog)
|
|
1285
|
+
if (controller.signal.aborted || !(e instanceof StatementError || e instanceof PgError || e instanceof EncodeError || e instanceof NumericError))
|
|
1286
|
+
throw e;
|
|
1287
|
+
this.server.log(`session ${this.id}: a statement of no table that maps case is answered by the catalog: the synced data's run failed (${e instanceof StatementError || e instanceof PgError ? `${e.fields.code} ` : ""}${e.message})`);
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
finally {
|
|
1291
|
+
disarm();
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
811
1294
|
/** One statement of a simple query: the whole reply but the ReadyForQuery. */
|
|
812
1295
|
async runSimple(raw, text, whole) {
|
|
813
1296
|
if (raw.stmt === undefined)
|
|
814
1297
|
return [emptyQueryResponse()];
|
|
815
1298
|
const now = this.txStart(this.statementNow);
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
|
|
819
|
-
}
|
|
1299
|
+
this.heldToFailedBlock(raw.stmt);
|
|
1300
|
+
const t = await this.routed(this.classify(raw.stmt, text), text, [], false);
|
|
820
1301
|
switch (t.kind) {
|
|
821
1302
|
case "empty": return [emptyQueryResponse()];
|
|
822
1303
|
case "explain": {
|
|
823
|
-
const e = await this.explaining(raw, whole, t, []);
|
|
1304
|
+
const e = await this.explaining(raw, whole, t, [], false);
|
|
824
1305
|
if (e.plan === null)
|
|
825
|
-
return catalogExplained(await this.forwardSimple(text), e.options);
|
|
1306
|
+
return catalogExplained(await this.forwardSimple(this.spliced(text, raw)), e.options);
|
|
826
1307
|
const controller = new AbortController();
|
|
827
1308
|
const disarm = this.armTimeout(controller);
|
|
828
1309
|
try {
|
|
829
1310
|
const { field, rows } = await this.duckExplain({ options: e.options, plan: e.plan }, [], [], { parse: now, exec: now }, controller);
|
|
830
|
-
|
|
831
|
-
|
|
1311
|
+
const out = [rowDescription([field]), ...rows, commandComplete("EXPLAIN")];
|
|
1312
|
+
this.recordRequest({ rows: rows.length }, undefined, undefined, out);
|
|
1313
|
+
return out;
|
|
832
1314
|
}
|
|
833
1315
|
finally {
|
|
834
1316
|
disarm();
|
|
835
1317
|
}
|
|
836
1318
|
}
|
|
837
|
-
case "catalog": return this.forwardSimple(text);
|
|
1319
|
+
case "catalog": return this.forwardSimple(this.spliced(text, raw));
|
|
838
1320
|
case "set": return this.setStatement(t);
|
|
1321
|
+
// its RowDescription first, as Postgres's: a value it refuses is refused after it
|
|
1322
|
+
case "set_config":
|
|
1323
|
+
this.send(this.setConfigDescription(t, 0));
|
|
1324
|
+
return this.setConfigStatement(t);
|
|
839
1325
|
case "show": return this.showStatement(t.name);
|
|
840
1326
|
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")];
|
|
1327
|
+
case "discard": return this.discard(t.target);
|
|
846
1328
|
case "deallocate":
|
|
847
1329
|
this.statements.clear();
|
|
848
1330
|
return [commandComplete("DEALLOCATE ALL")];
|
|
@@ -860,8 +1342,9 @@ class Session {
|
|
|
860
1342
|
finally {
|
|
861
1343
|
cursor.answer.close();
|
|
862
1344
|
}
|
|
863
|
-
|
|
864
|
-
|
|
1345
|
+
const done = [commandComplete(`SELECT ${cursor.sent}`)];
|
|
1346
|
+
this.recordRequest({ rows: cursor.sent }, undefined, undefined, done);
|
|
1347
|
+
return done;
|
|
865
1348
|
}
|
|
866
1349
|
finally {
|
|
867
1350
|
disarm();
|
|
@@ -869,15 +1352,205 @@ class Session {
|
|
|
869
1352
|
}
|
|
870
1353
|
}
|
|
871
1354
|
}
|
|
872
|
-
/** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone
|
|
873
|
-
* declared parameter types
|
|
874
|
-
|
|
1355
|
+
/** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone,
|
|
1356
|
+
* declared parameter types and the session's role until the schema or the roles change. Every statement DuckDB
|
|
1357
|
+
* runs has one: never its text as written. */
|
|
1358
|
+
async duckPlan(raw, whole, paramOids, under = this.sessionState(), wireBound = false) {
|
|
875
1359
|
const stmt = raw.stmt;
|
|
876
1360
|
const bytes = Buffer.from(whole, "utf8");
|
|
877
1361
|
const base = raw.stmt_location ?? 0;
|
|
878
1362
|
const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
|
|
879
|
-
const
|
|
880
|
-
|
|
1363
|
+
const grants = this.grants();
|
|
1364
|
+
// 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
|
|
1365
|
+
// a user with no role under a file with no default its own key, never a token's ("") — and, where the role filters
|
|
1366
|
+
// rows, the filters bound to the user's values (a plan holds them as literals)
|
|
1367
|
+
const rowsKey = rowsKeyOf(grants);
|
|
1368
|
+
const key = `${grants?.noRole !== undefined ? "\u0000no role" : this.roleName() ?? ""}${rowsKey === "" ? "" : `\u0000${rowsKey}`}`;
|
|
1369
|
+
return this.server.duckPlan(text, paramOids, under, wireBound, key, async () => {
|
|
1370
|
+
const plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, under, this.server.userTypes(), wireBound, grants);
|
|
1371
|
+
return rowsKey === "" ? plan : { ...plan, rowsKey };
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* THE SESSION'S ROLE NOW (src/duck/grants.ts; docs/POSTGRES-API.md "Roles"): what its user's role may read, asked at
|
|
1376
|
+
* every plan, every Execute and every catalog read — the user's role is re-read with the user before each statement
|
|
1377
|
+
* (`stillLive`), the roles file whenever it changes (`PgApiOptions.roles`), so a role given, taken or edited applies at
|
|
1378
|
+
* the next statement, with no restart. undefined: no roles anywhere (nothing to hold, and no reads to record);
|
|
1379
|
+
* null: a session held to nothing beside roles — a token's (its plan still names its reads). FAIL CLOSED: a role the
|
|
1380
|
+
* file no longer names, or a file that cannot be read as it is now, refuses the statement by name, never widens.
|
|
1381
|
+
*
|
|
1382
|
+
* 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,
|
|
1383
|
+
* every copied table; the file's `default:`, that role (read here at every statement, so an edit of `default:` applies
|
|
1384
|
+
* at the next one); a file with no `default:`, nothing (`RoleGrants.noRole`: every statement that reads a copied table
|
|
1385
|
+
* refused with the two ways out, src/duck/grants.ts noRoleRefusal). A token, which is no user, reads every copied table.
|
|
1386
|
+
*/
|
|
1387
|
+
grants() {
|
|
1388
|
+
const g = this.ownGrants();
|
|
1389
|
+
return g === null || g === undefined ? g : this.withRows(g);
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* THE ROLE'S ROW FILTERS BOUND TO THIS SESSION'S USER (src/duck/row-filter.ts): each table's `where:` with the user's
|
|
1393
|
+
* attribute values as literals of the types the catalog inferred (`Server.checkRoles`), each `through:` read through
|
|
1394
|
+
* its parent. A USER WITHOUT A VALUE ITS ROLE'S FILTERS READ IS REFUSED, LOUDLY, at every statement — never answered
|
|
1395
|
+
* with no rows (a user added before the roles file gained the attribute): 42501, the attribute and the filter in the
|
|
1396
|
+
* DETAIL, the exact commands in the HINT. A filter not checked yet (the catalog a step behind the file) refuses the
|
|
1397
|
+
* statement, said: fail closed.
|
|
1398
|
+
*/
|
|
1399
|
+
withRows(g) {
|
|
1400
|
+
if (g.filters === undefined || g.filters.size === 0)
|
|
1401
|
+
return g;
|
|
1402
|
+
const values = this.token?.attributes ?? {};
|
|
1403
|
+
const cached = this.boundRows;
|
|
1404
|
+
if (cached !== null && cached.of === g && cached.values === values)
|
|
1405
|
+
return cached.bound;
|
|
1406
|
+
const user = this.token?.user ?? this.user;
|
|
1407
|
+
const missing = missingAttributes(g.filters.values(), values);
|
|
1408
|
+
if (missing.length > 0) {
|
|
1409
|
+
const sets = missing.map((m) => `--set ${m.attr}=<value>`).join(" ");
|
|
1410
|
+
const shapes = g.attributes ?? new Map();
|
|
1411
|
+
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`,
|
|
1412
|
+
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`,
|
|
1413
|
+
hint: `mason user remove ${user} && mason user add ${user} --role ${g.name} ${sets}` }, 403);
|
|
1414
|
+
}
|
|
1415
|
+
const rows = new Map();
|
|
1416
|
+
for (const f of g.filters.values()) {
|
|
1417
|
+
if (f.where === undefined) {
|
|
1418
|
+
rows.set(f.table, bindFilter(f, new Map(), values));
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
const types = g.filterTypes?.get(f.table);
|
|
1422
|
+
// a table not copied yet has no types (nothing reads it: the statement is Postgres's 42P01); a copied one must
|
|
1423
|
+
if (types === undefined) {
|
|
1424
|
+
if (g.filterTypes === undefined) {
|
|
1425
|
+
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);
|
|
1426
|
+
}
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
rows.set(f.table, bindFilter(f, types, values));
|
|
1430
|
+
}
|
|
1431
|
+
const bound = { ...g, rows, ...(user !== undefined ? { rowsUser: user } : {}) };
|
|
1432
|
+
this.boundRows = { of: g, values, bound };
|
|
1433
|
+
return bound;
|
|
1434
|
+
}
|
|
1435
|
+
boundRows = null;
|
|
1436
|
+
/** the role the session is held to now, its filters unbound (`grants` binds them) */
|
|
1437
|
+
ownGrants() {
|
|
1438
|
+
const live = this.server.rolesChecked();
|
|
1439
|
+
const own = this.token?.role;
|
|
1440
|
+
const user = this.token?.user ?? this.user;
|
|
1441
|
+
if (own === undefined) {
|
|
1442
|
+
if (this.token?.user === undefined || live.file === null)
|
|
1443
|
+
return live.grants.size === 0 ? undefined : null;
|
|
1444
|
+
if (live.problem !== null) {
|
|
1445
|
+
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}`,
|
|
1446
|
+
hint: `fix ${live.file}: a user with no role is held to its default: line` }, 503);
|
|
1447
|
+
}
|
|
1448
|
+
if (live.default !== null)
|
|
1449
|
+
return live.grants.get(live.default) ?? this.notInFile(live, live.default, user);
|
|
1450
|
+
return { name: "", file: live.file, tables: new Map(), all: false, noRole: { user, example: exampleRole(live) } };
|
|
1451
|
+
}
|
|
1452
|
+
if (live.problem !== null) {
|
|
1453
|
+
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}`,
|
|
1454
|
+
hint: `fix ${live.file ?? "the roles file"}: the role ${own} is the user ${JSON.stringify(user)}'s` }, 503);
|
|
1455
|
+
}
|
|
1456
|
+
return live.grants.get(own) ?? this.notInFile(live, own, user);
|
|
1457
|
+
}
|
|
1458
|
+
/** a role the roles file does not name (now): refused by name, never widened */
|
|
1459
|
+
notInFile(live, role, user) {
|
|
1460
|
+
const named = [...live.grants.keys()];
|
|
1461
|
+
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)"}`,
|
|
1462
|
+
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)"}`,
|
|
1463
|
+
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);
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* THE ROLE THE SESSION IS HELD TO NOW, by name — the user's own, else (a user with none) the roles file's `default:`;
|
|
1467
|
+
* undefined for a token, with no roles file, under a file with no default, or one that cannot be read (`grants`
|
|
1468
|
+
* refuses that). Read by the catalog's `SET ROLE` (`catalogAs`), the plan cache's key and the request log's `role`,
|
|
1469
|
+
* so each says the role the statement was held to. Never throws (the request log reads it).
|
|
1470
|
+
*/
|
|
1471
|
+
roleName() {
|
|
1472
|
+
const own = this.token?.role;
|
|
1473
|
+
if (own !== undefined)
|
|
1474
|
+
return own;
|
|
1475
|
+
if (this.token?.user === undefined)
|
|
1476
|
+
return undefined;
|
|
1477
|
+
const live = this.server.rolesNow();
|
|
1478
|
+
return live.file !== null && live.problem === null ? live.default ?? undefined : undefined;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* THE CATALOG'S ROLE OF THIS SESSION NOW: the effective role (`roleName`), or — for a user the file holds to nothing
|
|
1482
|
+
* (no role, no `default:`) — `NO_ROLE_CATALOG`, a role with no grant, so information_schema lists it nothing and every
|
|
1483
|
+
* privilege function is false, as on Postgres for a login with no grant. Undefined: the catalog's own (a token, no
|
|
1484
|
+
* roles file). Throws where `grants` refuses (fail closed).
|
|
1485
|
+
*/
|
|
1486
|
+
catalogRole() {
|
|
1487
|
+
return this.grants()?.noRole !== undefined ? NO_ROLE_CATALOG : this.roleName();
|
|
1488
|
+
}
|
|
1489
|
+
/** a user the roles file holds to nothing (no role of its own, no `default:`), read without throwing: its catalog
|
|
1490
|
+
* statements answer `current_user`, `current_role` and `user` as its own name (`userSpliced`), as Postgres does for
|
|
1491
|
+
* a login with no grant, never the catalog role's */
|
|
1492
|
+
heldToNothing() {
|
|
1493
|
+
if (this.token?.role !== undefined || this.token?.user === undefined)
|
|
1494
|
+
return false;
|
|
1495
|
+
const live = this.server.rolesNow();
|
|
1496
|
+
return live.file !== null && live.problem === null && live.default === null;
|
|
1497
|
+
}
|
|
1498
|
+
/** the statement as the catalog runs it for this session: the user's name spliced for the keywords that name it, and
|
|
1499
|
+
* every call of an override (`userSpliced`) */
|
|
1500
|
+
spliced(text, raw) {
|
|
1501
|
+
return userSpliced(text, raw.stmt, raw.stmt_location ?? 0, this.user, this.heldToNothing() ? HELD_TO_NOTHING_OPS : SESSION_OPS);
|
|
1502
|
+
}
|
|
1503
|
+
/** a plan's reads held to the session's role of THIS moment (a role given or taken since the Parse): refused as the
|
|
1504
|
+
* Parse would have refused it; a plan made before any role existed has no reads, and is planned again at its next Parse */
|
|
1505
|
+
heldToRole(plan) {
|
|
1506
|
+
const grants = this.grants();
|
|
1507
|
+
if (grants === null || grants === undefined)
|
|
1508
|
+
return;
|
|
1509
|
+
if (plan.reads === undefined && grants.noRole === undefined) {
|
|
1510
|
+
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}`,
|
|
1511
|
+
hint: "prepare the statement again: its reads are then held to the role" }, 403);
|
|
1512
|
+
}
|
|
1513
|
+
if (plan.reads === undefined) {
|
|
1514
|
+
// a plan made before any roles file existed, for a user the file now holds to nothing: prepared again, held then
|
|
1515
|
+
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`,
|
|
1516
|
+
hint: "prepare the statement again: its reads are then held to the roles file" }, 403);
|
|
1517
|
+
}
|
|
1518
|
+
const refused = grantRefusal(plan.reads, grants);
|
|
1519
|
+
if (refused !== null)
|
|
1520
|
+
throw refused;
|
|
1521
|
+
// a plan holds the filters it was made under as literals: one made under others (the roles file edited since its
|
|
1522
|
+
// Parse) never runs — executeDuck plans it again first, so this is the last guard
|
|
1523
|
+
if ((plan.rowsKey ?? "") !== rowsKeyOf(grants)) {
|
|
1524
|
+
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);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* A CATALOG REQUEST OF THIS SESSION, WITH ITS SETTINGS AND AS ITS ROLE (src/api/postgres/catalog.ts asSession, asRole):
|
|
1529
|
+
* every request carries the session's settings (`sessionSettings`), so `current_setting(name)` answers what `SHOW`
|
|
1530
|
+
* does, and a session with a role runs its catalog statements under `SET ROLE`, RESET after, each in the one request
|
|
1531
|
+
* the catalog runs whole, so `information_schema`,
|
|
1532
|
+
* `has_table_privilege` and `has_column_privilege` answer for the role and no other session's request meets it. A
|
|
1533
|
+
* role the catalog has not made yet (its rebuild a moment behind the file) refuses the statement, said; a role the
|
|
1534
|
+
* file no longer names is refused before it reaches the catalog (`grants`).
|
|
1535
|
+
*/
|
|
1536
|
+
async catalogAs(message) {
|
|
1537
|
+
// fail closed first (a role the file lacks, a file that cannot be read), then the catalog's role of the session
|
|
1538
|
+
// (`catalogRole`; both read the roles once, with no await between)
|
|
1539
|
+
const role = this.catalogRole();
|
|
1540
|
+
// the transaction's instant, which a block's statements on either path read (`txNow`): begun here when none is
|
|
1541
|
+
// (reset at the end of a statement outside a block, as executeDuck's)
|
|
1542
|
+
const now = this.txNow ??= this.server.now();
|
|
1543
|
+
// the request's transaction begins at that instant (asSession, catalog-worker.ts pinnedClock): the catalog's own
|
|
1544
|
+
// transaction start is the session's, whatever reads it
|
|
1545
|
+
const reply = unwrapSession(splitFrames(await this.server.catalog.run(asSession(role === undefined ? message : asRole(message, role), this.sessionState(), this.sessionSettings()), now)));
|
|
1546
|
+
if (role === undefined)
|
|
1547
|
+
return reply;
|
|
1548
|
+
const { frames, setRoleRefused } = unwrapRole(reply);
|
|
1549
|
+
if (setRoleRefused !== null) {
|
|
1550
|
+
const e = relayedError(setRoleRefused);
|
|
1551
|
+
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);
|
|
1552
|
+
}
|
|
1553
|
+
return frames;
|
|
881
1554
|
}
|
|
882
1555
|
/**
|
|
883
1556
|
* 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
|
|
@@ -901,7 +1574,7 @@ class Session {
|
|
|
901
1574
|
this.running = controller;
|
|
902
1575
|
let bounded;
|
|
903
1576
|
try {
|
|
904
|
-
bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.
|
|
1577
|
+
bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.sessionState(), true);
|
|
905
1578
|
}
|
|
906
1579
|
catch (e) {
|
|
907
1580
|
if (!(e instanceof StatementError) || e.fields.code === "57014")
|
|
@@ -928,12 +1601,12 @@ class Session {
|
|
|
928
1601
|
* statement it explains classified as that statement alone would be — refused with that statement's own error — and,
|
|
929
1602
|
* for DuckDB's, planned afresh (an ANALYZE's Planning Time is this one's), never from the plans kept per text.
|
|
930
1603
|
*/
|
|
931
|
-
async explaining(raw, whole, t, paramOids) {
|
|
1604
|
+
async explaining(raw, whole, t, paramOids, bindable) {
|
|
932
1605
|
const options = explainOptions(t.options);
|
|
933
|
-
const
|
|
934
|
-
if (
|
|
1606
|
+
const classified = this.classify(t.query, "");
|
|
1607
|
+
if (classified.kind === "catalog" && classified.fromless === undefined)
|
|
935
1608
|
return { options, plan: null };
|
|
936
|
-
if (
|
|
1609
|
+
if (classified.kind !== "duckdb" && classified.kind !== "catalog")
|
|
937
1610
|
throw new PgError({ code: "0A000", message: "EXPLAIN of this statement is not supported: a SELECT is explained" });
|
|
938
1611
|
const bytes = Buffer.from(whole, "utf8");
|
|
939
1612
|
const base = raw.stmt_location ?? 0;
|
|
@@ -941,9 +1614,17 @@ class Session {
|
|
|
941
1614
|
const found = await explainedText(text, t.query);
|
|
942
1615
|
if (found === null)
|
|
943
1616
|
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)" });
|
|
1617
|
+
// a statement of no table is explained as what answers it: the synced data's plan where it orders text (`routed`)
|
|
1618
|
+
if ((await this.routed(classified, found.text, paramOids, bindable)).kind === "catalog")
|
|
1619
|
+
return { options, plan: null };
|
|
944
1620
|
let plan;
|
|
945
1621
|
try {
|
|
946
|
-
|
|
1622
|
+
const grants = this.grants();
|
|
1623
|
+
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.sessionState(), this.server.userTypes(), false, grants);
|
|
1624
|
+
// the row filters it was made under, as every plan of a session carries them (Session.duckPlan)
|
|
1625
|
+
const rowsKey = rowsKeyOf(grants);
|
|
1626
|
+
if (rowsKey !== "")
|
|
1627
|
+
plan = { ...plan, rowsKey };
|
|
947
1628
|
}
|
|
948
1629
|
catch (e) {
|
|
949
1630
|
// a refusal of the statement explained is placed as Postgres places it: in the whole text, past `EXPLAIN …`
|
|
@@ -956,14 +1637,21 @@ class Session {
|
|
|
956
1637
|
}
|
|
957
1638
|
/** an EXPLAIN of a DuckDB statement, run: its QUERY PLAN rows (ANALYZE runs the statement and sends none of its rows) */
|
|
958
1639
|
async duckExplain(e, params, resultFormats, clock, controller) {
|
|
1640
|
+
this.heldToRole(e.plan);
|
|
1641
|
+
// ANALYZE RUNS THE STATEMENT AND REPORTS WHAT THE ENGINE DID, which is a count of what it read and what it proved
|
|
1642
|
+
// of the whole table: nothing of it can be said to a filtered user, so there is nothing left to run it for
|
|
1643
|
+
const filtered = e.plan.rowFilters ?? [];
|
|
1644
|
+
if (e.options.analyze && filtered.length > 0) {
|
|
1645
|
+
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");
|
|
1646
|
+
}
|
|
959
1647
|
await this.readAside();
|
|
960
1648
|
const runner = await this.duck();
|
|
961
1649
|
this.running = controller;
|
|
962
1650
|
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 },
|
|
1651
|
+
const values = filtered.length > 0 ? [] : await this.duckValues(e.plan, params, clock);
|
|
1652
|
+
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
1653
|
// 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?.() ?? []) });
|
|
1654
|
+
copies: copyFacts(this.server.options.duck.copies?.() ?? []), ...(filtered.length > 0 ? { rowFilters: filtered, reads: (e.plan.reads ?? []).map((r) => r.table) } : {}) });
|
|
967
1655
|
return { field: explainField(e.options, formatAt(resultFormats, 0)), rows: lines.map((l) => dataRow([Buffer.from(l)])) };
|
|
968
1656
|
}
|
|
969
1657
|
finally {
|
|
@@ -974,27 +1662,47 @@ class Session {
|
|
|
974
1662
|
* transaction's start where the plan reads it (its positions follow the statement's own parameters) */
|
|
975
1663
|
async duckValues(plan, params, clock) {
|
|
976
1664
|
const own = params.length === 0 ? params
|
|
977
|
-
: await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.
|
|
1665
|
+
: await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.sessionState());
|
|
978
1666
|
const values = [...own];
|
|
979
1667
|
for (const [which, at] of Object.entries(plan.clock ?? {}))
|
|
980
|
-
values[at - 1] =
|
|
1668
|
+
values[at - 1] = instantText(clock[which]);
|
|
981
1669
|
return values;
|
|
982
1670
|
}
|
|
983
1671
|
async duck() {
|
|
984
1672
|
this.duckRunner ??= await this.server.options.duck.open();
|
|
985
1673
|
return this.duckRunner;
|
|
986
1674
|
}
|
|
987
|
-
|
|
1675
|
+
/**
|
|
1676
|
+
* WHAT A DUCKDB STATEMENT OF THIS SESSION RUNS UNDER: its TimeZone and search_path, and every setting
|
|
1677
|
+
* `current_setting(name)` reads beside a copied table (src/duck/rewrite.ts) — the catalog Postgres's own
|
|
1678
|
+
* (`Server.catalogSettings`) with the session's laid over (`sessionSettings`), as the catalog's `current_setting`
|
|
1679
|
+
* answers them, never DuckDB's own settings.
|
|
1680
|
+
*/
|
|
1681
|
+
async duckSession() {
|
|
1682
|
+
const settings = new Map([...await this.server.catalogSettings(), ...this.sessionSettings()]);
|
|
1683
|
+
return { ...this.sessionState(), settings: Object.fromEntries(settings) };
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* THE SESSION'S STATE FOR AN ANSWER, AS ONE VALUE (GitHub issue 253): its TimeZone, search_path and DateStyle, each
|
|
1687
|
+
* `sessionSetting`'s — the one reader SHOW answers from — so a statement planned, analysed, run on DuckDB or answered by
|
|
1688
|
+
* the catalog Postgres is made under the same values (src/api/postgres/catalog.ts SessionState). Nothing else reads
|
|
1689
|
+
* the session's zone, path or order.
|
|
1690
|
+
*/
|
|
1691
|
+
sessionState() {
|
|
1692
|
+
const read = (name, otherwise) => this.sessionSetting(name) ?? otherwise;
|
|
1693
|
+
return { timeZone: read("timezone", "UTC"), searchPath: read("search_path", '"$user", public'), dateStyle: read("datestyle", "ISO, MDY") };
|
|
1694
|
+
}
|
|
988
1695
|
/** A DuckDB statement run: its columns (Postgres's, where it has a plan), and its rows to read, encoded in the
|
|
989
1696
|
* formats asked */
|
|
990
1697
|
async duckAnswer(plan, params, resultFormats, clock, controller) {
|
|
991
1698
|
this.readsOf(plan);
|
|
1699
|
+
this.heldToRole(plan);
|
|
992
1700
|
await this.readAside();
|
|
993
1701
|
const runner = await this.duck();
|
|
994
1702
|
this.running = controller;
|
|
995
1703
|
try {
|
|
996
1704
|
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 ?? []);
|
|
1705
|
+
const a = await runner.run(plan.sql, values, await this.duckSession(), controller.signal, plan.fields, plan.exact ?? []);
|
|
998
1706
|
// a SELECT's analysis always has its RowDescription; without one, DuckDB's own columns are said as they are
|
|
999
1707
|
const fields = plan.fields !== null
|
|
1000
1708
|
? 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) }))
|
|
@@ -1031,7 +1739,7 @@ class Session {
|
|
|
1031
1739
|
if (mine)
|
|
1032
1740
|
this.running = c.controller;
|
|
1033
1741
|
try {
|
|
1034
|
-
const rows = await c.answer.fetch(c.formats, { timeZone: this.
|
|
1742
|
+
const rows = await c.answer.fetch(c.formats, { timeZone: this.sessionState().timeZone });
|
|
1035
1743
|
if (rows === null)
|
|
1036
1744
|
c.done = true;
|
|
1037
1745
|
else
|
|
@@ -1052,6 +1760,8 @@ class Session {
|
|
|
1052
1760
|
if (c.pending.length === 0)
|
|
1053
1761
|
return;
|
|
1054
1762
|
c.sent += c.pending.length;
|
|
1763
|
+
if (this.asked !== null)
|
|
1764
|
+
this.asked.rows = c.sent;
|
|
1055
1765
|
this.sendAll(c.pending);
|
|
1056
1766
|
c.pending = [];
|
|
1057
1767
|
}
|
|
@@ -1124,7 +1834,7 @@ class Session {
|
|
|
1124
1834
|
const c = portal?.cursor;
|
|
1125
1835
|
if (portal !== undefined && c !== undefined && c.suspended === true && c.recorded !== true && c.failed === undefined) {
|
|
1126
1836
|
c.recorded = true;
|
|
1127
|
-
this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, portal.statement.sql, undefined, { method: "EXECUTE", jsonReads: portal.statement.duck?.jsonReads });
|
|
1837
|
+
this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, portal.statement.sql, undefined, [], { method: "EXECUTE", jsonReads: portal.statement.duck?.jsonReads, carried: portal.bytes });
|
|
1128
1838
|
}
|
|
1129
1839
|
c?.answer.close();
|
|
1130
1840
|
this.portals.delete(name);
|
|
@@ -1132,33 +1842,142 @@ class Session {
|
|
|
1132
1842
|
dropPortals() { for (const name of [...this.portals.keys()])
|
|
1133
1843
|
this.dropPortal(name); }
|
|
1134
1844
|
async forwardSimple(text) {
|
|
1135
|
-
const reply =
|
|
1845
|
+
const reply = await this.catalogAs(frame("Q", cstr(text)));
|
|
1136
1846
|
const err = reply.find((f) => f.type === "E");
|
|
1137
1847
|
if (err !== undefined)
|
|
1138
1848
|
throw relayedError(err.frame);
|
|
1139
1849
|
return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
|
|
1140
1850
|
}
|
|
1851
|
+
/** SET and RESET as statements. SET LOCAL and SET TRANSACTION (`local`) outside a block are Postgres's WARNING, said
|
|
1852
|
+
* before the value is read, so an invalid value is refused after it. The value still holds to the end of its own
|
|
1853
|
+
* transaction: to the Sync, in a batch. */
|
|
1141
1854
|
async setStatement(t) {
|
|
1855
|
+
if (t.local && this.tx === "none" && !this.implicitBlock) {
|
|
1856
|
+
this.send(noticeResponse({ severity: "WARNING", code: "25P01", message: `${t.name === "transaction" ? "SET TRANSACTION" : "SET LOCAL"} can only be used in transaction blocks` }));
|
|
1857
|
+
}
|
|
1858
|
+
return this.applySetting(t);
|
|
1859
|
+
}
|
|
1860
|
+
/** A SET's effect, whoever asked (a statement, `set_config`): `vars` is moved, and a plain SET stays for the
|
|
1861
|
+
* transaction's COMMIT (`settingsTx`, the transaction's first one snapshots it). */
|
|
1862
|
+
async applySetting(t) {
|
|
1142
1863
|
if (t.name === "all") {
|
|
1143
|
-
this.
|
|
1864
|
+
await this.resetAll();
|
|
1144
1865
|
return [commandComplete("RESET")];
|
|
1145
1866
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1867
|
+
// SET ROLE and SET SESSION AUTHORIZATION: a session is its user and becomes no one else — RESET, NONE and its own
|
|
1868
|
+
// name are Postgres's no-ops, any other name Postgres's refusal. Never the catalog's to validate: that is one
|
|
1869
|
+
// superuser connection, which accepted `SET ROLE postgres` silently (a review finding of #35)
|
|
1870
|
+
if (t.name === "role" || t.name === "session_authorization") {
|
|
1871
|
+
// its own name, NONE, RESET — and SET ROLE to the role it already is (its own, or the roles file's default: what
|
|
1872
|
+
// current_user says), which Postgres always allows; SET SESSION AUTHORIZATION is the login's name alone
|
|
1873
|
+
const own = t.value === null || t.value === this.user || (t.name === "role" && (t.value.toLowerCase() === "none" || t.value === this.roleName()));
|
|
1874
|
+
if (!own)
|
|
1875
|
+
throw new PgError({ code: "42501", message: `permission denied to set ${t.name === "role" ? "role" : "session authorization"} "${t.value}"` }, 403);
|
|
1876
|
+
return [commandComplete(t.tag)];
|
|
1877
|
+
}
|
|
1878
|
+
// a user's attributes are the users file's (mason user add --set), never the session's to change: Postgres's refusal
|
|
1879
|
+
// of a parameter a role may not set
|
|
1880
|
+
if (t.name.toLowerCase().startsWith("mason.attr."))
|
|
1881
|
+
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);
|
|
1882
|
+
// a placeholder (`myapp.x`) is validated here: a SET the catalog validated left it DEFINED there, with '', for every
|
|
1883
|
+
// session (a `current_setting` of it answered '' where Postgres's is 42704) — its rollback undoes the value, not that
|
|
1884
|
+
const placeholder = await this.isPlaceholder(t.name);
|
|
1885
|
+
if (placeholder)
|
|
1886
|
+
placeholderName(t.name);
|
|
1887
|
+
else if (!this.decidesHere(t.name))
|
|
1148
1888
|
await this.validateElsewhere(t.name, t.value);
|
|
1149
|
-
|
|
1150
|
-
|
|
1889
|
+
// DateStyle: Postgres's check_datestyle decides its validity (22023 with its DETAIL) and the order a part not named
|
|
1890
|
+
// keeps, from the session's value (GitHub issue 253 review r1); Mason adds only its refusal of a non-ISO style
|
|
1891
|
+
const value = t.name === "datestyle" && t.value !== null ? await this.canonicalElsewhere("datestyle", t.value, this.sessionState().dateStyle) : t.value;
|
|
1892
|
+
const tx = this.settingsAtStart();
|
|
1893
|
+
if (value === null)
|
|
1894
|
+
await this.resetParameter(t.name);
|
|
1895
|
+
else
|
|
1896
|
+
this.setParameter(t.name, value === t.value ? value : isoDateStyle(value, t.value));
|
|
1897
|
+
// transaction_isolation is the transaction's alone, however it is SET (Postgres resets it as the transaction ends)
|
|
1898
|
+
if (!t.local && t.name !== "transaction_isolation")
|
|
1899
|
+
copySetting(t.name, this.vars, tx.commit);
|
|
1900
|
+
// once named a placeholder stays defined, '', whichever way the transaction ends: its definition is no value it undoes
|
|
1901
|
+
if (placeholder)
|
|
1902
|
+
for (const v of [tx.rollback, tx.commit])
|
|
1903
|
+
if (!v.other.has(t.name))
|
|
1904
|
+
v.other.set(t.name, "");
|
|
1905
|
+
return [commandComplete(t.tag)];
|
|
1906
|
+
}
|
|
1907
|
+
/** what the transaction found, kept at its first SET (`settingsTx`) */
|
|
1908
|
+
settingsAtStart() {
|
|
1909
|
+
return this.settingsTx ??= { rollback: copyVars(this.vars), commit: copyVars(this.vars) };
|
|
1910
|
+
}
|
|
1911
|
+
/** `SELECT set_config(name, value, is_local)` alone (translate.ts setConfig): the session's SET or SET LOCAL, answered
|
|
1912
|
+
* as Postgres answers the function — one text row, the value as `SHOW` then answers it (its own DataRow: one reader),
|
|
1913
|
+
* the column named `set_config` or as aliased. Its RowDescription is the caller's: a Query's, an Execute's Describe's.
|
|
1914
|
+
* Unlike SET LOCAL it warns of nothing outside a block: Postgres's function does not. */
|
|
1915
|
+
async setConfigStatement(t) {
|
|
1916
|
+
await this.applySetting({ kind: "set", name: t.name, value: t.value, local: t.local, tag: "SET" });
|
|
1917
|
+
const row = (await this.showStatement(t.name)).find((f) => f[0] === DATA_ROW);
|
|
1918
|
+
if (row === undefined)
|
|
1919
|
+
throw new PgError({ code: "XX000", message: `SHOW ${t.name} answered no row after set_config (Mason bug: report it)` }, 500);
|
|
1920
|
+
return [row, commandComplete("SELECT 1")];
|
|
1921
|
+
}
|
|
1922
|
+
setConfigDescription(t, format) {
|
|
1923
|
+
return rowDescription([fieldOf(t.column, "text", format)]);
|
|
1924
|
+
}
|
|
1925
|
+
/** DISCARD ALL is Postgres's CLOSE ALL, DEALLOCATE ALL and RESET ALL (its ParameterStatus with it) in one; PLANS,
|
|
1926
|
+
* SEQUENCES and TEMP find nothing of theirs here */
|
|
1927
|
+
async discard(target) {
|
|
1928
|
+
if (target !== "ALL")
|
|
1929
|
+
return [commandComplete(`DISCARD ${target}`)];
|
|
1930
|
+
if (this.tx !== "none" || this.implicitBlock)
|
|
1931
|
+
throw new PgError({ code: "25001", message: "DISCARD ALL cannot run inside a transaction block" });
|
|
1932
|
+
this.statements.clear();
|
|
1933
|
+
this.dropPortals();
|
|
1934
|
+
await this.resetAll();
|
|
1935
|
+
// it commits at once, what follows it in a batch being a transaction of its own (XACT_FLAGS_NEEDIMMEDIATECOMMIT)
|
|
1936
|
+
this.endTransaction(true);
|
|
1937
|
+
return [commandComplete("DISCARD ALL")];
|
|
1151
1938
|
}
|
|
1152
1939
|
decidesHere(name) {
|
|
1153
1940
|
return ["timezone", "time zone", "application_name", "search_path", "client_encoding", "names", "datestyle", "extra_float_digits",
|
|
1154
1941
|
"standard_conforming_strings", "intervalstyle", "transaction_isolation", "default_transaction_isolation", "transaction", "session characteristics",
|
|
1155
1942
|
"default_transaction_read_only", "transaction_read_only"].includes(name);
|
|
1156
1943
|
}
|
|
1944
|
+
/**
|
|
1945
|
+
* WHAT THIS SESSION ANSWERS FOR ONE PARAMETER, by its name in any case: `SHOW <name>`, SHOW ALL's row of that name and
|
|
1946
|
+
* the ParameterStatus the startup reports all read it, so a tool reads ONE string however it asks. The TLS parameters
|
|
1947
|
+
* first (`tlsParameters`), then those this server fixes (`fixedParameter`), then the session's own SETs (`vars.other`);
|
|
1948
|
+
* null for one the catalog Postgres answers as its own.
|
|
1949
|
+
*/
|
|
1950
|
+
sessionSetting(name) {
|
|
1951
|
+
const n = name.toLowerCase();
|
|
1952
|
+
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;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* EVERY PARAMETER THIS SESSION HOLDS, each `sessionSetting`'s: what `current_setting(name)` answers, in the catalog
|
|
1956
|
+
* (`catalogAs`, src/api/postgres/catalog.ts asSession) and beside a copied table (`duckSession`) — so it is the string
|
|
1957
|
+
* `SHOW <name>` answers, however a tool asks (GitHub issue 40). The names are those `sessionSetting` reads from.
|
|
1958
|
+
*/
|
|
1959
|
+
sessionSettings() {
|
|
1960
|
+
const names = new Set([...Object.keys(tlsParameters(this.socket instanceof tls.TLSSocket)), ...FIXED.keys(), ...this.vars.other.keys(),
|
|
1961
|
+
// the user's attributes, as `mason.attr.<name>` (a list's values joined by commas): what the catalog's row security
|
|
1962
|
+
// policies read (catalog.ts catalogPolicies), as Postgres's read `current_setting('mason.attr.tenant')`
|
|
1963
|
+
...Object.keys(this.token?.attributes ?? {}).map((a) => `mason.attr.${a}`)]);
|
|
1964
|
+
const out = new Map();
|
|
1965
|
+
for (const n of names) {
|
|
1966
|
+
const v = this.sessionSetting(n);
|
|
1967
|
+
if (v !== null)
|
|
1968
|
+
out.set(n, v);
|
|
1969
|
+
}
|
|
1970
|
+
return out;
|
|
1971
|
+
}
|
|
1157
1972
|
async showStatement(name) {
|
|
1158
1973
|
if (name === "all")
|
|
1159
|
-
|
|
1160
|
-
const value =
|
|
1974
|
+
return this.showAll();
|
|
1975
|
+
const value = this.sessionSetting(name);
|
|
1161
1976
|
if (value === null) {
|
|
1977
|
+
// a placeholder this session never SET is none of its settings, whatever the one catalog every session shares
|
|
1978
|
+
// has defined (a forwarded `set_config` defines one there): Postgres's 42704
|
|
1979
|
+
if (await this.isPlaceholder(name))
|
|
1980
|
+
throw new PgError({ code: "42704", message: `unrecognized configuration parameter "${name}"` });
|
|
1162
1981
|
const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(`SHOW ${quoteIdent(name)}`))));
|
|
1163
1982
|
const err = reply.find((f) => f.type === "E");
|
|
1164
1983
|
if (err !== undefined)
|
|
@@ -1167,6 +1986,31 @@ class Session {
|
|
|
1167
1986
|
}
|
|
1168
1987
|
return [rowDescription([fieldOf(spell(name), "text", 0)]), dataRow([Buffer.from(value, "utf8")]), commandComplete("SHOW")];
|
|
1169
1988
|
}
|
|
1989
|
+
/**
|
|
1990
|
+
* SHOW ALL (Postico sends it at every connection): the catalog Postgres's own answer — its RowDescription relayed as it
|
|
1991
|
+
* is (name, setting, description: text, as Postgres's), a row per parameter in its order — with the setting of each row
|
|
1992
|
+
* this session decides replaced by `sessionSetting`'s, so the row and `SHOW <name>` are one string. Mason's settings,
|
|
1993
|
+
* never the source database's (Mason gap show-all). A parameter Postgres's SHOW ALL leaves out is left out here too,
|
|
1994
|
+
* though the session set it or reports it: a placeholder (`SET myapp.x`), session_authorization, is_superuser, role
|
|
1995
|
+
* (GUC_NO_SHOW_ALL) — the catalog, a Postgres, lists exactly the names Postgres lists.
|
|
1996
|
+
*/
|
|
1997
|
+
async showAll() {
|
|
1998
|
+
const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr("SHOW ALL"))));
|
|
1999
|
+
const err = reply.find((f) => f.type === "E");
|
|
2000
|
+
if (err !== undefined)
|
|
2001
|
+
throw relayedError(err.frame);
|
|
2002
|
+
return reply.filter((f) => f.type !== "Z").map((f) => {
|
|
2003
|
+
if (f.type !== "D")
|
|
2004
|
+
return f.frame;
|
|
2005
|
+
const [row] = decodeBackend(f.frame);
|
|
2006
|
+
const name = row?.type === "DataRow" && row.values.length === 3 ? row.values[0] : null;
|
|
2007
|
+
if (row?.type !== "DataRow" || name === null || name === undefined) {
|
|
2008
|
+
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);
|
|
2009
|
+
}
|
|
2010
|
+
const value = this.sessionSetting(name.toString("utf8"));
|
|
2011
|
+
return value === null ? f.frame : dataRow([name, Buffer.from(value, "utf8"), row.values[2] ?? null]);
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
1170
2014
|
/** a SHOW's RowDescription, for a Describe: the frame its answer starts with (the column is named by the parameter) */
|
|
1171
2015
|
async showDescription(name) {
|
|
1172
2016
|
return (await this.showStatement(name)).filter((f) => f[0] === ROW_DESCRIPTION);
|
|
@@ -1178,10 +2022,12 @@ class Session {
|
|
|
1178
2022
|
this.tx = "open";
|
|
1179
2023
|
return [commandComplete(tag)];
|
|
1180
2024
|
}
|
|
2025
|
+
// the implicit transaction's end too: `SET a; ROLLBACK` in one Query undoes the SET, with the same warning
|
|
2026
|
+
const wasFailed = this.tx === "failed";
|
|
2027
|
+
this.endTransaction(tag === "COMMIT" && !wasFailed);
|
|
1181
2028
|
if (this.tx === "none") {
|
|
1182
2029
|
return [noticeResponse({ severity: "WARNING", code: "25P01", message: "there is no transaction in progress" }), commandComplete(tag)];
|
|
1183
2030
|
}
|
|
1184
|
-
const wasFailed = this.tx === "failed";
|
|
1185
2031
|
this.tx = "none";
|
|
1186
2032
|
// the transaction's "now" ends with it; a statement after it in the same Query begins the next at the Query's arrival
|
|
1187
2033
|
this.txNow = null;
|
|
@@ -1197,7 +2043,7 @@ class Session {
|
|
|
1197
2043
|
// an implicit transaction ends at Sync; an open one keeps its "now"
|
|
1198
2044
|
if (this.tx === "none")
|
|
1199
2045
|
this.txNow = null;
|
|
1200
|
-
this.send(
|
|
2046
|
+
this.send(...this.ready());
|
|
1201
2047
|
return;
|
|
1202
2048
|
}
|
|
1203
2049
|
if (this.skipToSync || m.type === "Flush")
|
|
@@ -1210,7 +2056,18 @@ class Session {
|
|
|
1210
2056
|
case "Parse": return this.sendAll(await this.parseMessage(m));
|
|
1211
2057
|
case "Bind": return this.sendAll(await this.bindMessage(m));
|
|
1212
2058
|
case "Describe": return this.sendAll(await this.describeMessage(m));
|
|
1213
|
-
case "Execute":
|
|
2059
|
+
case "Execute": {
|
|
2060
|
+
const out = await this.executeMessage(m);
|
|
2061
|
+
this.sendAll(out);
|
|
2062
|
+
// AN EXECUTE LEAVES ITS TRANSACTION OPEN UNTIL THE SYNC, and what follows it runs in an implicit block, as
|
|
2063
|
+
// Postgres 18's does, a portal suspended at a row count included: a later SET LOCAL warns of nothing, a later
|
|
2064
|
+
// DISCARD ALL is refused. Not after a transaction statement, which ended it, nor a DISCARD ALL, whose portal
|
|
2065
|
+
// it dropped
|
|
2066
|
+
const kind = this.portals.get(m.portal)?.statement.shape?.kind;
|
|
2067
|
+
if (kind !== undefined && kind !== "transaction")
|
|
2068
|
+
this.implicitBlock = true;
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
1214
2071
|
case "Close": return this.sendAll(await this.closeMessage(m));
|
|
1215
2072
|
}
|
|
1216
2073
|
}
|
|
@@ -1218,8 +2075,7 @@ class Session {
|
|
|
1218
2075
|
const e = asPgError(err, sql);
|
|
1219
2076
|
this.refuse(e, sql, m.type);
|
|
1220
2077
|
this.skipToSync = true;
|
|
1221
|
-
|
|
1222
|
-
this.tx = "failed";
|
|
2078
|
+
this.abortTransaction();
|
|
1223
2079
|
}
|
|
1224
2080
|
}
|
|
1225
2081
|
sqlOf(m) {
|
|
@@ -1238,7 +2094,7 @@ class Session {
|
|
|
1238
2094
|
}
|
|
1239
2095
|
/** runs frames in the sidecar; throws the first ErrorResponse; returns the rest, less ReadyForQuery */
|
|
1240
2096
|
async catalogRun(...parts) {
|
|
1241
|
-
const reply =
|
|
2097
|
+
const reply = await this.catalogAs(Buffer.concat(parts));
|
|
1242
2098
|
// the sidecar answered NOTHING to a batch: it is ignoring everything until a Sync (an earlier error nobody synced)
|
|
1243
2099
|
// — a protocol state this server must never leave it in; said, never relayed as an empty answer
|
|
1244
2100
|
if (reply.length === 0)
|
|
@@ -1246,26 +2102,27 @@ class Session {
|
|
|
1246
2102
|
const err = reply.find((f) => f.type === "E");
|
|
1247
2103
|
if (err !== undefined) {
|
|
1248
2104
|
// 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"));
|
|
2105
|
+
// sidecar is one connection every session shares: one refused Parse with no Sync after it silenced the catalog for
|
|
2106
|
+
// every session until a restart (review of #480). Every request carries one (catalog.ts asSession: the RESET of
|
|
2107
|
+
// the session's settings after it must run), its ReadyForQuery dropped here
|
|
1253
2108
|
throw relayedError(err.frame);
|
|
1254
2109
|
}
|
|
1255
2110
|
return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
|
|
1256
2111
|
}
|
|
1257
2112
|
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
2113
|
const stmts = (await parseSql(m.sql)).stmts ?? [];
|
|
1261
2114
|
if (stmts.length > 1)
|
|
1262
2115
|
throw new PgError({ code: "42601", message: "cannot insert multiple commands into a prepared statement" });
|
|
1263
2116
|
const raw = stmts[0] ?? null;
|
|
2117
|
+
if (raw?.stmt !== undefined)
|
|
2118
|
+
this.heldToFailedBlock(raw.stmt);
|
|
2119
|
+
if (m.name !== "" && this.statements.has(m.name))
|
|
2120
|
+
throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
|
|
1264
2121
|
const statement = { sql: m.sql, raw, paramOids: [...m.paramTypes], shape: null, catalogName: null };
|
|
1265
2122
|
if (raw?.stmt !== undefined) {
|
|
1266
|
-
statement.shape = this.classify(raw.stmt, statementText(m.sql, raw));
|
|
2123
|
+
statement.shape = await this.routed(this.classify(raw.stmt, statementText(m.sql, raw)), statementText(m.sql, raw), m.paramTypes, true);
|
|
1267
2124
|
if (statement.shape.kind === "explain") {
|
|
1268
|
-
const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes);
|
|
2125
|
+
const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes, true);
|
|
1269
2126
|
if (e.plan === null)
|
|
1270
2127
|
statement.explainCatalog = e.options;
|
|
1271
2128
|
else {
|
|
@@ -1275,8 +2132,9 @@ class Session {
|
|
|
1275
2132
|
}
|
|
1276
2133
|
// Postgres's analysis says the result's columns and the parameters' types, and DuckDB's SQL is made from it
|
|
1277
2134
|
if (statement.shape.kind === "duckdb") {
|
|
1278
|
-
|
|
1279
|
-
statement.
|
|
2135
|
+
const under = this.sessionState();
|
|
2136
|
+
statement.duck = await this.duckPlan(raw, m.sql, m.paramTypes, under);
|
|
2137
|
+
statement.plannedUnder = under;
|
|
1280
2138
|
// the implicit transaction begins with the Parse, as Postgres's does (start_xact_command): its literals read it
|
|
1281
2139
|
statement.parsedNow = this.txNow ??= this.server.now();
|
|
1282
2140
|
}
|
|
@@ -1285,7 +2143,7 @@ class Session {
|
|
|
1285
2143
|
const own = this.statements.get(m.name);
|
|
1286
2144
|
// the unnamed statement is replaced by a Parse; the sidecar's must be closed first
|
|
1287
2145
|
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)));
|
|
2146
|
+
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
2147
|
this.statements.set(m.name, statement);
|
|
1290
2148
|
// the CloseComplete of the close this server added is not the client's
|
|
1291
2149
|
return reply.slice(close.length);
|
|
@@ -1298,6 +2156,8 @@ class Session {
|
|
|
1298
2156
|
const statement = this.statements.get(m.statement);
|
|
1299
2157
|
if (statement === undefined)
|
|
1300
2158
|
throw new PgError({ code: "26000", message: `prepared statement "${m.statement}" does not exist` });
|
|
2159
|
+
// before anything runs: a catalog statement is run whole at its Bind
|
|
2160
|
+
this.heldToFailedBlock(statement.raw?.stmt);
|
|
1301
2161
|
if (m.portal !== "" && this.portals.has(m.portal))
|
|
1302
2162
|
throw new PgError({ code: "42P03", message: `cursor "${m.portal}" already exists` });
|
|
1303
2163
|
if (statement.catalogName !== null) {
|
|
@@ -1314,10 +2174,11 @@ class Session {
|
|
|
1314
2174
|
}
|
|
1315
2175
|
this.dropPortal(m.portal);
|
|
1316
2176
|
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,
|
|
2177
|
+
this.portals.set(m.portal, { statement, params: [], boundAt: this.server.now(), resultFormats: m.resultFormats, rows: null, sent: 0, fields: null, bytes: 0,
|
|
1318
2178
|
catalog: { description, rows: statement.explainCatalog === undefined ? rows : catalogExplained(rows, statement.explainCatalog), sent: 0, done } });
|
|
1319
2179
|
return [bound];
|
|
1320
2180
|
}
|
|
2181
|
+
await this.plannedAgainIfMoved(statement);
|
|
1321
2182
|
const planned = statement.duck ?? statement.explain?.plan;
|
|
1322
2183
|
const expected = planned !== undefined ? planned.params.length : 0;
|
|
1323
2184
|
if (m.params.length !== expected)
|
|
@@ -1328,6 +2189,9 @@ class Session {
|
|
|
1328
2189
|
const format = m.paramFormats.length === 0 ? 0 : m.paramFormats.length === 1 ? m.paramFormats[0] : m.paramFormats[i] ?? 0;
|
|
1329
2190
|
if (format === 0)
|
|
1330
2191
|
return p.toString("utf8");
|
|
2192
|
+
const oid = statement.paramOids[i] || planned?.params[i] || 0;
|
|
2193
|
+
if (TEXT_ARRAYS.has(oid))
|
|
2194
|
+
return textArrayFromBinary(p, i, oid === 1015 ? 1043 : 25);
|
|
1331
2195
|
const type = oidOf(statement.paramOids[i] ?? 0) ?? (planned !== undefined ? oidOf(planned.params[i] ?? 0) : undefined) ?? "text";
|
|
1332
2196
|
return decodeBinaryParam(type, p, i);
|
|
1333
2197
|
});
|
|
@@ -1335,7 +2199,7 @@ class Session {
|
|
|
1335
2199
|
const boundAt = this.server.now();
|
|
1336
2200
|
this.txNow ??= boundAt;
|
|
1337
2201
|
this.dropPortal(m.portal);
|
|
1338
|
-
this.portals.set(m.portal, { statement, params, boundAt, resultFormats: m.resultFormats, rows: null, sent: 0, fields: null });
|
|
2202
|
+
this.portals.set(m.portal, { statement, params, boundAt, resultFormats: m.resultFormats, rows: null, sent: 0, fields: null, bytes: 0 });
|
|
1339
2203
|
return [bindComplete()];
|
|
1340
2204
|
}
|
|
1341
2205
|
async describeMessage(m) {
|
|
@@ -1343,11 +2207,14 @@ class Session {
|
|
|
1343
2207
|
const statement = this.statements.get(m.name);
|
|
1344
2208
|
if (statement === undefined)
|
|
1345
2209
|
throw new PgError({ code: "26000", message: `prepared statement "${m.name}" does not exist` });
|
|
2210
|
+
this.describedInFailedBlock(statement);
|
|
1346
2211
|
if (statement.catalogName !== null)
|
|
1347
2212
|
return this.catalogRun(frame("D", Buffer.from("S"), cstr(statement.catalogName)));
|
|
1348
2213
|
const shape = statement.shape;
|
|
1349
2214
|
if (shape?.kind === "show")
|
|
1350
2215
|
return [parameterDescription([]), ...await this.showDescription(shape.name)];
|
|
2216
|
+
if (shape?.kind === "set_config")
|
|
2217
|
+
return [parameterDescription([]), this.setConfigDescription(shape, 0)];
|
|
1351
2218
|
if (statement.explain !== undefined) {
|
|
1352
2219
|
const e = statement.explain;
|
|
1353
2220
|
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 +2229,14 @@ class Session {
|
|
|
1362
2229
|
const portal = this.portals.get(m.name);
|
|
1363
2230
|
if (portal === undefined)
|
|
1364
2231
|
throw new PgError({ code: "34000", message: `portal "${m.name}" does not exist` });
|
|
2232
|
+
this.describedInFailedBlock(portal.statement);
|
|
1365
2233
|
if (portal.catalog !== undefined)
|
|
1366
2234
|
return [portal.catalog.description];
|
|
1367
2235
|
const shape = portal.statement.shape;
|
|
1368
2236
|
if (shape?.kind === "show")
|
|
1369
2237
|
return this.showDescription(shape.name);
|
|
2238
|
+
if (shape?.kind === "set_config")
|
|
2239
|
+
return [this.setConfigDescription(shape, formatAt(portal.resultFormats, 0))];
|
|
1370
2240
|
if (portal.statement.explain !== undefined)
|
|
1371
2241
|
return [rowDescription([explainField(portal.statement.explain.options, formatAt(portal.resultFormats, 0))])];
|
|
1372
2242
|
const d = portal.statement.duck;
|
|
@@ -1378,6 +2248,11 @@ class Session {
|
|
|
1378
2248
|
const portal = this.portals.get(m.portal);
|
|
1379
2249
|
if (portal === undefined)
|
|
1380
2250
|
throw new PgError({ code: "34000", message: `portal "${m.portal}" does not exist` });
|
|
2251
|
+
const statement = portal.statement;
|
|
2252
|
+
if (statement.raw?.stmt === undefined)
|
|
2253
|
+
return [emptyQueryResponse()];
|
|
2254
|
+
// a catalog portal's rows are held here since its Bind: a block that failed after it answers none of them
|
|
2255
|
+
this.heldToFailedBlock(statement.raw.stmt);
|
|
1381
2256
|
if (portal.catalog !== undefined) {
|
|
1382
2257
|
const c = portal.catalog;
|
|
1383
2258
|
const take = m.maxRows > 0 ? Math.min(m.maxRows, c.rows.length - c.sent) : c.rows.length - c.sent;
|
|
@@ -1385,24 +2260,19 @@ class Session {
|
|
|
1385
2260
|
c.sent += take;
|
|
1386
2261
|
return c.sent < c.rows.length ? [...out, portalSuspended()] : [...out, c.done];
|
|
1387
2262
|
}
|
|
1388
|
-
|
|
1389
|
-
if (
|
|
1390
|
-
|
|
2263
|
+
// what its earlier Executes sent is this one's row's too (`bytesFor`)
|
|
2264
|
+
if (this.asked !== null)
|
|
2265
|
+
this.asked.carried = portal.bytes;
|
|
1391
2266
|
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
2267
|
switch (t.kind) {
|
|
1396
2268
|
case "empty": return [emptyQueryResponse()];
|
|
1397
2269
|
case "catalog": throw new PgError({ code: "XX000", message: "a catalog statement bound as a query (Mason bug: report it)" }, 500);
|
|
1398
2270
|
case "set": return this.setStatement(t);
|
|
2271
|
+
case "set_config": return this.setConfigStatement(t);
|
|
1399
2272
|
// as Postgres: the extended protocol's Execute sends the rows and the tag, the RowDescription is the Describe's
|
|
1400
2273
|
case "show": return (await this.showStatement(t.name)).filter((f) => f[0] !== ROW_DESCRIPTION);
|
|
1401
2274
|
case "transaction": return this.transaction(t.tag);
|
|
1402
|
-
case "discard":
|
|
1403
|
-
this.statements.clear();
|
|
1404
|
-
this.dropPortals();
|
|
1405
|
-
return [commandComplete("DISCARD ALL")];
|
|
2275
|
+
case "discard": return this.discard(t.target);
|
|
1406
2276
|
case "deallocate":
|
|
1407
2277
|
this.statements.clear();
|
|
1408
2278
|
return [commandComplete("DEALLOCATE ALL")];
|
|
@@ -1436,17 +2306,52 @@ class Session {
|
|
|
1436
2306
|
const out = rows.slice(portal.sent, portal.sent + take);
|
|
1437
2307
|
portal.sent += take;
|
|
1438
2308
|
if (portal.sent < rows.length)
|
|
1439
|
-
return [...out, portalSuspended()];
|
|
1440
|
-
|
|
1441
|
-
|
|
2309
|
+
return this.pageSent(portal, [...out, portalSuspended()]);
|
|
2310
|
+
const done = [...out, commandComplete("EXPLAIN")];
|
|
2311
|
+
this.recordRequest({ rows: rows.length }, undefined, undefined, done);
|
|
2312
|
+
return done;
|
|
1442
2313
|
}
|
|
1443
2314
|
}
|
|
1444
2315
|
}
|
|
2316
|
+
/**
|
|
2317
|
+
* THE BYTES SENT FOR THE STATEMENT BEING ASKED, `then` (the frames that end it, sent just after) among them: what its
|
|
2318
|
+
* portal's earlier Executes sent (`carried`), and what the session was written since this message arrived — exact,
|
|
2319
|
+
* since one message is handled at a time and a portal read aside meanwhile is read, never sent (`readAside`). Never
|
|
2320
|
+
* the session's bytes since a portal's first Execute: another portal's rows sent between two of its Executes were
|
|
2321
|
+
* counted in both rows.
|
|
2322
|
+
*/
|
|
2323
|
+
bytesFor(then, asked = this.asked) {
|
|
2324
|
+
return (asked?.carried ?? 0) + this.sentBytes - (asked?.sentAt ?? this.sentBytes) + then.reduce((n, b) => n + b.length, 0);
|
|
2325
|
+
}
|
|
2326
|
+
/** a page of a portal, suspended: what it sends is kept on the portal (`Portal.bytes`) for its last Execute's row */
|
|
2327
|
+
pageSent(portal, frames) {
|
|
2328
|
+
portal.bytes = this.bytesFor(frames);
|
|
2329
|
+
return frames;
|
|
2330
|
+
}
|
|
1445
2331
|
/** the statement being answered reads these JSON paths: its request log row says so, answered or failed */
|
|
1446
2332
|
readsOf(plan) {
|
|
1447
2333
|
if (this.asked !== null && plan.jsonReads !== undefined)
|
|
1448
2334
|
this.asked.jsonReads = plan.jsonReads;
|
|
1449
2335
|
}
|
|
2336
|
+
/**
|
|
2337
|
+
* THE SEARCH_PATH MOVED SINCE A STATEMENT WAS PLANNED: at its Bind it is planned again under the session's state now,
|
|
2338
|
+
* as Postgres's plancache plans a cached statement again (`GetCachedPlan` in exec_bind_message; GitHub issue 63) —
|
|
2339
|
+
* never run against tables named under another path. Its parameters keep the types the Parse fixed; a result that
|
|
2340
|
+
* moved (names, types, count) is Postgres's 0A000, and a refusal of the new plan (42P01) is the Bind's, as Postgres's.
|
|
2341
|
+
* The portal keeps the plan it was bound with. The zone alone plans nothing again: the plan reads it at execution.
|
|
2342
|
+
*/
|
|
2343
|
+
async plannedAgainIfMoved(statement) {
|
|
2344
|
+
const now = this.sessionState();
|
|
2345
|
+
if (statement.raw === null || statement.duck === undefined || statement.plannedUnder === undefined || statement.plannedUnder.searchPath === now.searchPath)
|
|
2346
|
+
return;
|
|
2347
|
+
const again = await this.duckPlan(statement.raw, statement.sql, statement.duck.params, now);
|
|
2348
|
+
const result = (p) => JSON.stringify(p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null);
|
|
2349
|
+
if (result(again) !== result(statement.duck))
|
|
2350
|
+
throw new PgError({ code: "0A000", message: "cached plan must not change result type" });
|
|
2351
|
+
statement.duck = again;
|
|
2352
|
+
statement.plannedUnder = now;
|
|
2353
|
+
delete statement.duckOnWire;
|
|
2354
|
+
}
|
|
1450
2355
|
/** an Execute of a DuckDB statement's portal: its cursor begun at the first, then paged or streamed */
|
|
1451
2356
|
async executeDuck(m, portal, statement, controller) {
|
|
1452
2357
|
// each Execute of a paged portal is a message of its own: the last one's row in the request log carries the reads
|
|
@@ -1454,7 +2359,17 @@ class Session {
|
|
|
1454
2359
|
this.readsOf(statement.duck);
|
|
1455
2360
|
if (portal.cursor === undefined) {
|
|
1456
2361
|
if (statement.duck === undefined)
|
|
1457
|
-
throw new PgError({ code: "XX000", message: "a
|
|
2362
|
+
throw new PgError({ code: "XX000", message: "a statement without its plan: report it (Mason's Postgres listener)" });
|
|
2363
|
+
// A ROLE'S ROW FILTERS MOVED SINCE THE PARSE (the roles file edited): planned again under them, as Postgres plans a
|
|
2364
|
+
// prepared statement again after its policies change — where its columns and parameters are what the Parse said
|
|
2365
|
+
if (statement.raw !== null && (statement.duck.rowsKey ?? "") !== rowsKeyOf(this.grants())) {
|
|
2366
|
+
const again = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.sessionState());
|
|
2367
|
+
const shape = (p) => JSON.stringify([p.params, p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null]);
|
|
2368
|
+
if (shape(again) === shape(statement.duck)) {
|
|
2369
|
+
statement.duck = again;
|
|
2370
|
+
delete statement.duckOnWire;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
1458
2373
|
// a row count on the wire bounds the rows: the plan made for it where it differs (boundOnWire)
|
|
1459
2374
|
const plan = m.maxRows > 0 ? await this.boundOnWire(statement, statement.duck, controller) : statement.duck;
|
|
1460
2375
|
const exec = this.txNow ??= this.server.now();
|
|
@@ -1476,23 +2391,25 @@ class Session {
|
|
|
1476
2391
|
c.answer.close();
|
|
1477
2392
|
}
|
|
1478
2393
|
c.recorded = true;
|
|
1479
|
-
|
|
1480
|
-
|
|
2394
|
+
const done = [commandComplete(`SELECT ${c.sent}`)];
|
|
2395
|
+
this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, undefined, undefined, done);
|
|
2396
|
+
return done;
|
|
1481
2397
|
}
|
|
1482
2398
|
// at most maxRows (JDBC's fetch size, a cursor): suspended while a row is left, read one chunk ahead to know it
|
|
1483
2399
|
while (!c.done && c.pending.length <= m.maxRows)
|
|
1484
2400
|
await this.fill(c);
|
|
1485
2401
|
const out = c.pending.splice(0, m.maxRows);
|
|
1486
2402
|
c.sent += out.length;
|
|
1487
|
-
// suspended: its row is written at its last Execute, or when it is dropped (dropPortal)
|
|
2403
|
+
// suspended: its row is written at its last Execute, or when it is dropped (dropPortal), with what its pages sent
|
|
1488
2404
|
if (c.pending.length > 0) {
|
|
1489
2405
|
c.suspended = true;
|
|
1490
|
-
return [...out, portalSuspended()];
|
|
2406
|
+
return this.pageSent(portal, [...out, portalSuspended()]);
|
|
1491
2407
|
}
|
|
1492
2408
|
c.answer.close();
|
|
1493
2409
|
c.recorded = true;
|
|
1494
|
-
|
|
1495
|
-
|
|
2410
|
+
const done = [...out, commandComplete(`SELECT ${c.sent}`)];
|
|
2411
|
+
this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, undefined, undefined, done);
|
|
2412
|
+
return done;
|
|
1496
2413
|
}
|
|
1497
2414
|
catch (e) {
|
|
1498
2415
|
// its refusal is its row (refuse)
|
|
@@ -1519,15 +2436,19 @@ class Session {
|
|
|
1519
2436
|
* refuses this statement only (recorded by `refuse`).
|
|
1520
2437
|
*/
|
|
1521
2438
|
async stillLive(asked) {
|
|
1522
|
-
const recheck = this.
|
|
2439
|
+
const recheck = this.door.recheck;
|
|
1523
2440
|
const user = this.token?.user;
|
|
1524
2441
|
if (recheck === undefined || user === undefined)
|
|
1525
2442
|
return null;
|
|
1526
2443
|
const now = this.server.now();
|
|
1527
|
-
|
|
2444
|
+
// the instant is microseconds (src/lib/instant.ts), the interval milliseconds
|
|
2445
|
+
if (now - this.verifiedAt < this.door.recheckMs * 1000)
|
|
1528
2446
|
return null;
|
|
1529
2447
|
try {
|
|
1530
|
-
|
|
2448
|
+
// the user as the file has it NOW: its role given or taken since applies to this statement
|
|
2449
|
+
const fresh = await recheck(this.token);
|
|
2450
|
+
if (fresh !== undefined && this.token !== null)
|
|
2451
|
+
this.token = fresh;
|
|
1531
2452
|
}
|
|
1532
2453
|
catch (err) {
|
|
1533
2454
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1536,7 +2457,9 @@ class Session {
|
|
|
1536
2457
|
return new PgError({ code: "57P03", message, hint: "run the statement again once Mason can read its list of users" }, 503);
|
|
1537
2458
|
if (err instanceof ApiError)
|
|
1538
2459
|
this.recordRefusal(asked.toUpperCase(), err);
|
|
1539
|
-
this.fatal("28000", `the session's user is no longer accepted: ${message}`,
|
|
2460
|
+
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
|
|
2461
|
+
? `a disabled user's sessions end at their next statement; mason user enable ${user} lets it connect again, with the same password`
|
|
2462
|
+
: `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
|
|
1540
2463
|
return null;
|
|
1541
2464
|
}
|
|
1542
2465
|
this.verifiedAt = now;
|
|
@@ -1559,6 +2482,100 @@ const formatAt = (formats, i) => (formats.length === 0 ? 0 : formats.length ===
|
|
|
1559
2482
|
const isUtf8 = (encoding) => /^(utf-?8|unicode)$/i.test(encoding.trim());
|
|
1560
2483
|
const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
1561
2484
|
const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
2485
|
+
/** the SQLValueFunction ops that name a user, each with its keyword (the text at its location, and its column's name) */
|
|
2486
|
+
const USER_OPS = { SVFOP_SESSION_USER: "session_user", SVFOP_CURRENT_USER: "current_user", SVFOP_CURRENT_ROLE: "current_role", SVFOP_USER: "user" };
|
|
2487
|
+
/** every session: `session_user` alone (`current_user` is the catalog's role: the session's role under SET ROLE) */
|
|
2488
|
+
const SESSION_OPS = new Set(["SVFOP_SESSION_USER"]);
|
|
2489
|
+
/** a user the roles file holds to nothing: its catalog role is `NO_ROLE_CATALOG`, never a name it should answer */
|
|
2490
|
+
const HELD_TO_NOTHING_OPS = new Set(Object.keys(USER_OPS));
|
|
2491
|
+
/** the functions Mason overrides in the catalog's `public` (src/api/postgres/catalog.ts currentSettingSql,
|
|
2492
|
+
* sourceTypeNames; `version()` in `Server`'s setup), each with what a call of it may be written with: `pg_catalog.` too,
|
|
2493
|
+
* or unqualified only (`pg_catalog.format_type`, written out, is Postgres's own) */
|
|
2494
|
+
const OVERRIDES = { version: { qualified: true }, current_setting: { qualified: true }, format_type: { qualified: false } };
|
|
2495
|
+
/**
|
|
2496
|
+
* WHAT A STATEMENT THE CATALOG ANSWERS READS OF ITS SESSION, spliced in its text at the parse tree's locations:
|
|
2497
|
+
* - `session_user` IS THIS SESSION'S USER, never the catalog's superuser (it answered `postgres` for every session while
|
|
2498
|
+
* `SHOW session_authorization` said the user's name: a review finding of #35). The keyword cannot be overridden as
|
|
2499
|
+
* `version()` and `current_setting` are, so each one of `ops` is spliced with a scalar subquery named as the keyword
|
|
2500
|
+
* names its column, `(SELECT 'agent'::name AS session_user)`: the name, the type and every expression around it stay
|
|
2501
|
+
* Postgres's. `current_user`, `current_role` and `user` are the catalog's role (the session's role under SET ROLE,
|
|
2502
|
+
* src/api/postgres/catalog.ts asRole), spliced only for a user the roles file holds to nothing
|
|
2503
|
+
* (`HELD_TO_NOTHING_OPS`), whose catalog role is `NO_ROLE_CATALOG`: Postgres answers its own name for all three to a
|
|
2504
|
+
* login with no grant.
|
|
2505
|
+
* - EVERY CALL OF AN OVERRIDE (`OVERRIDES`: `version()`, `current_setting(…)`, `format_type(…)`), however written, is
|
|
2506
|
+
* the override's (`public.<name>`): the catalog runs a request under the session's search_path, which puts pg_catalog
|
|
2507
|
+
* first as Postgres's does (GitHub issue 253), so the override is reached by name, never by the path's order — and
|
|
2508
|
+
* Postgres's own `pg_catalog.current_setting` is called by its OID's C function whatever its catalog row says, so it
|
|
2509
|
+
* cannot be replaced (GitHub issue 40). The call is read from the parse tree's FuncCall, never from the text.
|
|
2510
|
+
* The clock is never spliced: the catalog's transaction begins at the session's instant (catalog.ts asSession), so
|
|
2511
|
+
* every reader of it is the session's as written (review r3 of PR 333).
|
|
2512
|
+
* `base`: the statement's offset in the text the parse tree's locations count from.
|
|
2513
|
+
*/
|
|
2514
|
+
function userSpliced(text, stmt, base, user, ops) {
|
|
2515
|
+
const at = new Map();
|
|
2516
|
+
const walk = (n) => {
|
|
2517
|
+
if (Array.isArray(n)) {
|
|
2518
|
+
for (const x of n)
|
|
2519
|
+
walk(x);
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
if (typeof n !== "object" || n === null)
|
|
2523
|
+
return;
|
|
2524
|
+
const f = n.SQLValueFunction;
|
|
2525
|
+
if (f !== undefined && f.op !== undefined && typeof f.location === "number" && ops.has(f.op))
|
|
2526
|
+
at.set(f.location, { kind: "user", keyword: USER_OPS[f.op] });
|
|
2527
|
+
const c = n.FuncCall;
|
|
2528
|
+
const name = c?.funcname?.map((x) => x.String?.sval ?? "");
|
|
2529
|
+
if (c !== undefined && name !== undefined && typeof c.location === "number" && c.over === undefined && (name.length === 1 || (name.length === 2 && name[0] === "pg_catalog"))) {
|
|
2530
|
+
const fn = name.at(-1);
|
|
2531
|
+
const override = OVERRIDES[fn];
|
|
2532
|
+
if (override !== undefined && (name.length === 1 || override.qualified))
|
|
2533
|
+
at.set(c.location, { kind: "override", name: fn });
|
|
2534
|
+
}
|
|
2535
|
+
for (const v of Object.values(n))
|
|
2536
|
+
walk(v);
|
|
2537
|
+
};
|
|
2538
|
+
walk(stmt);
|
|
2539
|
+
if (at.size === 0)
|
|
2540
|
+
return text;
|
|
2541
|
+
const bytes = Buffer.from(text, "utf8");
|
|
2542
|
+
const parts = [];
|
|
2543
|
+
let end = bytes.length;
|
|
2544
|
+
const qualified = (name) => new RegExp(`^(?:(?:pg_catalog|"pg_catalog")\\s*\\.\\s*)?(?:${name}|"${name}")(?![A-Za-z0-9_$])`, "i");
|
|
2545
|
+
for (const loc of [...at.keys()].sort((x, y) => y - x)) {
|
|
2546
|
+
const splice = at.get(loc);
|
|
2547
|
+
const from = loc - base;
|
|
2548
|
+
if (from < 0 || from >= end)
|
|
2549
|
+
continue;
|
|
2550
|
+
const ahead = bytes.subarray(from, Math.min(end, from + 128)).toString("utf8");
|
|
2551
|
+
let written, put;
|
|
2552
|
+
if (splice.kind === "override") {
|
|
2553
|
+
// the name as written (`version`, `pg_catalog.current_setting`, `"pg_catalog" . "version"`): its span, never a guess
|
|
2554
|
+
const m = qualified(splice.name).exec(ahead);
|
|
2555
|
+
if (m === null)
|
|
2556
|
+
continue;
|
|
2557
|
+
written = m[0];
|
|
2558
|
+
put = `public.${splice.name}`;
|
|
2559
|
+
}
|
|
2560
|
+
else {
|
|
2561
|
+
const keyword = splice.keyword;
|
|
2562
|
+
if (ahead.slice(0, keyword.length).toLowerCase() !== keyword)
|
|
2563
|
+
continue;
|
|
2564
|
+
// the keyword must end there (`user` is the start of `user_name`): the parser placed it, so a word character after is not it
|
|
2565
|
+
if (/^[A-Za-z0-9_$]/.test(ahead.slice(keyword.length, keyword.length + 1)))
|
|
2566
|
+
continue;
|
|
2567
|
+
written = keyword;
|
|
2568
|
+
put = `(SELECT ${quoteLiteral(user)}::name AS ${keyword})`;
|
|
2569
|
+
}
|
|
2570
|
+
parts.unshift(bytes.subarray(from + Buffer.byteLength(written), end));
|
|
2571
|
+
parts.unshift(Buffer.from(put, "utf8"));
|
|
2572
|
+
end = from;
|
|
2573
|
+
}
|
|
2574
|
+
parts.unshift(bytes.subarray(0, end));
|
|
2575
|
+
return Buffer.concat(parts).toString("utf8");
|
|
2576
|
+
}
|
|
2577
|
+
/** the splice of a statement the catalog answers, exported for its unit tests (tests/postgres-api/server.test.ts) */
|
|
2578
|
+
export const userSplicedForTest = userSpliced;
|
|
1562
2579
|
/** the text of one statement of a simple query, from the parser's byte offsets */
|
|
1563
2580
|
function statementText(sql, raw) {
|
|
1564
2581
|
const bytes = Buffer.from(sql, "utf8");
|
|
@@ -1568,6 +2585,114 @@ function statementText(sql, raw) {
|
|
|
1568
2585
|
}
|
|
1569
2586
|
const oidOf = (oid) => Object.keys(OID).find((t) => OID[t].oid === oid);
|
|
1570
2587
|
const PG_EPOCH_MS = Date.UTC(2000, 0, 1);
|
|
2588
|
+
/** an element type's name, as array_recv's 42804 says it */
|
|
2589
|
+
const ELEMENT_NAMES = { 25: "text", 1043: "character varying", 16: "boolean", 20: "bigint", 21: "smallint", 23: "integer", 700: "real", 701: "double precision", 1700: "numeric", 2950: "uuid", 1082: "date", 1114: "timestamp without time zone", 1184: "timestamp with time zone", 3802: "jsonb", 114: "json" };
|
|
2590
|
+
/** text[] and varchar[], the array types whose binary form a Bind is decoded from (textArrayFromBinary) */
|
|
2591
|
+
const TEXT_ARRAYS = new Set([1009, 1015]);
|
|
2592
|
+
/**
|
|
2593
|
+
* A BINARY text[] (or varchar[]) PARAMETER, as the literal array_in reads to the same array (Npgsql sends every
|
|
2594
|
+
* parameter binary): array_send's form, its dimensions, a NULL flag, the element type, each dimension's length and lower
|
|
2595
|
+
* bound, then each element's length (-1 a NULL) and bytes. Every element is quoted, so any character is itself; a lower
|
|
2596
|
+
* bound other than 1 and a second dimension are written as array_in reads them, and refused where every text[] value is
|
|
2597
|
+
* (src/duck/analyze.ts readParameters). Bytes that are no such array are refused as array_recv refuses them, in its
|
|
2598
|
+
* order: too few 08P01 (22P03 within an element), flags other than 0 or 1 22P03, an element type other than the
|
|
2599
|
+
* parameter's own 42804, a negative or too large dimension 54000, an element that is no UTF-8 or holds a NUL 22021, too
|
|
2600
|
+
* many bytes 22P03 naming the parameter.
|
|
2601
|
+
*/
|
|
2602
|
+
function textArrayFromBinary(bytes, i, expected) {
|
|
2603
|
+
const bad = () => new PgError({ code: "22P03", message: `incorrect binary data format in bind parameter ${i + 1}` });
|
|
2604
|
+
const short = () => new PgError({ code: "08P01", message: "insufficient data left in message" });
|
|
2605
|
+
let at = 0;
|
|
2606
|
+
const int = () => { if (at + 4 > bytes.length)
|
|
2607
|
+
throw short(); const v = bytes.readInt32BE(at); at += 4; return v; };
|
|
2608
|
+
const ndim = int();
|
|
2609
|
+
if (ndim < 0 || ndim > 6)
|
|
2610
|
+
throw new PgError({ code: ndim < 0 ? "22P03" : "54000", message: ndim < 0 ? `invalid number of dimensions: ${ndim}` : `number of array dimensions (${ndim}) exceeds the maximum allowed (6)` });
|
|
2611
|
+
// array_recv's order: the dimensions, the flags, the element type, then each dimension's size
|
|
2612
|
+
const flags = int();
|
|
2613
|
+
if (flags !== 0 && flags !== 1)
|
|
2614
|
+
throw new PgError({ code: "22P03", message: "invalid array flags" });
|
|
2615
|
+
const element = int() >>> 0;
|
|
2616
|
+
if (element !== expected) {
|
|
2617
|
+
throw new PgError({ code: "42804", message: `binary data has array element type ${element} (${ELEMENT_NAMES[element] ?? "another type"}) instead of expected ${expected} (${ELEMENT_NAMES[expected]})` });
|
|
2618
|
+
}
|
|
2619
|
+
const dims = [];
|
|
2620
|
+
for (let d = 0; d < ndim; d++) {
|
|
2621
|
+
const length = int();
|
|
2622
|
+
dims.push({ length, lower: int() });
|
|
2623
|
+
}
|
|
2624
|
+
// ArrayGetNItems: a negative dimension, a product past an int32 at any step, or past MaxArraySize at the end
|
|
2625
|
+
const MAX_ARRAY_SIZE = 268435455;
|
|
2626
|
+
const tooBig = () => new PgError({ code: "54000", message: `array size exceeds the maximum allowed (${MAX_ARRAY_SIZE})` });
|
|
2627
|
+
let items = 1;
|
|
2628
|
+
for (const d of dims) {
|
|
2629
|
+
if (d.length < 0)
|
|
2630
|
+
throw tooBig();
|
|
2631
|
+
items *= d.length;
|
|
2632
|
+
if (items > 0x7fffffff)
|
|
2633
|
+
throw tooBig();
|
|
2634
|
+
}
|
|
2635
|
+
if (items > MAX_ARRAY_SIZE)
|
|
2636
|
+
throw tooBig();
|
|
2637
|
+
const elementText = () => {
|
|
2638
|
+
const length = int();
|
|
2639
|
+
if (length === -1)
|
|
2640
|
+
return "NULL";
|
|
2641
|
+
// ReadArrayBinary's own check is 22P03, where reading a length past the end is pq_getmsgint's 08P01
|
|
2642
|
+
if (length < 0 || at + length > bytes.length)
|
|
2643
|
+
throw new PgError({ code: "22P03", message: "insufficient data left in message" });
|
|
2644
|
+
const text = utf8Element(bytes.subarray(at, at + length));
|
|
2645
|
+
at += length;
|
|
2646
|
+
return `"${text.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
2647
|
+
};
|
|
2648
|
+
const level = (d) => `{${Array.from({ length: dims[d].length }, () => d === ndim - 1 ? elementText() : level(d + 1)).join(",")}}`;
|
|
2649
|
+
const body = ndim === 0 || dims.some((d) => d.length === 0) ? "{}" : level(0);
|
|
2650
|
+
if (at !== bytes.length)
|
|
2651
|
+
throw bad();
|
|
2652
|
+
const bounds = dims.every((d) => d.lower === 1) || body === "{}" ? "" : `${dims.map((d) => `[${d.lower}:${d.lower + d.length - 1}]`).join("")}=`;
|
|
2653
|
+
return bounds + body;
|
|
2654
|
+
}
|
|
2655
|
+
const fatalUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
2656
|
+
/**
|
|
2657
|
+
* An element's text, as textrecv reads it (pg_verify_mbstr): bytes that are no UTF-8, or a NUL, are refused 22021 naming
|
|
2658
|
+
* the first bad character's bytes (report_invalid_encoding: as many as its lead byte says, as many as are left), never
|
|
2659
|
+
* decoded with a replacement character.
|
|
2660
|
+
*/
|
|
2661
|
+
function utf8Element(bytes) {
|
|
2662
|
+
if (!bytes.includes(0)) {
|
|
2663
|
+
// a fatal TextDecoder throws exactly TypeError on bytes that are no UTF-8: the scan below names them
|
|
2664
|
+
try {
|
|
2665
|
+
return fatalUtf8.decode(bytes);
|
|
2666
|
+
}
|
|
2667
|
+
catch (err) {
|
|
2668
|
+
if (!(err instanceof TypeError))
|
|
2669
|
+
throw err;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
// find the first character Postgres refuses: a NUL, or a character no UTF-8 decoder reads alone
|
|
2673
|
+
for (let at = 0; at < bytes.length;) {
|
|
2674
|
+
const b = bytes[at];
|
|
2675
|
+
const length = b < 0x80 ? 1 : (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 1;
|
|
2676
|
+
const char = bytes.subarray(at, at + length);
|
|
2677
|
+
let legal = b !== 0 && char.length === length;
|
|
2678
|
+
if (legal && b >= 0x80) {
|
|
2679
|
+
try {
|
|
2680
|
+
fatalUtf8.decode(char);
|
|
2681
|
+
}
|
|
2682
|
+
catch (err) {
|
|
2683
|
+
if (!(err instanceof TypeError))
|
|
2684
|
+
throw err;
|
|
2685
|
+
legal = false;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
if (!legal) {
|
|
2689
|
+
const shown = Array.from(char, (c) => `0x${c.toString(16).padStart(2, "0")}`).join(" ");
|
|
2690
|
+
throw new PgError({ code: "22021", message: `invalid byte sequence for encoding "UTF8": ${shown}` });
|
|
2691
|
+
}
|
|
2692
|
+
at += length;
|
|
2693
|
+
}
|
|
2694
|
+
throw new PgError({ code: "XX000", message: "a text[] element failed to decode as UTF-8 at no character: report it" });
|
|
2695
|
+
}
|
|
1571
2696
|
/** A binary-format parameter, as text Postgres's input function reads (DuckOptions.parameters). */
|
|
1572
2697
|
function decodeBinaryParam(type, bytes, i) {
|
|
1573
2698
|
const need = (n) => {
|
|
@@ -1636,11 +2761,14 @@ function relayedError(frame) {
|
|
|
1636
2761
|
// the listener
|
|
1637
2762
|
class Server {
|
|
1638
2763
|
sessions = new Map();
|
|
2764
|
+
/** the catalog Postgres's own settings, by name (`catalogSettings`), read once */
|
|
2765
|
+
baseSettings = null;
|
|
1639
2766
|
/** the DuckDB plans by text, search_path, TimeZone, declared parameter types and an Execute's bound (Session.boundOnWire), for
|
|
1640
2767
|
* the schema they were made under */
|
|
1641
2768
|
duckPlans = new Map();
|
|
1642
2769
|
duckPlansOf = null;
|
|
1643
2770
|
duckPlansAt = 0;
|
|
2771
|
+
duckPlansRoles = null;
|
|
1644
2772
|
/** the source's OIDs for the types an extension made, by name (a copied citext column's), from the schema */
|
|
1645
2773
|
userTypes() {
|
|
1646
2774
|
const out = new Map();
|
|
@@ -1654,7 +2782,7 @@ class Server {
|
|
|
1654
2782
|
if (c.typeOid === undefined || c.spelled === undefined)
|
|
1655
2783
|
continue;
|
|
1656
2784
|
// the type's name as pg_type holds it (`other.level` is `level`, `"My Mood"` is `My Mood`)
|
|
1657
|
-
const name = typeNameParts(c.spelled
|
|
2785
|
+
const name = typeNameParts(madeTypeName(c.spelled)).name;
|
|
1658
2786
|
// the element type and its array type, as pg_type names them (`citext`, `_citext`); an array column's own OID is
|
|
1659
2787
|
// its array type's (pg_attribute.atttypid), and it has none of its own (an array type's typarray is 0)
|
|
1660
2788
|
if (c.spelled.endsWith("[]"))
|
|
@@ -1676,22 +2804,26 @@ class Server {
|
|
|
1676
2804
|
* analysis starts the instance with the new DDL. A plan whose schema or generation moved while it was made is
|
|
1677
2805
|
* returned to its caller and not kept.
|
|
1678
2806
|
*/
|
|
1679
|
-
async duckPlan(text, paramOids,
|
|
2807
|
+
async duckPlan(text, paramOids, under, wireBound, role, make) {
|
|
1680
2808
|
const schema = this.schema();
|
|
2809
|
+
const roles = this.rolesNow();
|
|
1681
2810
|
const generation = this.options.duck?.generation?.() ?? 0;
|
|
1682
|
-
if (schema !== this.duckPlansOf || generation !== this.duckPlansAt) {
|
|
2811
|
+
if (schema !== this.duckPlansOf || generation !== this.duckPlansAt || roles !== this.duckPlansRoles) {
|
|
1683
2812
|
this.duckPlans.clear();
|
|
1684
2813
|
this.duckPlansOf = schema;
|
|
1685
2814
|
this.duckPlansAt = generation;
|
|
2815
|
+
this.duckPlansRoles = roles;
|
|
1686
2816
|
}
|
|
1687
2817
|
// the zone too: a plan holds the values Postgres parsed its literals to, under the session's zone
|
|
1688
2818
|
// and the bound: an Execute's row count plans a LEFT lateral of a list otherwise (Session.boundOnWire), kept beside
|
|
1689
|
-
|
|
2819
|
+
// 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
|
|
2820
|
+
// and the DateStyle: Postgres reads a literal date in its order (`'01/02/2026'::date`), at analysis
|
|
2821
|
+
const key = `${under.searchPath}\u0000${under.timeZone}\u0000${under.dateStyle}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${role}\u0000${text}`;
|
|
1690
2822
|
const hit = this.duckPlans.get(key);
|
|
1691
2823
|
if (hit !== undefined)
|
|
1692
2824
|
return hit;
|
|
1693
2825
|
const plan = await make();
|
|
1694
|
-
if (this.duckPlansOf !== schema || this.duckPlansAt !== generation)
|
|
2826
|
+
if (this.duckPlansOf !== schema || this.duckPlansAt !== generation || this.duckPlansRoles !== roles)
|
|
1695
2827
|
return plan;
|
|
1696
2828
|
// bounded: a tool that inlines its values sends a new text per value
|
|
1697
2829
|
if (this.duckPlans.size >= 2000)
|
|
@@ -1699,23 +2831,32 @@ class Server {
|
|
|
1699
2831
|
this.duckPlans.set(key, plan);
|
|
1700
2832
|
return plan;
|
|
1701
2833
|
}
|
|
1702
|
-
|
|
2834
|
+
/** the run's own listener's door */
|
|
2835
|
+
door;
|
|
1703
2836
|
catalog;
|
|
1704
2837
|
lastSchema;
|
|
2838
|
+
lastRoles;
|
|
1705
2839
|
options;
|
|
1706
2840
|
constructor(options) {
|
|
1707
2841
|
this.options = options;
|
|
1708
|
-
this.
|
|
2842
|
+
this.door = { tls: options.tls === null ? null : tls.createSecureContext({ cert: options.tls.cert, key: options.tls.key }), authenticate: options.authenticate,
|
|
2843
|
+
recheck: options.recheck, recheckMs: options.recheckMs ?? DEFAULT_RECHECK_MS, who: "a mason user", loginHint: USER_LOGIN_HINT, public: false, logins: null,
|
|
2844
|
+
proxyFrom: options.proxyFrom ?? null };
|
|
1709
2845
|
this.lastSchema = options.schema();
|
|
2846
|
+
this.lastRoles = options.roles?.() ?? NO_ROLES;
|
|
1710
2847
|
const version = versionText(options.product ?? "Mason", options.masonVersion);
|
|
1711
2848
|
this.catalog = new PgCatalog(this.lastSchema, {
|
|
2849
|
+
roles: catalogRolesOf(this.lastRoles),
|
|
1712
2850
|
log: (line) => this.log(line),
|
|
1713
2851
|
setup: [
|
|
1714
2852
|
// `version()` says whose Postgres this is; search_path lists pg_catalog AFTER public so the override wins
|
|
1715
2853
|
`CREATE OR REPLACE FUNCTION public.version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '${version.replaceAll("'", "''")}' $$`,
|
|
1716
|
-
// `current_setting(
|
|
1717
|
-
...currentSettingSql(
|
|
1718
|
-
|
|
2854
|
+
// `current_setting(name)` answers the session's settings, carried in by each of its requests (catalog.ts asSession)
|
|
2855
|
+
...currentSettingSql(),
|
|
2856
|
+
// latest(x ORDER BY …), Mason's aggregate, analysed as Postgres's own (catalog.ts LATEST_SQL)
|
|
2857
|
+
...LATEST_SQL,
|
|
2858
|
+
// the catalog's base state, which every request of a session leaves it in (catalog.ts CATALOG_BASE)
|
|
2859
|
+
CATALOG_BASE_SQL,
|
|
1719
2860
|
...databaseNameSql(options.database),
|
|
1720
2861
|
],
|
|
1721
2862
|
...(options.catalogStatementMs !== undefined ? { statementMs: options.catalogStatementMs } : {}),
|
|
@@ -1723,47 +2864,278 @@ class Server {
|
|
|
1723
2864
|
});
|
|
1724
2865
|
}
|
|
1725
2866
|
get database() { return this.options.database; }
|
|
1726
|
-
/**
|
|
1727
|
-
|
|
2867
|
+
/**
|
|
2868
|
+
* THE CATALOG POSTGRES'S OWN SETTINGS, every name its `current_setting` knows with the value it answers (`block_size`,
|
|
2869
|
+
* `server_version_num` …), read once (they are the catalog's build's and its setup's, the same at every start): what a
|
|
2870
|
+
* session's `current_setting` answers beside a copied table for a name the session does not hold (`duckSession`).
|
|
2871
|
+
*/
|
|
2872
|
+
catalogSettings() {
|
|
2873
|
+
this.baseSettings ??= (async () => {
|
|
2874
|
+
const reply = decodeBackend(Buffer.from(await this.catalog.run(frame("Q", cstr("SELECT name, pg_catalog.current_setting(name) FROM pg_catalog.pg_settings")))));
|
|
2875
|
+
const err = reply.find((m) => m.type === "ErrorResponse");
|
|
2876
|
+
if (err?.type === "ErrorResponse")
|
|
2877
|
+
throw new PgError({ code: "XX000", message: `the catalog Postgres's settings could not be read: ${err.fields.M} (Mason bug: report it)` }, 500);
|
|
2878
|
+
return new Map(reply.flatMap((m) => m.type === "DataRow" ? [[m.values[0].toString("utf8").toLowerCase(), m.values[1]?.toString("utf8") ?? ""]] : []));
|
|
2879
|
+
})();
|
|
2880
|
+
// a read that failed is read again at the next statement, never kept
|
|
2881
|
+
this.baseSettings.catch(() => { this.baseSettings = null; });
|
|
2882
|
+
return this.baseSettings;
|
|
2883
|
+
}
|
|
2884
|
+
/** the clock "now" is read from: microseconds since the epoch (src/lib/instant.ts) */
|
|
2885
|
+
now() { return (this.options.clock ?? nowMicros)(); }
|
|
1728
2886
|
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 */
|
|
2887
|
+
/** the schema as of now; the catalog Postgres is replaced when it changed (or the roles did: `rolesNow`) */
|
|
1730
2888
|
schema() {
|
|
1731
2889
|
const s = this.options.schema();
|
|
1732
|
-
|
|
2890
|
+
const r = this.options.roles?.() ?? NO_ROLES;
|
|
2891
|
+
if (s !== this.lastSchema || r !== this.lastRoles) {
|
|
1733
2892
|
this.lastSchema = s;
|
|
1734
|
-
|
|
2893
|
+
this.lastRoles = r;
|
|
2894
|
+
void this.catalog.replace(s, catalogRolesOf(r)).catch((e) => this.log(`replacing the catalog failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
1735
2895
|
}
|
|
1736
2896
|
return s;
|
|
1737
2897
|
}
|
|
2898
|
+
/** src/duck/generated-plan.ts `Planner`: null `grants` so the plan names what it reads, under no role */
|
|
2899
|
+
planner() {
|
|
2900
|
+
const duck = this.options.duck;
|
|
2901
|
+
if (duck === undefined)
|
|
2902
|
+
throw new Error("a generated table needs the listener's engine (Mason bug: report it)");
|
|
2903
|
+
const catalog = (b) => this.catalog.run(b);
|
|
2904
|
+
return {
|
|
2905
|
+
plan: (text, stmt, relations) => { this.schema(); return duck.plan(catalog, text, stmt, 0, [], GENERATED_SESSION, this.userTypes(), false, null, relations, true); },
|
|
2906
|
+
rows: (sql) => { this.schema(); return catalogRows(catalog, sql); },
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
/** the roles as of now (src/duck/grants.ts RolesLive); the catalog is replaced when they changed, as for the schema */
|
|
2910
|
+
rolesNow() {
|
|
2911
|
+
this.schema();
|
|
2912
|
+
return this.lastRoles;
|
|
2913
|
+
}
|
|
2914
|
+
/**
|
|
2915
|
+
* THE ROLES WITH THEIR ROW FILTERS CHECKED BY THE CATALOG POSTGRES (src/duck/row-filter.ts filterTypes): each `where:`
|
|
2916
|
+
* of a copied table analysed as `SELECT 1 FROM <table> WHERE <it>`, the type of each attribute it reads taken from
|
|
2917
|
+
* Postgres's inference (the ONE derivation: every binding and the catalog's policies read it), and a filter Postgres
|
|
2918
|
+
* refuses made the roles' `problem` — said once in the log, at its key, and every statement of a user held to a role
|
|
2919
|
+
* refused until it is fixed (fail closed, as for any mistake of the file). The catalog's row security policies are
|
|
2920
|
+
* written from the same types (catalog.ts catalogPolicies). Once per roles object and copied tables; nothing where no
|
|
2921
|
+
* role filters rows. A check that could not run (the catalog failed) is said as the problem and tried again next time.
|
|
2922
|
+
*/
|
|
2923
|
+
async checkRoles() {
|
|
2924
|
+
const raw = this.rolesNow();
|
|
2925
|
+
const schema = this.lastSchema;
|
|
2926
|
+
if (!filtersRows(raw))
|
|
2927
|
+
return raw;
|
|
2928
|
+
if (this.checked?.of === raw && this.checked.schema === schema)
|
|
2929
|
+
return this.checked.live;
|
|
2930
|
+
if (this.checking?.of === raw && this.checking.schema === schema)
|
|
2931
|
+
return this.checking.done;
|
|
2932
|
+
const done = (async () => {
|
|
2933
|
+
const copied = new Set(schema.tables.map((t) => `${t.schema ?? "public"}.${t.name}`));
|
|
2934
|
+
const problems = [];
|
|
2935
|
+
const typed = new Map();
|
|
2936
|
+
try {
|
|
2937
|
+
for (const g of raw.grants.values()) {
|
|
2938
|
+
if (g.filters === undefined) {
|
|
2939
|
+
typed.set(g.name, g);
|
|
2940
|
+
continue;
|
|
2941
|
+
}
|
|
2942
|
+
const types = new Map();
|
|
2943
|
+
for (const f of g.filters.values()) {
|
|
2944
|
+
if (f.where === undefined || !copied.has(f.table))
|
|
2945
|
+
continue;
|
|
2946
|
+
const r = await filterTypes((b) => this.catalog.run(b), f, g.attributes ?? new Map());
|
|
2947
|
+
if ("problem" in r) {
|
|
2948
|
+
problems.push(r.problem);
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
types.set(f.table, r.types);
|
|
2952
|
+
// Postgres read it; the rewrite must too, or each of the role's users would meet the refusal, statement by statement
|
|
2953
|
+
const probe = filterProbe(f, r.types, g.attributes ?? new Map());
|
|
2954
|
+
const stmt = (await parseSql(probe)).stmts?.[0]?.stmt;
|
|
2955
|
+
if (stmt === undefined)
|
|
2956
|
+
throw new Error(`a filter's read does not parse: ${probe}`);
|
|
2957
|
+
try {
|
|
2958
|
+
await this.options.duck.plan((b) => this.catalog.run(b), probe, stmt, 0, [], GENERATED_SESSION, this.userTypes(), false, undefined);
|
|
2959
|
+
}
|
|
2960
|
+
catch (e) {
|
|
2961
|
+
if (!(e instanceof StatementError))
|
|
2962
|
+
throw e;
|
|
2963
|
+
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}`);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
typed.set(g.name, { ...g, filterTypes: types });
|
|
2967
|
+
}
|
|
2968
|
+
await this.catalog.policies(catalogPolicies(schema, catalogRolesOf({ ...raw, grants: typed })));
|
|
2969
|
+
}
|
|
2970
|
+
catch (e) {
|
|
2971
|
+
const problem = `the roles' row filters could not be checked by the catalog: ${e instanceof Error ? e.message : String(e)}`;
|
|
2972
|
+
// tried again at every statement of every session, so said once a streak: the line a streak's end answers
|
|
2973
|
+
if (!this.rolesCheckFailing) {
|
|
2974
|
+
this.rolesCheckFailing = true;
|
|
2975
|
+
this.log(`roles: ${problem} — tried again at each statement, every statement of a user held to a role refused until then`);
|
|
2976
|
+
}
|
|
2977
|
+
return raw.problem !== null ? raw : { ...raw, problem, checkFailed: true };
|
|
2978
|
+
}
|
|
2979
|
+
if (this.rolesCheckFailing) {
|
|
2980
|
+
this.rolesCheckFailing = false;
|
|
2981
|
+
this.log("roles: the catalog checks the row filters again");
|
|
2982
|
+
}
|
|
2983
|
+
const problem = raw.problem ?? (problems.length === 0 ? null : `${raw.file ?? "the roles file"}: ${problems.join("; ")}`);
|
|
2984
|
+
if (problem !== null && raw.problem === null && problem !== this.saidProblem) {
|
|
2985
|
+
this.saidProblem = problem;
|
|
2986
|
+
this.log(`roles: ${problem} — every statement of a user held to a role is refused until it is fixed`);
|
|
2987
|
+
}
|
|
2988
|
+
const live = { ...raw, grants: typed, problem };
|
|
2989
|
+
if (this.lastRoles === raw && this.lastSchema === schema)
|
|
2990
|
+
this.checked = { of: raw, schema, live };
|
|
2991
|
+
return live;
|
|
2992
|
+
})();
|
|
2993
|
+
this.checking = { of: raw, schema, done };
|
|
2994
|
+
try {
|
|
2995
|
+
return await done;
|
|
2996
|
+
}
|
|
2997
|
+
finally {
|
|
2998
|
+
if (this.checking?.done === done)
|
|
2999
|
+
this.checking = null;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
checked = null;
|
|
3003
|
+
checking = null;
|
|
3004
|
+
saidProblem = null;
|
|
3005
|
+
rolesCheckFailing = false;
|
|
3006
|
+
/** the roles now, their row filters checked where the check for them is done (`checkRoles`), else as read: a
|
|
3007
|
+
* session's `grants` refuses a filter not checked yet */
|
|
3008
|
+
rolesChecked() {
|
|
3009
|
+
const raw = this.rolesNow();
|
|
3010
|
+
if (!filtersRows(raw))
|
|
3011
|
+
return raw;
|
|
3012
|
+
return this.checked?.of === raw && this.checked.schema === this.lastSchema ? this.checked.live : raw;
|
|
3013
|
+
}
|
|
1738
3014
|
forget(s) { this.sessions.delete(s.id); }
|
|
1739
|
-
|
|
3015
|
+
/** A CancelRequest: the session's statement stopped when the key is the session's (`interrupt`) and it arrived on
|
|
3016
|
+
* the session's own socket (a driver's cancel opens a connection to the address it logged in at; a public socket
|
|
3017
|
+
* reaches its own sessions alone). */
|
|
3018
|
+
cancel(pid, secret, door) {
|
|
1740
3019
|
const s = this.sessions.get(pid);
|
|
1741
|
-
if (s === undefined || s.secret !== secret)
|
|
3020
|
+
if (s === undefined || s.secret !== secret || s.door !== door)
|
|
1742
3021
|
return; // as Postgres: a wrong key is ignored, never answered
|
|
3022
|
+
this.interrupt(s);
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* THE ONE WAY A SESSION'S STATEMENT IS CANCELLED, for a CancelRequest and `mason cancel` alike: its controller aborted
|
|
3026
|
+
* with no reason, so DuckDB is interrupted (or the wait for the client woken) and the client is told 57014 "canceling
|
|
3027
|
+
* statement due to user request", recorded as cancelled. Answers what it stopped, or null when nothing ran.
|
|
3028
|
+
*/
|
|
3029
|
+
interrupt(s) {
|
|
3030
|
+
const running = s.runningNow(Date.now());
|
|
1743
3031
|
s.running?.abort();
|
|
3032
|
+
return running;
|
|
3033
|
+
}
|
|
3034
|
+
/** `mason cancel <session>` (`POST /api/cancel`): the session's statement stopped as a CancelRequest stops it — with
|
|
3035
|
+
* `statement`, only while it is the one the session runs (a listing's statement may have ended, and the session
|
|
3036
|
+
* begun another, by the time the cancel arrives) */
|
|
3037
|
+
cancelSession(id, statement) {
|
|
3038
|
+
const s = this.sessions.get(id);
|
|
3039
|
+
if (s === undefined)
|
|
3040
|
+
return { unknown: id };
|
|
3041
|
+
if (statement !== undefined) {
|
|
3042
|
+
const now = s.runningNow(Date.now());
|
|
3043
|
+
if (now !== null && now.statement !== statement)
|
|
3044
|
+
return { other: now, asked: statement };
|
|
3045
|
+
}
|
|
3046
|
+
const stopped = this.interrupt(s);
|
|
3047
|
+
if (stopped === null)
|
|
3048
|
+
return { idle: id };
|
|
3049
|
+
this.log(`session ${id}: its statement cancelled by mason cancel`);
|
|
3050
|
+
return { cancelled: stopped };
|
|
3051
|
+
}
|
|
3052
|
+
/** The sessions logged in now, by user and client, the most open first (`SessionsOpen`). */
|
|
3053
|
+
openSessions() {
|
|
3054
|
+
const now = Date.now();
|
|
3055
|
+
const by = new Map();
|
|
3056
|
+
let open = 0;
|
|
3057
|
+
for (const s of this.sessions.values()) {
|
|
3058
|
+
if (s.phase !== "ready")
|
|
3059
|
+
continue;
|
|
3060
|
+
open++;
|
|
3061
|
+
const user = s.token?.user ?? "", client = s.vars.applicationName;
|
|
3062
|
+
const k = JSON.stringify([user, client]);
|
|
3063
|
+
const g = by.get(k) ?? { user, client, open: 0, running: 0 };
|
|
3064
|
+
g.open++;
|
|
3065
|
+
if (s.runningNow(now) !== null)
|
|
3066
|
+
g.running++;
|
|
3067
|
+
by.set(k, g);
|
|
3068
|
+
}
|
|
3069
|
+
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)) };
|
|
3070
|
+
}
|
|
3071
|
+
/** The statements running now, oldest first. */
|
|
3072
|
+
running() {
|
|
3073
|
+
const now = Date.now();
|
|
3074
|
+
return [...this.sessions.values()].map((s) => s.runningNow(now)).filter((r) => r !== null)
|
|
3075
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.session - b.session);
|
|
1744
3076
|
}
|
|
1745
3077
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
3078
|
+
/** A socket that takes connections through `door`, listening; its port. Two of these are one server's two sockets. */
|
|
3079
|
+
async function listenOn(server, door, port, host) {
|
|
1748
3080
|
const listener = net.createServer((socket) => {
|
|
1749
3081
|
socket.setNoDelay(true);
|
|
1750
|
-
const session = new Session(socket, server);
|
|
3082
|
+
const session = new Session(socket, server, door);
|
|
1751
3083
|
server.sessions.set(session.id, session);
|
|
1752
3084
|
});
|
|
1753
3085
|
await new Promise((resolve, reject) => {
|
|
1754
3086
|
listener.once("error", reject);
|
|
1755
|
-
listener.listen(
|
|
3087
|
+
listener.listen(port, host, () => { listener.off("error", reject); resolve(); });
|
|
1756
3088
|
});
|
|
1757
3089
|
listener.on("error", (err) => server.log(`listener error: ${err.message}`));
|
|
1758
3090
|
const address = listener.address();
|
|
1759
|
-
|
|
3091
|
+
return { listener, port: typeof address === "object" && address !== null ? address.port : port };
|
|
3092
|
+
}
|
|
3093
|
+
const closeListener = (listener) => new Promise((resolve) => listener.close(() => resolve()));
|
|
3094
|
+
export async function startPostgresApi(options) {
|
|
3095
|
+
const server = new Server(options);
|
|
3096
|
+
const { listener, port } = await listenOn(server, server.door, options.port, options.host);
|
|
3097
|
+
const shares = new Set();
|
|
3098
|
+
const sessionsOf = (door) => [...server.sessions.values()].filter((s) => s.door === door);
|
|
1760
3099
|
return {
|
|
1761
3100
|
port,
|
|
3101
|
+
async openShare(o) {
|
|
3102
|
+
const door = { tls: tls.createSecureContext({ cert: o.tls.cert, key: o.tls.key }), authenticate: o.authenticate, recheck: o.recheck, recheckMs: 0, who: "a share",
|
|
3103
|
+
loginHint: o.loginHint, public: true, logins: new LoginQueue(o.pendingLimit ?? DEFAULT_PENDING_LIMIT, o.pendingMs ?? DEFAULT_PENDING_MS, o.dropped), refused: o.refused,
|
|
3104
|
+
// a tunnel's client comes from loopback: no header is believed on a share's socket
|
|
3105
|
+
proxyFrom: null };
|
|
3106
|
+
// loopback whatever the run's own listener is bound to: a tunnel's client is on this machine
|
|
3107
|
+
const share = await listenOn(server, door, 0, "127.0.0.1");
|
|
3108
|
+
shares.add(share.listener);
|
|
3109
|
+
return {
|
|
3110
|
+
port: share.port,
|
|
3111
|
+
get sessions() { return sessionsOf(door).filter((s) => s.phase === "ready").length; },
|
|
3112
|
+
endSessions: (jti, why) => {
|
|
3113
|
+
const ended = sessionsOf(door).filter((s) => s.token?.jti === jti);
|
|
3114
|
+
for (const s of ended)
|
|
3115
|
+
s.end(why);
|
|
3116
|
+
return ended.length;
|
|
3117
|
+
},
|
|
3118
|
+
async close() {
|
|
3119
|
+
if (!shares.delete(share.listener))
|
|
3120
|
+
return;
|
|
3121
|
+
for (const s of sessionsOf(door))
|
|
3122
|
+
s.end("the share ended");
|
|
3123
|
+
await closeListener(share.listener);
|
|
3124
|
+
},
|
|
3125
|
+
};
|
|
3126
|
+
},
|
|
1762
3127
|
get sessions() { return server.sessions.size; },
|
|
3128
|
+
roles: () => server.rolesChecked(),
|
|
3129
|
+
checkRoles: () => server.checkRoles(),
|
|
3130
|
+
planner: () => server.planner(),
|
|
3131
|
+
openSessions: () => server.openSessions(),
|
|
3132
|
+
running: () => server.running(),
|
|
3133
|
+
cancel: (session, statement) => server.cancelSession(session, statement),
|
|
1763
3134
|
async close() {
|
|
1764
3135
|
for (const s of [...server.sessions.values()])
|
|
1765
3136
|
s.end("server closing");
|
|
1766
|
-
await
|
|
3137
|
+
await Promise.all([listener, ...shares].map(closeListener));
|
|
3138
|
+
shares.clear();
|
|
1767
3139
|
await server.catalog.stop();
|
|
1768
3140
|
await stopParser();
|
|
1769
3141
|
},
|