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