@mason-data/cli 0.8.0 → 0.10.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 +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -3,20 +3,23 @@ import { explainedText, explainOptions } from "./explain.js";
|
|
|
3
3
|
import * as crypto from "node:crypto";
|
|
4
4
|
import * as net from "node:net";
|
|
5
5
|
import * as tls from "node:tls";
|
|
6
|
-
import {
|
|
6
|
+
import { catalogRows } from "../../duck/analyze.js";
|
|
7
|
+
import { ADDRESS_NOT_ALLOWED, ApiError, USER_DISABLED } from "../error.js";
|
|
7
8
|
import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
|
|
8
|
-
import {
|
|
9
|
+
import { gapRefusal, StatementError } from "../../duck/statement.js";
|
|
9
10
|
import { canonicalZone } from "../../lib/timezone.js";
|
|
10
11
|
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 { asRole, asSession, CATALOG_BASE_SQL, catalogPolicies, currentSettingSql, databaseNameSql, LATEST_SQL, PgCatalog, unwrapRole, unwrapSession } from "./catalog.js";
|
|
12
13
|
import { wrongDatabaseHint } from "../../lib/database-name.js";
|
|
13
14
|
import { attributeSetting, bindFilter, filterProbe, filterTypes, missingAttributes } from "../../duck/row-filter.js";
|
|
14
15
|
import { EncodeError, OID, fieldOf } from "./encode.js";
|
|
15
16
|
import { numericFromBinary, NumericError } from "../../lib/pg-numeric.js";
|
|
17
|
+
import { instantText, nowMicros } from "../../lib/instant.js";
|
|
16
18
|
import { parseSql, PostgresSyntaxError, stopParser } from "./parser.js";
|
|
17
19
|
import { FrontendDecoder, ProtocolError, authenticationCleartextPassword, authenticationOk, backendKeyData, bindComplete, closeComplete, commandComplete, dataRow, emptyQueryResponse, errorResponse, noData, noticeResponse, parameterDescription, parameterStatus, parseComplete, portalSuspended, readyForQuery, rowDescription, sslResponse, cstr, int16, int32, frame, decodeBackend, decodeNoticeFrame, splitFrames, MAX_MESSAGE_LENGTH, } from "./protocol.js";
|
|
18
|
-
import { typeNameParts } from "./schema.js";
|
|
20
|
+
import { madeTypeName, typeNameParts } from "./schema.js";
|
|
19
21
|
import { Unsupported, duckTranslate, endsFailedBlock } from "./translate.js";
|
|
22
|
+
import { clientAddress, PROXY_HEADER_MS, PROXY_SETTING, proxyNames, readProxyHeader, startsProxyHeader } from "./proxy.js";
|
|
20
23
|
/** what an EXPLAIN of a catalog statement says first: whose plan it is */
|
|
21
24
|
const CATALOG_EXPLAINED = "Mason: answered by the catalog Postgres (Mason's own, PGlite), not the synced data: a statement of pg_catalog or information_schema, or of no table, is the catalog's, and this is its plan";
|
|
22
25
|
/** an EXPLAIN's one column, Postgres's: text, or json for FORMAT JSON (ExplainResultDesc) */
|
|
@@ -88,24 +91,6 @@ export function timeoutMs(value) {
|
|
|
88
91
|
const per = { us: 0.001, ms: 1, s: 1000, min: 60_000, h: 3_600_000, d: 86_400_000 }[(m[2] ?? "ms").toLowerCase()];
|
|
89
92
|
return Math.round(Number(m[1]) * per);
|
|
90
93
|
}
|
|
91
|
-
/** A statement's error as Postgres raises it, from outside this module (the DuckDB runner's): the fields, and the SQL
|
|
92
|
-
* its position is in. */
|
|
93
|
-
export class StatementError extends Error {
|
|
94
|
-
fields;
|
|
95
|
-
sql;
|
|
96
|
-
/** the registry entry (src/duck/gaps.ts) that refused the statement, as the refusal was built with it (`gapRefusal`,
|
|
97
|
-
* src/duck/store.ts pgErrorOf): what the request log records as `gap`; null for every other error */
|
|
98
|
-
gap;
|
|
99
|
-
constructor(fields, sql = "", gap = null) { super(fields.message); this.fields = fields; this.sql = sql; this.gap = gap; }
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* A REFUSAL BY A REGISTRY ENTRY (src/duck/gaps.ts), the one way one is built: 0A000, `Mason gap <id>` in its DETAIL (what
|
|
103
|
-
* the oracle and a reader read), followed by `detail` (docs/SQL-SUPPORT.md, or what it names), and the id itself on the
|
|
104
|
-
* error, so the request log records which entry refused it without reading the text back.
|
|
105
|
-
*/
|
|
106
|
-
export function gapRefusal(gap, message, hint, sql = "", detail = " (docs/SQL-SUPPORT.md)") {
|
|
107
|
-
return new StatementError({ code: "0A000", message, hint, detail: `${gapMark(gap)}${detail}` }, sql, gap);
|
|
108
|
-
}
|
|
109
94
|
export const SERVER_VERSION = "18.6";
|
|
110
95
|
export const SERVER_VERSION_NUM = "180006";
|
|
111
96
|
/** `version()`'s whole text: `PostgreSQL 18.6` FIRST, since tools parse it (as Redshift and YugabyteDB keep it), then
|
|
@@ -204,7 +189,7 @@ function asPgError(err, sql) {
|
|
|
204
189
|
return new PgError({ code: sqlstateOf(err.status, err.code), message: err.message, detail: `Mason: ${err.code}` }, err.status);
|
|
205
190
|
// Postgres's own error for a numeric (a division by zero, 22012; a field overflow, 22003), raised as Postgres raises it
|
|
206
191
|
if (err instanceof NumericError)
|
|
207
|
-
return new PgError({ code: err.sqlstate, message: err.message }, err.sqlstate === "XX000" ? 500 : 400, err.gap);
|
|
192
|
+
return new PgError({ code: err.sqlstate, message: err.message, ...(err.detail === null ? {} : { detail: err.detail }) }, err.sqlstate === "XX000" ? 500 : 400, err.gap);
|
|
208
193
|
if (err instanceof EncodeError)
|
|
209
194
|
return err.code === "XX000"
|
|
210
195
|
? new PgError({ code: "XX000", message: err.message, detail: "Mason: a value the column's Postgres type cannot carry; report it" }, 500)
|
|
@@ -221,8 +206,10 @@ const REPORTED = new Set(["application_name", "client_encoding", "datestyle", "i
|
|
|
221
206
|
/** Postgres's spelling of the parameters it reports, for the ParameterStatus name. */
|
|
222
207
|
const SPELLING = { datestyle: "DateStyle", intervalstyle: "IntervalStyle", timezone: "TimeZone" };
|
|
223
208
|
const spell = (name) => SPELLING[name] ?? name;
|
|
224
|
-
const defaultVars = () => ({ timeZone: "UTC", applicationName: "", searchPath: '"$user", public', other: new Map() });
|
|
209
|
+
const defaultVars = () => ({ timeZone: "UTC", dateStyle: "ISO, MDY", applicationName: "", searchPath: '"$user", public', other: new Map() });
|
|
225
210
|
const copyVars = (v) => ({ ...v, other: new Map(v.other) });
|
|
211
|
+
/** a generated table's plan is made under no session: UTC, under `public`, ISO with month-day-year */
|
|
212
|
+
const GENERATED_SESSION = { timeZone: "UTC", searchPath: "public", dateStyle: "ISO, MDY" };
|
|
226
213
|
/** One parameter's value carried from one set of values to another, from where `setParameter` keeps it. */
|
|
227
214
|
function copySetting(name, from, to) {
|
|
228
215
|
switch (name) {
|
|
@@ -230,6 +217,9 @@ function copySetting(name, from, to) {
|
|
|
230
217
|
case "time zone":
|
|
231
218
|
to.timeZone = from.timeZone;
|
|
232
219
|
return;
|
|
220
|
+
case "datestyle":
|
|
221
|
+
to.dateStyle = from.dateStyle;
|
|
222
|
+
return;
|
|
233
223
|
case "application_name":
|
|
234
224
|
to.applicationName = from.applicationName;
|
|
235
225
|
return;
|
|
@@ -237,11 +227,24 @@ function copySetting(name, from, to) {
|
|
|
237
227
|
to.searchPath = from.searchPath;
|
|
238
228
|
return;
|
|
239
229
|
}
|
|
240
|
-
const
|
|
230
|
+
const key = SETTING_KEY[name] ?? name;
|
|
231
|
+
const value = from.other.get(key);
|
|
241
232
|
if (value === undefined)
|
|
242
|
-
to.other.delete(
|
|
233
|
+
to.other.delete(key);
|
|
243
234
|
else
|
|
244
|
-
to.other.set(
|
|
235
|
+
to.other.set(key, value);
|
|
236
|
+
}
|
|
237
|
+
/** the parameter a SET of another name moves: SET TRANSACTION and SET SESSION CHARACTERISTICS are Postgres's spellings of
|
|
238
|
+
* transaction_isolation and default_transaction_isolation (translate.ts setStatement) */
|
|
239
|
+
const SETTING_KEY = { transaction: "transaction_isolation", "session characteristics": "default_transaction_isolation" };
|
|
240
|
+
/** the isolation levels Postgres knows, as SHOW answers them */
|
|
241
|
+
const ISOLATION_LEVELS = ["serializable", "repeatable read", "read committed", "read uncommitted"];
|
|
242
|
+
/** the one refusal of a DateStyle that is Mason's own: an output style other than ISO (its value as the catalog
|
|
243
|
+
* Postgres canonicalised it, `German, DMY`), which this API never writes */
|
|
244
|
+
function isoDateStyle(canonical, value) {
|
|
245
|
+
if (!/^ISO,/.test(canonical))
|
|
246
|
+
throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
|
|
247
|
+
return canonical;
|
|
245
248
|
}
|
|
246
249
|
/** A placeholder's name as Postgres's `valid_custom_variable_name` reads it: two or more simple identifiers, dotted. */
|
|
247
250
|
function placeholderName(name) {
|
|
@@ -253,14 +256,18 @@ function placeholderName(name) {
|
|
|
253
256
|
const FIXED = new Map([
|
|
254
257
|
["server_version", () => SERVER_VERSION], ["server_version_num", () => SERVER_VERSION_NUM],
|
|
255
258
|
["server_encoding", () => "UTF8"], ["client_encoding", () => "UTF8"],
|
|
256
|
-
["datestyle", () =>
|
|
259
|
+
["datestyle", (v) => v.dateStyle], ["intervalstyle", () => "postgres"],
|
|
257
260
|
["timezone", (v) => v.timeZone], ["application_name", (v) => v.applicationName], ["search_path", (v) => v.searchPath],
|
|
258
261
|
...["integer_datetimes", "standard_conforming_strings", "default_transaction_read_only", "transaction_read_only", "in_hot_standby"].map((n) => [n, () => "on"]),
|
|
259
262
|
["is_superuser", () => "off"],
|
|
260
263
|
// the session's user, and no role SET over it (SET ROLE is its own name or NONE, setStatement): Postgres's `none`
|
|
261
264
|
["session_authorization", (_v, user) => user], ["role", () => "none"],
|
|
262
|
-
|
|
263
|
-
|
|
265
|
+
// what SET TRANSACTION and SET SESSION CHARACTERISTICS said, remembered (`setParameter`): nothing is ever written, so
|
|
266
|
+
// every level answers the same rows
|
|
267
|
+
["transaction_isolation", (v) => v.other.get("transaction_isolation") ?? v.other.get("default_transaction_isolation") ?? "read committed"],
|
|
268
|
+
["default_transaction_isolation", (v) => v.other.get("default_transaction_isolation") ?? "read committed"],
|
|
269
|
+
// remembered: every float is sent as the shortest text that reads back exactly, Postgres's output at 1 and above
|
|
270
|
+
["extra_float_digits", (v) => v.other.get("extra_float_digits") ?? "1"], ["max_identifier_length", () => "63"],
|
|
264
271
|
]);
|
|
265
272
|
function fixedParameter(name, vars, user) {
|
|
266
273
|
return FIXED.get(name)?.(vars, user) ?? null;
|
|
@@ -289,6 +296,8 @@ const aborted = () => new PgError({ code: "25P02", message: "current transaction
|
|
|
289
296
|
const nameLength = (name) => name === undefined ? undefined : Buffer.from(name, "utf8").subarray(0, 63).toString("utf8");
|
|
290
297
|
/** The hint a refused login is told on the run's own listener. */
|
|
291
298
|
const USER_LOGIN_HINT = "a tool that keeps one password connects as a user: mason user add <name> prints its password and URL";
|
|
299
|
+
/** What a connection refused for its address is told (ADDRESS_NOT_ALLOWED): the same whether the user exists or not. */
|
|
300
|
+
const ADDRESS_HINT = "a user limited to address ranges connects only from them: mason user list shows each user's ranges, mason user from <name> changes them";
|
|
292
301
|
/**
|
|
293
302
|
* THE CONNECTIONS STILL LOGGING IN ON A PUBLIC SOCKET (`ShareDoor`): at most `limit`, each for at most `ms`, the oldest
|
|
294
303
|
* making way for a new one. A limit that refused the NEW connection would hand the socket to whoever opened `limit`
|
|
@@ -331,11 +340,14 @@ class Session {
|
|
|
331
340
|
presented = null;
|
|
332
341
|
/** the database a public socket's startup named that is not this one: refused once the login is good */
|
|
333
342
|
wrongDatabase = null;
|
|
334
|
-
/** when the user was last found live (a user's session is asked again,
|
|
343
|
+
/** when the user was last found live, in microseconds as the session's clock (a user's session is asked again,
|
|
344
|
+
* `recheckMs`) */
|
|
335
345
|
verifiedAt = 0;
|
|
336
346
|
vars = defaultVars();
|
|
337
347
|
/** the session's parameters as its startup left them (its packet's `application_name` and `options`): what RESET restores */
|
|
338
348
|
startupVars = defaultVars();
|
|
349
|
+
/** a startup packet's DateStyle, canonicalised by the catalog Postgres once the password is read (`password`) */
|
|
350
|
+
startupDateStyle = null;
|
|
339
351
|
/** each reported parameter's value as the client was last told it (`reportChanges`) */
|
|
340
352
|
reported = new Map();
|
|
341
353
|
tx = "none";
|
|
@@ -348,9 +360,10 @@ class Session {
|
|
|
348
360
|
/** the statement runs in an IMPLICIT transaction block, as Postgres's IsTransactionBlock reads one: a Query of several
|
|
349
361
|
* statements, or a Sync's batch once an Execute completed (`extended`). A plain transaction of one statement is none */
|
|
350
362
|
implicitBlock = false;
|
|
351
|
-
/** the instant the transaction began (Postgres's `now()`),
|
|
363
|
+
/** the instant the transaction began (Postgres's `now()`), in microseconds since the epoch (src/lib/instant.ts),
|
|
364
|
+
* null between transactions */
|
|
352
365
|
txNow = null;
|
|
353
|
-
/** the instant of the statement being run (`statement_timestamp()`): its Query's arrival or its Bind */
|
|
366
|
+
/** the instant of the statement being run (`statement_timestamp()`), in microseconds: its Query's arrival or its Bind */
|
|
354
367
|
statementNow = 0;
|
|
355
368
|
statements = new Map();
|
|
356
369
|
portals = new Map();
|
|
@@ -387,23 +400,56 @@ class Session {
|
|
|
387
400
|
server;
|
|
388
401
|
/** the socket it arrived on: how it may start, and who may log in */
|
|
389
402
|
door;
|
|
403
|
+
/**
|
|
404
|
+
* THE CLIENT'S ADDRESS (proxy.ts `clientAddress`, its one reader): the socket's peer, or the source the PROXY header
|
|
405
|
+
* of a proxy MASON_PG_PROXY_FROM names carries, once read; null on a public socket (a share's: its peer is the
|
|
406
|
+
* tunnel's, never the client's) and where the peer could not be read. The request log's `client_addr` and the log's
|
|
407
|
+
* connect line read it.
|
|
408
|
+
*/
|
|
409
|
+
clientAddr;
|
|
410
|
+
/** the PROXY header a named proxy's connection begins with, while it is read: its bytes so far and its deadline */
|
|
411
|
+
proxyHead = null;
|
|
412
|
+
/** a named proxy's header was read: a second one is refused as one */
|
|
413
|
+
proxied = false;
|
|
390
414
|
constructor(socket, server, door) {
|
|
391
415
|
this.server = server;
|
|
392
416
|
this.door = door;
|
|
393
417
|
this.socket = socket;
|
|
418
|
+
this.clientAddr = door.public ? null : clientAddress(socket.remoteAddress, null);
|
|
419
|
+
if (door.proxyFrom !== null) {
|
|
420
|
+
const peer = clientAddress(socket.remoteAddress, null);
|
|
421
|
+
if (peer === null) {
|
|
422
|
+
// fail closed: an address that cannot be read cannot be told from a named proxy's
|
|
423
|
+
this.server.log(`session ${this.id} refused: its address could not be read, so whether ${PROXY_SETTING} names it could not be told`);
|
|
424
|
+
process.nextTick(() => this.end("its address could not be read"));
|
|
425
|
+
}
|
|
426
|
+
else if (proxyNames(door.proxyFrom, peer)) {
|
|
427
|
+
const timer = setTimeout(() => this.proxyRefused(`no whole PROXY protocol v2 header in ${PROXY_HEADER_MS / 1000} s`), PROXY_HEADER_MS);
|
|
428
|
+
timer.unref();
|
|
429
|
+
this.proxyHead = { bytes: Buffer.alloc(0), timer };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
394
432
|
this.attach(socket);
|
|
395
433
|
door.logins?.admit(this);
|
|
396
434
|
}
|
|
397
435
|
attach(socket) {
|
|
398
|
-
socket.on("data", (
|
|
399
|
-
//
|
|
400
|
-
|
|
436
|
+
socket.on("data", (data) => {
|
|
437
|
+
// a named proxy's PROXY header comes first, before anything else on the connection is looked at
|
|
438
|
+
const chunk = this.proxyHead === null ? data : this.readProxy(data);
|
|
439
|
+
if (chunk === null || chunk.length === 0)
|
|
440
|
+
return;
|
|
441
|
+
// the connection's first byte says how it starts: a TLS ClientHello is TLS at once (`directTls`), a PROXY header
|
|
442
|
+
// where none is believed is refused, anything else the protocol's startup phase, SSLRequest included
|
|
401
443
|
if (!this.sniffed) {
|
|
402
444
|
this.sniffed = true;
|
|
403
445
|
if (chunk[0] === TLS_HANDSHAKE) {
|
|
404
446
|
this.directTls(chunk);
|
|
405
447
|
return;
|
|
406
448
|
}
|
|
449
|
+
if (startsProxyHeader(chunk[0])) {
|
|
450
|
+
this.unbelievedProxy();
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
407
453
|
}
|
|
408
454
|
this.decoder.push(chunk);
|
|
409
455
|
void this.drain();
|
|
@@ -429,11 +475,54 @@ class Session {
|
|
|
429
475
|
startedAt: new Date(this.asked.startedAt).toISOString(), runningMs: now - this.asked.startedAt, rows: this.asked.rows ?? 0,
|
|
430
476
|
query: stored.text, queryTruncated: stored.truncated };
|
|
431
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* The PROXY header of a named proxy's connection, as its bytes arrive (proxy.ts `readProxyHeader`): null while it is
|
|
480
|
+
* not whole or once it is refused, else the bytes after it, which are the client's. Bounded: past its 16 fixed bytes
|
|
481
|
+
* the header says its length, and one past MAX_PROXY_BODY is refused there.
|
|
482
|
+
*/
|
|
483
|
+
readProxy(data) {
|
|
484
|
+
const head = this.proxyHead;
|
|
485
|
+
head.bytes = head.bytes.length === 0 ? data : Buffer.concat([head.bytes, data]);
|
|
486
|
+
const read = readProxyHeader(head.bytes);
|
|
487
|
+
if ("more" in read)
|
|
488
|
+
return null;
|
|
489
|
+
clearTimeout(head.timer);
|
|
490
|
+
this.proxyHead = null;
|
|
491
|
+
if ("refused" in read) {
|
|
492
|
+
this.proxyRefused(read.refused);
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
this.clientAddr = clientAddress(this.socket.remoteAddress, read.header);
|
|
496
|
+
this.proxied = true;
|
|
497
|
+
return head.bytes.subarray(read.length);
|
|
498
|
+
}
|
|
499
|
+
/** A named proxy's connection that did not begin with a valid PROXY v2 header: refused, said in the log with why. */
|
|
500
|
+
proxyRefused(why) {
|
|
501
|
+
if (this.phase === "closed")
|
|
502
|
+
return;
|
|
503
|
+
this.server.log(`session ${this.id} refused: ${why}, from ${this.clientAddr ?? "an address that could not be read"}, which ${PROXY_SETTING} names, ` +
|
|
504
|
+
`so it must begin with one — set the proxy to send the PROXY protocol v2 (HAProxy: send-proxy-v2), or take its address out of ${PROXY_SETTING} (docs/POSTGRES-API.md "Behind a TCP proxy")`);
|
|
505
|
+
this.fatal("08P01", "the connection did not begin with a valid PROXY protocol v2 header");
|
|
506
|
+
}
|
|
507
|
+
/** A PROXY header where none is believed: from an address MASON_PG_PROXY_FROM does not name, or a second one. Refused,
|
|
508
|
+
* never read: a header is a claim about who connected, and only a named proxy may make it. */
|
|
509
|
+
unbelievedProxy() {
|
|
510
|
+
// a share's socket is public: a stranger's bytes write no line of the log there
|
|
511
|
+
if (!this.door.public)
|
|
512
|
+
this.server.log(`session ${this.id} refused: a PROXY protocol header from ${this.clientAddr ?? "an address that is not shown"}, ` +
|
|
513
|
+
(this.proxied ? "after the header its proxy sent: one header a connection" : this.door.proxyFrom === null ? "and this listener believes none" : `which ${PROXY_SETTING} does not name`) +
|
|
514
|
+
` — a header is believed only from the addresses ${PROXY_SETTING} names: the proxy's own (docs/POSTGRES-API.md "Behind a TCP proxy")`);
|
|
515
|
+
this.fatal("08P01", "a PROXY protocol header, which this server takes only from the addresses of its own proxy");
|
|
516
|
+
}
|
|
432
517
|
end(why) {
|
|
433
518
|
if (this.phase === "closed")
|
|
434
519
|
return;
|
|
435
520
|
const wasReady = this.phase === "ready";
|
|
436
521
|
this.phase = "closed";
|
|
522
|
+
if (this.proxyHead !== null) {
|
|
523
|
+
clearTimeout(this.proxyHead.timer);
|
|
524
|
+
this.proxyHead = null;
|
|
525
|
+
}
|
|
437
526
|
this.closedBy = why;
|
|
438
527
|
this.door.logins?.settled(this);
|
|
439
528
|
if (wasReady)
|
|
@@ -688,10 +777,25 @@ class Session {
|
|
|
688
777
|
}
|
|
689
778
|
let token;
|
|
690
779
|
try {
|
|
691
|
-
token = await this.door.authenticate(m.password, this.user);
|
|
780
|
+
token = await this.door.authenticate(m.password, this.user, this.clientAddr);
|
|
692
781
|
}
|
|
693
782
|
catch (err) {
|
|
694
783
|
const message = err instanceof Error ? err.message : String(err);
|
|
784
|
+
// an address outside the user's ranges, judged before its password: refused as Postgres refuses a connection no
|
|
785
|
+
// pg_hba.conf line takes (28000), in words made of the name and the address alone, so a missing user and a
|
|
786
|
+
// limited one read the same; which it was is the log's (the ApiError's message)
|
|
787
|
+
if (err instanceof ApiError && err.code === ADDRESS_NOT_ALLOWED) {
|
|
788
|
+
this.server.log(`session ${this.id} refused: ${message}`);
|
|
789
|
+
const refused = new PgError({ code: "28000", message: `no entry for ${this.clientAddr === null ? "an address that could not be read" : `host "${this.clientAddr}"`}, user "${this.user}"`,
|
|
790
|
+
hint: ADDRESS_HINT }, 401);
|
|
791
|
+
this.asked = { sql: "", method: "CONNECT", startedAt: Date.now(), sentAt: this.sentBytes };
|
|
792
|
+
const response = errorResponse({ ...refused.fields, severity: "FATAL" });
|
|
793
|
+
if (this.door.refused?.(this.user) !== false)
|
|
794
|
+
this.recordRequest(refused, undefined, err.userName, [response]);
|
|
795
|
+
this.send(response);
|
|
796
|
+
this.end("authentication failed");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
695
799
|
// a revocation record that could not be read is no wrong password: the server could not tell, said as such
|
|
696
800
|
const unknown = err instanceof ApiError && err.status === 503;
|
|
697
801
|
// a disabled user (`mason user disable`): its password was right, so it is no wrong password but a role that may
|
|
@@ -720,6 +824,22 @@ class Session {
|
|
|
720
824
|
this.fatal("3D000", `database "${this.wrongDatabase}" does not exist`, wrongDatabaseHint(this.server.database));
|
|
721
825
|
return;
|
|
722
826
|
}
|
|
827
|
+
// a startup packet's DateStyle, read as Postgres reads it (`canonicalElsewhere`): refused as the startup's other
|
|
828
|
+
// parameters are, before the session begins
|
|
829
|
+
if (this.startupDateStyle !== null) {
|
|
830
|
+
try {
|
|
831
|
+
const canonical = await this.canonicalElsewhere("datestyle", this.startupDateStyle, this.sessionState().dateStyle);
|
|
832
|
+
this.setParameter("datestyle", isoDateStyle(canonical, this.startupDateStyle));
|
|
833
|
+
this.startupVars = copyVars(this.vars);
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
const e = asPgError(err, "");
|
|
837
|
+
this.fatal(e.fields.code, e.message, e.fields.hint);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (this.phase !== "password")
|
|
841
|
+
return; // the socket closed while the catalog was asked
|
|
842
|
+
}
|
|
723
843
|
this.token = token;
|
|
724
844
|
this.presented = m.password;
|
|
725
845
|
this.verifiedAt = this.server.now();
|
|
@@ -727,7 +847,7 @@ class Session {
|
|
|
727
847
|
this.decoder.maxLength = MAX_MESSAGE_LENGTH; // authenticated: a statement may be as long as the protocol allows
|
|
728
848
|
// what is happening, as it happens (mason logs once showed little): who connected, and (end) who left
|
|
729
849
|
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)}` +
|
|
730
|
-
(token.user !== undefined ? ` (${this.door.who})` : ""));
|
|
850
|
+
(token.user !== undefined ? ` (${this.door.who})` : "") + (this.clientAddr !== null ? ` from ${this.clientAddr}` : ""));
|
|
731
851
|
this.sendAll([authenticationOk(), ...this.reportChanges(), backendKeyData(this.id, this.secret), readyForQuery("I")]);
|
|
732
852
|
}
|
|
733
853
|
reportedParameters() {
|
|
@@ -766,9 +886,14 @@ class Session {
|
|
|
766
886
|
throw new PgError({ code: "22023", message: `invalid value for parameter "client_encoding": "${value}"`, hint: "this API sends UTF8 only" });
|
|
767
887
|
return;
|
|
768
888
|
case "datestyle":
|
|
769
|
-
|
|
770
|
-
|
|
889
|
+
// the value is the catalog Postgres's canonical one (`canonicalElsewhere`): its validity and the order a part not
|
|
890
|
+
// named keeps are Postgres's check_datestyle, never derived here. A startup packet's is read after the password
|
|
891
|
+
// (`startupDateStyle`), where the catalog can be asked
|
|
892
|
+
if (atStartup) {
|
|
893
|
+
this.startupDateStyle = value;
|
|
894
|
+
return;
|
|
771
895
|
}
|
|
896
|
+
this.vars.dateStyle = value === null ? "ISO, MDY" : isoDateStyle(value, value);
|
|
772
897
|
return;
|
|
773
898
|
case "extra_float_digits": {
|
|
774
899
|
const n = value === null ? 1 : Number(value);
|
|
@@ -776,19 +901,44 @@ class Session {
|
|
|
776
901
|
throw new PgError({ code: "22023", message: `${n} is outside the valid range for parameter "extra_float_digits" (-15 .. 3)` });
|
|
777
902
|
if (n < 1)
|
|
778
903
|
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` });
|
|
904
|
+
if (value === null)
|
|
905
|
+
this.vars.other.delete(name);
|
|
906
|
+
else
|
|
907
|
+
this.vars.other.set(name, String(n));
|
|
779
908
|
return;
|
|
780
909
|
}
|
|
781
910
|
case "standard_conforming_strings":
|
|
782
911
|
if (value !== null && !/^(on|true|yes|1)$/i.test(value))
|
|
783
912
|
throw new PgError({ code: "0A000", message: "standard_conforming_strings off is not supported: a string literal is read as Postgres reads it by default" });
|
|
784
913
|
return;
|
|
785
|
-
case "intervalstyle":
|
|
914
|
+
case "intervalstyle": {
|
|
915
|
+
const style = value?.trim().toLowerCase() ?? "postgres";
|
|
916
|
+
if (style === "postgres")
|
|
917
|
+
return;
|
|
918
|
+
if (["postgres_verbose", "sql_standard", "iso_8601"].includes(style)) {
|
|
919
|
+
throw gapRefusal("interval-style", `IntervalStyle ${value} is not supported: every interval is written in Postgres's default style`, "SET IntervalStyle = postgres");
|
|
920
|
+
}
|
|
921
|
+
throw new PgError({ code: "22023", message: `invalid value for parameter "IntervalStyle": "${value}"`, hint: "Available values: postgres, postgres_verbose, sql_standard, iso_8601." });
|
|
922
|
+
}
|
|
786
923
|
case "transaction_isolation":
|
|
787
924
|
case "default_transaction_isolation":
|
|
788
925
|
case "transaction":
|
|
789
|
-
case "session characteristics":
|
|
790
|
-
// read
|
|
926
|
+
case "session characteristics": {
|
|
927
|
+
// remembered, so SHOW and current_setting read back what was set; a store that is never written answers the same
|
|
928
|
+
// rows at every level. SET TRANSACTION … READ ONLY and the like name no level: nothing to remember
|
|
929
|
+
const key = SETTING_KEY[name] ?? name;
|
|
930
|
+
if (value === null) {
|
|
931
|
+
this.vars.other.delete(key);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const level = value.trim().toLowerCase().replace(/\s+/g, " ");
|
|
935
|
+
if (level === "" && (name === "transaction" || name === "session characteristics"))
|
|
936
|
+
return;
|
|
937
|
+
if (!ISOLATION_LEVELS.includes(level))
|
|
938
|
+
throw new PgError({ code: "22023", message: `invalid value for parameter "${key}": "${value}"`, hint: `Available values: ${ISOLATION_LEVELS.join(", ")}.` });
|
|
939
|
+
this.vars.other.set(key, level);
|
|
791
940
|
return;
|
|
941
|
+
}
|
|
792
942
|
case "default_transaction_read_only":
|
|
793
943
|
case "transaction_read_only":
|
|
794
944
|
if (value !== null && /^(off|false|no|0)$/i.test(value))
|
|
@@ -847,6 +997,9 @@ class Session {
|
|
|
847
997
|
case "time zone":
|
|
848
998
|
this.vars.timeZone = start.timeZone;
|
|
849
999
|
return;
|
|
1000
|
+
case "datestyle":
|
|
1001
|
+
this.vars.dateStyle = start.dateStyle;
|
|
1002
|
+
return;
|
|
850
1003
|
case "application_name":
|
|
851
1004
|
this.vars.applicationName = start.applicationName;
|
|
852
1005
|
return;
|
|
@@ -952,6 +1105,24 @@ class Session {
|
|
|
952
1105
|
/** The catalog Postgres validates a parameter this server has no opinion on; its own error is the answer. Inside a
|
|
953
1106
|
* transaction that is ROLLED BACK: the sidecar is one connection every session shares, and a SET that stuck there
|
|
954
1107
|
* was every session's (review of #480). The value itself lives in this session's `vars.other`. */
|
|
1108
|
+
/**
|
|
1109
|
+
* A PARAMETER'S VALUE AS THE CATALOG POSTGRES READS IT, from the session's `current` one: SET LOCAL to `current`, then
|
|
1110
|
+
* to `value`, then SHOW, in a transaction rolled back (as `validateElsewhere`, nothing stays on the shared connection).
|
|
1111
|
+
* Postgres's refusal (22023 and its DETAIL) is relayed as its own; its answer is the canonical value SHOW gives.
|
|
1112
|
+
*/
|
|
1113
|
+
async canonicalElsewhere(name, value, current) {
|
|
1114
|
+
const sql = `BEGIN; SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(current)}; SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}; SHOW ${quoteIdent(name)}; ROLLBACK`;
|
|
1115
|
+
const messages = decodeBackend(Buffer.from(await this.server.catalog.run(Buffer.concat([frame("Q", cstr(sql)), frame("Q", cstr("ROLLBACK"))]))));
|
|
1116
|
+
const err = messages.find((x) => x.type === "ErrorResponse");
|
|
1117
|
+
if (err?.type === "ErrorResponse") {
|
|
1118
|
+
throw new PgError({ code: err.fields.C ?? "22023", message: err.fields.M ?? `invalid value for parameter "${name}"`, ...(err.fields.D !== undefined ? { detail: err.fields.D } : {}), ...(err.fields.H !== undefined ? { hint: err.fields.H } : {}) });
|
|
1119
|
+
}
|
|
1120
|
+
const row = messages.find((x) => x.type === "DataRow");
|
|
1121
|
+
const shown = row?.type === "DataRow" ? row.values[0]?.toString("utf8") : undefined;
|
|
1122
|
+
if (shown === undefined)
|
|
1123
|
+
throw new PgError({ code: "XX000", message: `SHOW ${name} answered no row in the catalog (Mason bug: report it)` }, 500);
|
|
1124
|
+
return shown;
|
|
1125
|
+
}
|
|
955
1126
|
async validateElsewhere(name, value) {
|
|
956
1127
|
const sql = value === null ? `RESET ${quoteIdent(name)}` : `SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}`;
|
|
957
1128
|
// one batch, so no other session's statement lands inside the transaction: the ROLLBACK in the first query is not
|
|
@@ -1057,6 +1228,7 @@ class Session {
|
|
|
1057
1228
|
requestId: crypto.randomUUID(), at: new Date(startedAt), method: asked?.method ?? "QUERY", durationMs: Date.now() - startedAt,
|
|
1058
1229
|
client: `postgres:${this.vars.applicationName || "unnamed"}`, session: this.id, payload: stored.text, payloadTruncated: stored.truncated,
|
|
1059
1230
|
caller: hashCaller(this.presented), bytesSent,
|
|
1231
|
+
...(this.clientAddr !== null ? { clientAddress: this.clientAddr } : {}),
|
|
1060
1232
|
...(user !== undefined ? { user } : {}),
|
|
1061
1233
|
...(role !== undefined ? { role } : {}),
|
|
1062
1234
|
...(this.token?.attributes !== undefined && Object.keys(this.token.attributes).length > 0 ? { attributes: this.token.attributes } : {}),
|
|
@@ -1075,17 +1247,78 @@ class Session {
|
|
|
1075
1247
|
this.server.schema();
|
|
1076
1248
|
return duckTranslate(stmt, text);
|
|
1077
1249
|
}
|
|
1250
|
+
/**
|
|
1251
|
+
* A SELECT OF NO TABLE THAT ORDERS TEXT (issue 122): the catalog's database is C, so one whose analysis orders text
|
|
1252
|
+
* under a collation that is not byte order is the synced data's (src/duck/plan.ts `ordersText`, decided by the
|
|
1253
|
+
* analysis, never the text), planned and refused like any other; every other stays the catalog's (SELECT 1, version(),
|
|
1254
|
+
* 'a' = 'A', anything under C).
|
|
1255
|
+
* ONE THAT MAPS CASE (upper, lower, initcap: issue 406), which the catalog answered before by its own C.UTF-8's
|
|
1256
|
+
* mapping, is the synced data's only where the synced data's RUN answers it (`answersCase`): the route's probe is the
|
|
1257
|
+
* run, never the plan alone, since a construct the rewrite leaves as written (`pg_catalog.lower(…)`, `char 'abc'`,
|
|
1258
|
+
* `FROM (SELECT)`) plans and fails only in the engine; where it fails, the catalog answers, as before.
|
|
1259
|
+
*/
|
|
1260
|
+
async routed(t, text, paramOids, bindable) {
|
|
1261
|
+
const ask = this.server.options.duck.ordersText;
|
|
1262
|
+
if (t.kind !== "catalog" || t.fromless === undefined || ask === undefined)
|
|
1263
|
+
return t;
|
|
1264
|
+
const why = await ask((b) => this.server.catalog.run(b), text, paramOids, this.sessionState(), bindable);
|
|
1265
|
+
if (why === null || why === "case" && !await this.answersCase(text, paramOids))
|
|
1266
|
+
return t;
|
|
1267
|
+
return { kind: "duckdb", sql: text, names: t.fromless.names };
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* WHETHER THE SYNCED DATA ANSWERS A STATEMENT OF NO TABLE THAT MAPS CASE: its plan made and RUN on this session's
|
|
1271
|
+
* connection, under its state and clock, every row read and none sent. A statement the plan or the run refuses, or
|
|
1272
|
+
* one with a parameter (no value before its Bind), is the catalog's, which answered it before (Mason gap
|
|
1273
|
+
* fromless-numeric-text-order). Only a statement's failure is read so; any other is thrown.
|
|
1274
|
+
*/
|
|
1275
|
+
async answersCase(text, paramOids) {
|
|
1276
|
+
const raw = (await parseSql(text)).stmts?.[0];
|
|
1277
|
+
if (raw?.stmt === undefined)
|
|
1278
|
+
return false;
|
|
1279
|
+
const controller = new AbortController();
|
|
1280
|
+
const disarm = this.armTimeout(controller);
|
|
1281
|
+
try {
|
|
1282
|
+
const plan = await this.duckPlan(raw, text, paramOids);
|
|
1283
|
+
if (plan.params.length > 0)
|
|
1284
|
+
return false;
|
|
1285
|
+
const now = this.txNow ?? this.statementNow;
|
|
1286
|
+
const { cursor } = await this.duckAnswer(plan, [], [], { parse: now, exec: now }, controller);
|
|
1287
|
+
try {
|
|
1288
|
+
while (!cursor.done) {
|
|
1289
|
+
await this.fill(cursor);
|
|
1290
|
+
cursor.pending = [];
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
finally {
|
|
1294
|
+
cursor.answer.close();
|
|
1295
|
+
}
|
|
1296
|
+
return true;
|
|
1297
|
+
}
|
|
1298
|
+
catch (e) {
|
|
1299
|
+
// the statement's own failure (Postgres's analysis, the rewrite's refusal, the engine's error at the run): said in
|
|
1300
|
+
// the log and answered by the catalog, as before this route existed
|
|
1301
|
+
// (a cancel or the statement's timeout is the statement's answer, never a reason to run it again on the catalog)
|
|
1302
|
+
if (controller.signal.aborted || !(e instanceof StatementError || e instanceof PgError || e instanceof EncodeError || e instanceof NumericError))
|
|
1303
|
+
throw e;
|
|
1304
|
+
this.server.log(`session ${this.id}: a statement of no table that maps case is answered by the catalog: the synced data's run failed (${e instanceof StatementError || e instanceof PgError ? `${e.fields.code} ` : ""}${e.message})`);
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
finally {
|
|
1308
|
+
disarm();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1078
1311
|
/** One statement of a simple query: the whole reply but the ReadyForQuery. */
|
|
1079
1312
|
async runSimple(raw, text, whole) {
|
|
1080
1313
|
if (raw.stmt === undefined)
|
|
1081
1314
|
return [emptyQueryResponse()];
|
|
1082
1315
|
const now = this.txStart(this.statementNow);
|
|
1083
1316
|
this.heldToFailedBlock(raw.stmt);
|
|
1084
|
-
const t = this.classify(raw.stmt, text);
|
|
1317
|
+
const t = await this.routed(this.classify(raw.stmt, text), text, [], false);
|
|
1085
1318
|
switch (t.kind) {
|
|
1086
1319
|
case "empty": return [emptyQueryResponse()];
|
|
1087
1320
|
case "explain": {
|
|
1088
|
-
const e = await this.explaining(raw, whole, t, []);
|
|
1321
|
+
const e = await this.explaining(raw, whole, t, [], false);
|
|
1089
1322
|
if (e.plan === null)
|
|
1090
1323
|
return catalogExplained(await this.forwardSimple(this.spliced(text, raw)), e.options);
|
|
1091
1324
|
const controller = new AbortController();
|
|
@@ -1139,20 +1372,19 @@ class Session {
|
|
|
1139
1372
|
/** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone,
|
|
1140
1373
|
* declared parameter types and the session's role until the schema or the roles change. Every statement DuckDB
|
|
1141
1374
|
* runs has one: never its text as written. */
|
|
1142
|
-
async duckPlan(raw, whole, paramOids, under = this.
|
|
1375
|
+
async duckPlan(raw, whole, paramOids, under = this.sessionState(), wireBound = false) {
|
|
1143
1376
|
const stmt = raw.stmt;
|
|
1144
1377
|
const bytes = Buffer.from(whole, "utf8");
|
|
1145
1378
|
const base = raw.stmt_location ?? 0;
|
|
1146
1379
|
const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
|
|
1147
|
-
const { searchPath, timeZone } = under;
|
|
1148
1380
|
const grants = this.grants();
|
|
1149
1381
|
// 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
1382
|
// a user with no role under a file with no default its own key, never a token's ("") — and, where the role filters
|
|
1151
1383
|
// rows, the filters bound to the user's values (a plan holds them as literals)
|
|
1152
1384
|
const rowsKey = rowsKeyOf(grants);
|
|
1153
1385
|
const key = `${grants?.noRole !== undefined ? "\u0000no role" : this.roleName() ?? ""}${rowsKey === "" ? "" : `\u0000${rowsKey}`}`;
|
|
1154
|
-
return this.server.duckPlan(text, paramOids,
|
|
1155
|
-
const plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids,
|
|
1386
|
+
return this.server.duckPlan(text, paramOids, under, wireBound, key, async () => {
|
|
1387
|
+
const plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, under, this.server.userTypes(), wireBound, grants);
|
|
1156
1388
|
return rowsKey === "" ? plan : { ...plan, rowsKey };
|
|
1157
1389
|
});
|
|
1158
1390
|
}
|
|
@@ -1280,7 +1512,8 @@ class Session {
|
|
|
1280
1512
|
const live = this.server.rolesNow();
|
|
1281
1513
|
return live.file !== null && live.problem === null && live.default === null;
|
|
1282
1514
|
}
|
|
1283
|
-
/** the statement as the catalog runs it for this session: the user's name spliced for the keywords that name it
|
|
1515
|
+
/** the statement as the catalog runs it for this session: the user's name spliced for the keywords that name it, and
|
|
1516
|
+
* every call of an override (`userSpliced`) */
|
|
1284
1517
|
spliced(text, raw) {
|
|
1285
1518
|
return userSpliced(text, raw.stmt, raw.stmt_location ?? 0, this.user, this.heldToNothing() ? HELD_TO_NOTHING_OPS : SESSION_OPS);
|
|
1286
1519
|
}
|
|
@@ -1321,7 +1554,12 @@ class Session {
|
|
|
1321
1554
|
// fail closed first (a role the file lacks, a file that cannot be read), then the catalog's role of the session
|
|
1322
1555
|
// (`catalogRole`; both read the roles once, with no await between)
|
|
1323
1556
|
const role = this.catalogRole();
|
|
1324
|
-
|
|
1557
|
+
// the transaction's instant, which a block's statements on either path read (`txNow`): begun here when none is
|
|
1558
|
+
// (reset at the end of a statement outside a block, as executeDuck's)
|
|
1559
|
+
const now = this.txNow ??= this.server.now();
|
|
1560
|
+
// the request's transaction begins at that instant (asSession, catalog-worker.ts pinnedClock): the catalog's own
|
|
1561
|
+
// transaction start is the session's, whatever reads it
|
|
1562
|
+
const reply = unwrapSession(splitFrames(await this.server.catalog.run(asSession(role === undefined ? message : asRole(message, role), this.sessionState(), this.sessionSettings()), now)));
|
|
1325
1563
|
if (role === undefined)
|
|
1326
1564
|
return reply;
|
|
1327
1565
|
const { frames, setRoleRefused } = unwrapRole(reply);
|
|
@@ -1353,7 +1591,7 @@ class Session {
|
|
|
1353
1591
|
this.running = controller;
|
|
1354
1592
|
let bounded;
|
|
1355
1593
|
try {
|
|
1356
|
-
bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.
|
|
1594
|
+
bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.sessionState(), true);
|
|
1357
1595
|
}
|
|
1358
1596
|
catch (e) {
|
|
1359
1597
|
if (!(e instanceof StatementError) || e.fields.code === "57014")
|
|
@@ -1380,12 +1618,12 @@ class Session {
|
|
|
1380
1618
|
* statement it explains classified as that statement alone would be — refused with that statement's own error — and,
|
|
1381
1619
|
* for DuckDB's, planned afresh (an ANALYZE's Planning Time is this one's), never from the plans kept per text.
|
|
1382
1620
|
*/
|
|
1383
|
-
async explaining(raw, whole, t, paramOids) {
|
|
1621
|
+
async explaining(raw, whole, t, paramOids, bindable) {
|
|
1384
1622
|
const options = explainOptions(t.options);
|
|
1385
|
-
const
|
|
1386
|
-
if (
|
|
1623
|
+
const classified = this.classify(t.query, "");
|
|
1624
|
+
if (classified.kind === "catalog" && classified.fromless === undefined)
|
|
1387
1625
|
return { options, plan: null };
|
|
1388
|
-
if (
|
|
1626
|
+
if (classified.kind !== "duckdb" && classified.kind !== "catalog")
|
|
1389
1627
|
throw new PgError({ code: "0A000", message: "EXPLAIN of this statement is not supported: a SELECT is explained" });
|
|
1390
1628
|
const bytes = Buffer.from(whole, "utf8");
|
|
1391
1629
|
const base = raw.stmt_location ?? 0;
|
|
@@ -1393,10 +1631,13 @@ class Session {
|
|
|
1393
1631
|
const found = await explainedText(text, t.query);
|
|
1394
1632
|
if (found === null)
|
|
1395
1633
|
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)" });
|
|
1634
|
+
// a statement of no table is explained as what answers it: the synced data's plan where it orders text (`routed`)
|
|
1635
|
+
if ((await this.routed(classified, found.text, paramOids, bindable)).kind === "catalog")
|
|
1636
|
+
return { options, plan: null };
|
|
1396
1637
|
let plan;
|
|
1397
1638
|
try {
|
|
1398
1639
|
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.
|
|
1640
|
+
plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), found.text, t.query, base + Buffer.byteLength(text.slice(0, found.offset)), paramOids, this.sessionState(), this.server.userTypes(), false, grants);
|
|
1400
1641
|
// the row filters it was made under, as every plan of a session carries them (Session.duckPlan)
|
|
1401
1642
|
const rowsKey = rowsKeyOf(grants);
|
|
1402
1643
|
if (rowsKey !== "")
|
|
@@ -1438,10 +1679,10 @@ class Session {
|
|
|
1438
1679
|
* transaction's start where the plan reads it (its positions follow the statement's own parameters) */
|
|
1439
1680
|
async duckValues(plan, params, clock) {
|
|
1440
1681
|
const own = params.length === 0 ? params
|
|
1441
|
-
: await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.
|
|
1682
|
+
: await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.sessionState());
|
|
1442
1683
|
const values = [...own];
|
|
1443
1684
|
for (const [which, at] of Object.entries(plan.clock ?? {}))
|
|
1444
|
-
values[at - 1] =
|
|
1685
|
+
values[at - 1] = instantText(clock[which]);
|
|
1445
1686
|
return values;
|
|
1446
1687
|
}
|
|
1447
1688
|
async duck() {
|
|
@@ -1456,7 +1697,17 @@ class Session {
|
|
|
1456
1697
|
*/
|
|
1457
1698
|
async duckSession() {
|
|
1458
1699
|
const settings = new Map([...await this.server.catalogSettings(), ...this.sessionSettings()]);
|
|
1459
|
-
return {
|
|
1700
|
+
return { ...this.sessionState(), settings: Object.fromEntries(settings) };
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* THE SESSION'S STATE FOR AN ANSWER, AS ONE VALUE (GitHub issue 253): its TimeZone, search_path and DateStyle, each
|
|
1704
|
+
* `sessionSetting`'s — the one reader SHOW answers from — so a statement planned, analysed, run on DuckDB or answered by
|
|
1705
|
+
* the catalog Postgres is made under the same values (src/api/postgres/catalog.ts SessionState). Nothing else reads
|
|
1706
|
+
* the session's zone, path or order.
|
|
1707
|
+
*/
|
|
1708
|
+
sessionState() {
|
|
1709
|
+
const read = (name, otherwise) => this.sessionSetting(name) ?? otherwise;
|
|
1710
|
+
return { timeZone: read("timezone", "UTC"), searchPath: read("search_path", '"$user", public'), dateStyle: read("datestyle", "ISO, MDY") };
|
|
1460
1711
|
}
|
|
1461
1712
|
/** A DuckDB statement run: its columns (Postgres's, where it has a plan), and its rows to read, encoded in the
|
|
1462
1713
|
* formats asked */
|
|
@@ -1505,7 +1756,7 @@ class Session {
|
|
|
1505
1756
|
if (mine)
|
|
1506
1757
|
this.running = c.controller;
|
|
1507
1758
|
try {
|
|
1508
|
-
const rows = await c.answer.fetch(c.formats, { timeZone: this.
|
|
1759
|
+
const rows = await c.answer.fetch(c.formats, { timeZone: this.sessionState().timeZone });
|
|
1509
1760
|
if (rows === null)
|
|
1510
1761
|
c.done = true;
|
|
1511
1762
|
else
|
|
@@ -1652,12 +1903,16 @@ class Session {
|
|
|
1652
1903
|
placeholderName(t.name);
|
|
1653
1904
|
else if (!this.decidesHere(t.name))
|
|
1654
1905
|
await this.validateElsewhere(t.name, t.value);
|
|
1906
|
+
// DateStyle: Postgres's check_datestyle decides its validity (22023 with its DETAIL) and the order a part not named
|
|
1907
|
+
// keeps, from the session's value (GitHub issue 253 review r1); Mason adds only its refusal of a non-ISO style
|
|
1908
|
+
const value = t.name === "datestyle" && t.value !== null ? await this.canonicalElsewhere("datestyle", t.value, this.sessionState().dateStyle) : t.value;
|
|
1655
1909
|
const tx = this.settingsAtStart();
|
|
1656
|
-
if (
|
|
1910
|
+
if (value === null)
|
|
1657
1911
|
await this.resetParameter(t.name);
|
|
1658
1912
|
else
|
|
1659
|
-
this.setParameter(t.name, t.value);
|
|
1660
|
-
|
|
1913
|
+
this.setParameter(t.name, value === t.value ? value : isoDateStyle(value, t.value));
|
|
1914
|
+
// transaction_isolation is the transaction's alone, however it is SET (Postgres resets it as the transaction ends)
|
|
1915
|
+
if (!t.local && t.name !== "transaction_isolation")
|
|
1661
1916
|
copySetting(t.name, this.vars, tx.commit);
|
|
1662
1917
|
// once named a placeholder stays defined, '', whichever way the transaction ends: its definition is no value it undoes
|
|
1663
1918
|
if (placeholder)
|
|
@@ -1882,9 +2137,9 @@ class Session {
|
|
|
1882
2137
|
throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
|
|
1883
2138
|
const statement = { sql: m.sql, raw, paramOids: [...m.paramTypes], shape: null, catalogName: null };
|
|
1884
2139
|
if (raw?.stmt !== undefined) {
|
|
1885
|
-
statement.shape = this.classify(raw.stmt, statementText(m.sql, raw));
|
|
2140
|
+
statement.shape = await this.routed(this.classify(raw.stmt, statementText(m.sql, raw)), statementText(m.sql, raw), m.paramTypes, true);
|
|
1886
2141
|
if (statement.shape.kind === "explain") {
|
|
1887
|
-
const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes);
|
|
2142
|
+
const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes, true);
|
|
1888
2143
|
if (e.plan === null)
|
|
1889
2144
|
statement.explainCatalog = e.options;
|
|
1890
2145
|
else {
|
|
@@ -1894,8 +2149,9 @@ class Session {
|
|
|
1894
2149
|
}
|
|
1895
2150
|
// Postgres's analysis says the result's columns and the parameters' types, and DuckDB's SQL is made from it
|
|
1896
2151
|
if (statement.shape.kind === "duckdb") {
|
|
1897
|
-
|
|
1898
|
-
statement.
|
|
2152
|
+
const under = this.sessionState();
|
|
2153
|
+
statement.duck = await this.duckPlan(raw, m.sql, m.paramTypes, under);
|
|
2154
|
+
statement.plannedUnder = under;
|
|
1899
2155
|
// the implicit transaction begins with the Parse, as Postgres's does (start_xact_command): its literals read it
|
|
1900
2156
|
statement.parsedNow = this.txNow ??= this.server.now();
|
|
1901
2157
|
}
|
|
@@ -1939,6 +2195,7 @@ class Session {
|
|
|
1939
2195
|
catalog: { description, rows: statement.explainCatalog === undefined ? rows : catalogExplained(rows, statement.explainCatalog), sent: 0, done } });
|
|
1940
2196
|
return [bound];
|
|
1941
2197
|
}
|
|
2198
|
+
await this.plannedAgainIfMoved(statement);
|
|
1942
2199
|
const planned = statement.duck ?? statement.explain?.plan;
|
|
1943
2200
|
const expected = planned !== undefined ? planned.params.length : 0;
|
|
1944
2201
|
if (m.params.length !== expected)
|
|
@@ -1949,6 +2206,9 @@ class Session {
|
|
|
1949
2206
|
const format = m.paramFormats.length === 0 ? 0 : m.paramFormats.length === 1 ? m.paramFormats[0] : m.paramFormats[i] ?? 0;
|
|
1950
2207
|
if (format === 0)
|
|
1951
2208
|
return p.toString("utf8");
|
|
2209
|
+
const oid = statement.paramOids[i] || planned?.params[i] || 0;
|
|
2210
|
+
if (TEXT_ARRAYS.has(oid))
|
|
2211
|
+
return textArrayFromBinary(p, i, oid === 1015 ? 1043 : 25);
|
|
1952
2212
|
const type = oidOf(statement.paramOids[i] ?? 0) ?? (planned !== undefined ? oidOf(planned.params[i] ?? 0) : undefined) ?? "text";
|
|
1953
2213
|
return decodeBinaryParam(type, p, i);
|
|
1954
2214
|
});
|
|
@@ -2090,6 +2350,25 @@ class Session {
|
|
|
2090
2350
|
if (this.asked !== null && plan.jsonReads !== undefined)
|
|
2091
2351
|
this.asked.jsonReads = plan.jsonReads;
|
|
2092
2352
|
}
|
|
2353
|
+
/**
|
|
2354
|
+
* THE SEARCH_PATH MOVED SINCE A STATEMENT WAS PLANNED: at its Bind it is planned again under the session's state now,
|
|
2355
|
+
* as Postgres's plancache plans a cached statement again (`GetCachedPlan` in exec_bind_message; GitHub issue 63) —
|
|
2356
|
+
* never run against tables named under another path. Its parameters keep the types the Parse fixed; a result that
|
|
2357
|
+
* moved (names, types, count) is Postgres's 0A000, and a refusal of the new plan (42P01) is the Bind's, as Postgres's.
|
|
2358
|
+
* The portal keeps the plan it was bound with. The zone alone plans nothing again: the plan reads it at execution.
|
|
2359
|
+
*/
|
|
2360
|
+
async plannedAgainIfMoved(statement) {
|
|
2361
|
+
const now = this.sessionState();
|
|
2362
|
+
if (statement.raw === null || statement.duck === undefined || statement.plannedUnder === undefined || statement.plannedUnder.searchPath === now.searchPath)
|
|
2363
|
+
return;
|
|
2364
|
+
const again = await this.duckPlan(statement.raw, statement.sql, statement.duck.params, now);
|
|
2365
|
+
const result = (p) => JSON.stringify(p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null);
|
|
2366
|
+
if (result(again) !== result(statement.duck))
|
|
2367
|
+
throw new PgError({ code: "0A000", message: "cached plan must not change result type" });
|
|
2368
|
+
statement.duck = again;
|
|
2369
|
+
statement.plannedUnder = now;
|
|
2370
|
+
delete statement.duckOnWire;
|
|
2371
|
+
}
|
|
2093
2372
|
/** an Execute of a DuckDB statement's portal: its cursor begun at the first, then paged or streamed */
|
|
2094
2373
|
async executeDuck(m, portal, statement, controller) {
|
|
2095
2374
|
// each Execute of a paged portal is a message of its own: the last one's row in the request log carries the reads
|
|
@@ -2101,7 +2380,7 @@ class Session {
|
|
|
2101
2380
|
// A ROLE'S ROW FILTERS MOVED SINCE THE PARSE (the roles file edited): planned again under them, as Postgres plans a
|
|
2102
2381
|
// prepared statement again after its policies change — where its columns and parameters are what the Parse said
|
|
2103
2382
|
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.
|
|
2383
|
+
const again = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.sessionState());
|
|
2105
2384
|
const shape = (p) => JSON.stringify([p.params, p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null]);
|
|
2106
2385
|
if (shape(again) === shape(statement.duck)) {
|
|
2107
2386
|
statement.duck = again;
|
|
@@ -2179,11 +2458,12 @@ class Session {
|
|
|
2179
2458
|
if (recheck === undefined || user === undefined)
|
|
2180
2459
|
return null;
|
|
2181
2460
|
const now = this.server.now();
|
|
2182
|
-
|
|
2461
|
+
// the instant is microseconds (src/lib/instant.ts), the interval milliseconds
|
|
2462
|
+
if (now - this.verifiedAt < this.door.recheckMs * 1000)
|
|
2183
2463
|
return null;
|
|
2184
2464
|
try {
|
|
2185
2465
|
// 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);
|
|
2466
|
+
const fresh = await recheck(this.token, this.clientAddr);
|
|
2187
2467
|
if (fresh !== undefined && this.token !== null)
|
|
2188
2468
|
this.token = fresh;
|
|
2189
2469
|
}
|
|
@@ -2196,7 +2476,8 @@ class Session {
|
|
|
2196
2476
|
this.recordRefusal(asked.toUpperCase(), err);
|
|
2197
2477
|
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
2478
|
? `a disabled user's sessions end at their next statement; mason user enable ${user} lets it connect again, with the same password`
|
|
2199
|
-
: `a
|
|
2479
|
+
: err instanceof ApiError && err.code === ADDRESS_NOT_ALLOWED ? `its address ranges changed: a session from outside them ends at its next statement (mason user list shows them; mason user from ${user} changes them)`
|
|
2480
|
+
: `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
|
|
2200
2481
|
return null;
|
|
2201
2482
|
}
|
|
2202
2483
|
this.verifiedAt = now;
|
|
@@ -2225,8 +2506,10 @@ const USER_OPS = { SVFOP_SESSION_USER: "session_user", SVFOP_CURRENT_USER: "curr
|
|
|
2225
2506
|
const SESSION_OPS = new Set(["SVFOP_SESSION_USER"]);
|
|
2226
2507
|
/** a user the roles file holds to nothing: its catalog role is `NO_ROLE_CATALOG`, never a name it should answer */
|
|
2227
2508
|
const HELD_TO_NOTHING_OPS = new Set(Object.keys(USER_OPS));
|
|
2228
|
-
/**
|
|
2229
|
-
|
|
2509
|
+
/** the functions Mason overrides in the catalog's `public` (src/api/postgres/catalog.ts currentSettingSql,
|
|
2510
|
+
* sourceTypeNames; `version()` in `Server`'s setup), each with what a call of it may be written with: `pg_catalog.` too,
|
|
2511
|
+
* or unqualified only (`pg_catalog.format_type`, written out, is Postgres's own) */
|
|
2512
|
+
const OVERRIDES = { version: { qualified: true }, current_setting: { qualified: true }, format_type: { qualified: false } };
|
|
2230
2513
|
/**
|
|
2231
2514
|
* WHAT A STATEMENT THE CATALOG ANSWERS READS OF ITS SESSION, spliced in its text at the parse tree's locations:
|
|
2232
2515
|
* - `session_user` IS THIS SESSION'S USER, never the catalog's superuser (it answered `postgres` for every session while
|
|
@@ -2237,13 +2520,16 @@ const SETTING = "\u0000setting";
|
|
|
2237
2520
|
* src/api/postgres/catalog.ts asRole), spliced only for a user the roles file holds to nothing
|
|
2238
2521
|
* (`HELD_TO_NOTHING_OPS`), whose catalog role is `NO_ROLE_CATALOG`: Postgres answers its own name for all three to a
|
|
2239
2522
|
* login with no grant.
|
|
2240
|
-
* - `
|
|
2241
|
-
*
|
|
2242
|
-
*
|
|
2523
|
+
* - EVERY CALL OF AN OVERRIDE (`OVERRIDES`: `version()`, `current_setting(…)`, `format_type(…)`), however written, is
|
|
2524
|
+
* the override's (`public.<name>`): the catalog runs a request under the session's search_path, which puts pg_catalog
|
|
2525
|
+
* first as Postgres's does (GitHub issue 253), so the override is reached by name, never by the path's order — and
|
|
2526
|
+
* Postgres's own `pg_catalog.current_setting` is called by its OID's C function whatever its catalog row says, so it
|
|
2527
|
+
* cannot be replaced (GitHub issue 40). The call is read from the parse tree's FuncCall, never from the text.
|
|
2528
|
+
* The clock is never spliced: the catalog's transaction begins at the session's instant (catalog.ts asSession), so
|
|
2529
|
+
* every reader of it is the session's as written (review r3 of PR 333).
|
|
2243
2530
|
* `base`: the statement's offset in the text the parse tree's locations count from.
|
|
2244
2531
|
*/
|
|
2245
2532
|
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
2533
|
const at = new Map();
|
|
2248
2534
|
const walk = (n) => {
|
|
2249
2535
|
if (Array.isArray(n)) {
|
|
@@ -2254,12 +2540,16 @@ function userSpliced(text, stmt, base, user, ops) {
|
|
|
2254
2540
|
if (typeof n !== "object" || n === null)
|
|
2255
2541
|
return;
|
|
2256
2542
|
const f = n.SQLValueFunction;
|
|
2257
|
-
if (f !== undefined && f.op !== undefined &&
|
|
2258
|
-
at.set(f.location, USER_OPS[f.op]);
|
|
2543
|
+
if (f !== undefined && f.op !== undefined && typeof f.location === "number" && ops.has(f.op))
|
|
2544
|
+
at.set(f.location, { kind: "user", keyword: USER_OPS[f.op] });
|
|
2259
2545
|
const c = n.FuncCall;
|
|
2260
2546
|
const name = c?.funcname?.map((x) => x.String?.sval ?? "");
|
|
2261
|
-
if (c !== undefined && typeof c.location === "number" && name
|
|
2262
|
-
at
|
|
2547
|
+
if (c !== undefined && name !== undefined && typeof c.location === "number" && c.over === undefined && (name.length === 1 || (name.length === 2 && name[0] === "pg_catalog"))) {
|
|
2548
|
+
const fn = name.at(-1);
|
|
2549
|
+
const override = OVERRIDES[fn];
|
|
2550
|
+
if (override !== undefined && (name.length === 1 || override.qualified))
|
|
2551
|
+
at.set(c.location, { kind: "override", name: fn });
|
|
2552
|
+
}
|
|
2263
2553
|
for (const v of Object.values(n))
|
|
2264
2554
|
walk(v);
|
|
2265
2555
|
};
|
|
@@ -2269,34 +2559,41 @@ function userSpliced(text, stmt, base, user, ops) {
|
|
|
2269
2559
|
const bytes = Buffer.from(text, "utf8");
|
|
2270
2560
|
const parts = [];
|
|
2271
2561
|
let end = bytes.length;
|
|
2272
|
-
|
|
2273
|
-
|
|
2562
|
+
const qualified = (name) => new RegExp(`^(?:(?:pg_catalog|"pg_catalog")\\s*\\.\\s*)?(?:${name}|"${name}")(?![A-Za-z0-9_$])`, "i");
|
|
2563
|
+
for (const loc of [...at.keys()].sort((x, y) => y - x)) {
|
|
2564
|
+
const splice = at.get(loc);
|
|
2274
2565
|
const from = loc - base;
|
|
2275
|
-
if (from < 0)
|
|
2566
|
+
if (from < 0 || from >= end)
|
|
2276
2567
|
continue;
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2568
|
+
const ahead = bytes.subarray(from, Math.min(end, from + 128)).toString("utf8");
|
|
2569
|
+
let written, put;
|
|
2570
|
+
if (splice.kind === "override") {
|
|
2571
|
+
// the name as written (`version`, `pg_catalog.current_setting`, `"pg_catalog" . "version"`): its span, never a guess
|
|
2572
|
+
const m = qualified(splice.name).exec(ahead);
|
|
2573
|
+
if (m === null)
|
|
2282
2574
|
continue;
|
|
2283
|
-
written =
|
|
2575
|
+
written = m[0];
|
|
2576
|
+
put = `public.${splice.name}`;
|
|
2284
2577
|
}
|
|
2285
2578
|
else {
|
|
2286
|
-
|
|
2579
|
+
const keyword = splice.keyword;
|
|
2580
|
+
if (ahead.slice(0, keyword.length).toLowerCase() !== keyword)
|
|
2287
2581
|
continue;
|
|
2288
2582
|
// 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(
|
|
2583
|
+
if (/^[A-Za-z0-9_$]/.test(ahead.slice(keyword.length, keyword.length + 1)))
|
|
2290
2584
|
continue;
|
|
2291
2585
|
written = keyword;
|
|
2586
|
+
put = `(SELECT ${quoteLiteral(user)}::name AS ${keyword})`;
|
|
2292
2587
|
}
|
|
2293
2588
|
parts.unshift(bytes.subarray(from + Buffer.byteLength(written), end));
|
|
2294
|
-
parts.unshift(Buffer.from(
|
|
2589
|
+
parts.unshift(Buffer.from(put, "utf8"));
|
|
2295
2590
|
end = from;
|
|
2296
2591
|
}
|
|
2297
2592
|
parts.unshift(bytes.subarray(0, end));
|
|
2298
2593
|
return Buffer.concat(parts).toString("utf8");
|
|
2299
2594
|
}
|
|
2595
|
+
/** the splice of a statement the catalog answers, exported for its unit tests (tests/postgres-api/server.test.ts) */
|
|
2596
|
+
export const userSplicedForTest = userSpliced;
|
|
2300
2597
|
/** the text of one statement of a simple query, from the parser's byte offsets */
|
|
2301
2598
|
function statementText(sql, raw) {
|
|
2302
2599
|
const bytes = Buffer.from(sql, "utf8");
|
|
@@ -2306,6 +2603,114 @@ function statementText(sql, raw) {
|
|
|
2306
2603
|
}
|
|
2307
2604
|
const oidOf = (oid) => Object.keys(OID).find((t) => OID[t].oid === oid);
|
|
2308
2605
|
const PG_EPOCH_MS = Date.UTC(2000, 0, 1);
|
|
2606
|
+
/** an element type's name, as array_recv's 42804 says it */
|
|
2607
|
+
const ELEMENT_NAMES = { 25: "text", 1043: "character varying", 16: "boolean", 20: "bigint", 21: "smallint", 23: "integer", 700: "real", 701: "double precision", 1700: "numeric", 2950: "uuid", 1082: "date", 1114: "timestamp without time zone", 1184: "timestamp with time zone", 3802: "jsonb", 114: "json" };
|
|
2608
|
+
/** text[] and varchar[], the array types whose binary form a Bind is decoded from (textArrayFromBinary) */
|
|
2609
|
+
const TEXT_ARRAYS = new Set([1009, 1015]);
|
|
2610
|
+
/**
|
|
2611
|
+
* A BINARY text[] (or varchar[]) PARAMETER, as the literal array_in reads to the same array (Npgsql sends every
|
|
2612
|
+
* parameter binary): array_send's form, its dimensions, a NULL flag, the element type, each dimension's length and lower
|
|
2613
|
+
* bound, then each element's length (-1 a NULL) and bytes. Every element is quoted, so any character is itself; a lower
|
|
2614
|
+
* bound other than 1 and a second dimension are written as array_in reads them, and refused where every text[] value is
|
|
2615
|
+
* (src/duck/analyze.ts readParameters). Bytes that are no such array are refused as array_recv refuses them, in its
|
|
2616
|
+
* order: too few 08P01 (22P03 within an element), flags other than 0 or 1 22P03, an element type other than the
|
|
2617
|
+
* parameter's own 42804, a negative or too large dimension 54000, an element that is no UTF-8 or holds a NUL 22021, too
|
|
2618
|
+
* many bytes 22P03 naming the parameter.
|
|
2619
|
+
*/
|
|
2620
|
+
function textArrayFromBinary(bytes, i, expected) {
|
|
2621
|
+
const bad = () => new PgError({ code: "22P03", message: `incorrect binary data format in bind parameter ${i + 1}` });
|
|
2622
|
+
const short = () => new PgError({ code: "08P01", message: "insufficient data left in message" });
|
|
2623
|
+
let at = 0;
|
|
2624
|
+
const int = () => { if (at + 4 > bytes.length)
|
|
2625
|
+
throw short(); const v = bytes.readInt32BE(at); at += 4; return v; };
|
|
2626
|
+
const ndim = int();
|
|
2627
|
+
if (ndim < 0 || ndim > 6)
|
|
2628
|
+
throw new PgError({ code: ndim < 0 ? "22P03" : "54000", message: ndim < 0 ? `invalid number of dimensions: ${ndim}` : `number of array dimensions (${ndim}) exceeds the maximum allowed (6)` });
|
|
2629
|
+
// array_recv's order: the dimensions, the flags, the element type, then each dimension's size
|
|
2630
|
+
const flags = int();
|
|
2631
|
+
if (flags !== 0 && flags !== 1)
|
|
2632
|
+
throw new PgError({ code: "22P03", message: "invalid array flags" });
|
|
2633
|
+
const element = int() >>> 0;
|
|
2634
|
+
if (element !== expected) {
|
|
2635
|
+
throw new PgError({ code: "42804", message: `binary data has array element type ${element} (${ELEMENT_NAMES[element] ?? "another type"}) instead of expected ${expected} (${ELEMENT_NAMES[expected]})` });
|
|
2636
|
+
}
|
|
2637
|
+
const dims = [];
|
|
2638
|
+
for (let d = 0; d < ndim; d++) {
|
|
2639
|
+
const length = int();
|
|
2640
|
+
dims.push({ length, lower: int() });
|
|
2641
|
+
}
|
|
2642
|
+
// ArrayGetNItems: a negative dimension, a product past an int32 at any step, or past MaxArraySize at the end
|
|
2643
|
+
const MAX_ARRAY_SIZE = 268435455;
|
|
2644
|
+
const tooBig = () => new PgError({ code: "54000", message: `array size exceeds the maximum allowed (${MAX_ARRAY_SIZE})` });
|
|
2645
|
+
let items = 1;
|
|
2646
|
+
for (const d of dims) {
|
|
2647
|
+
if (d.length < 0)
|
|
2648
|
+
throw tooBig();
|
|
2649
|
+
items *= d.length;
|
|
2650
|
+
if (items > 0x7fffffff)
|
|
2651
|
+
throw tooBig();
|
|
2652
|
+
}
|
|
2653
|
+
if (items > MAX_ARRAY_SIZE)
|
|
2654
|
+
throw tooBig();
|
|
2655
|
+
const elementText = () => {
|
|
2656
|
+
const length = int();
|
|
2657
|
+
if (length === -1)
|
|
2658
|
+
return "NULL";
|
|
2659
|
+
// ReadArrayBinary's own check is 22P03, where reading a length past the end is pq_getmsgint's 08P01
|
|
2660
|
+
if (length < 0 || at + length > bytes.length)
|
|
2661
|
+
throw new PgError({ code: "22P03", message: "insufficient data left in message" });
|
|
2662
|
+
const text = utf8Element(bytes.subarray(at, at + length));
|
|
2663
|
+
at += length;
|
|
2664
|
+
return `"${text.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
2665
|
+
};
|
|
2666
|
+
const level = (d) => `{${Array.from({ length: dims[d].length }, () => d === ndim - 1 ? elementText() : level(d + 1)).join(",")}}`;
|
|
2667
|
+
const body = ndim === 0 || dims.some((d) => d.length === 0) ? "{}" : level(0);
|
|
2668
|
+
if (at !== bytes.length)
|
|
2669
|
+
throw bad();
|
|
2670
|
+
const bounds = dims.every((d) => d.lower === 1) || body === "{}" ? "" : `${dims.map((d) => `[${d.lower}:${d.lower + d.length - 1}]`).join("")}=`;
|
|
2671
|
+
return bounds + body;
|
|
2672
|
+
}
|
|
2673
|
+
const fatalUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
2674
|
+
/**
|
|
2675
|
+
* An element's text, as textrecv reads it (pg_verify_mbstr): bytes that are no UTF-8, or a NUL, are refused 22021 naming
|
|
2676
|
+
* the first bad character's bytes (report_invalid_encoding: as many as its lead byte says, as many as are left), never
|
|
2677
|
+
* decoded with a replacement character.
|
|
2678
|
+
*/
|
|
2679
|
+
function utf8Element(bytes) {
|
|
2680
|
+
if (!bytes.includes(0)) {
|
|
2681
|
+
// a fatal TextDecoder throws exactly TypeError on bytes that are no UTF-8: the scan below names them
|
|
2682
|
+
try {
|
|
2683
|
+
return fatalUtf8.decode(bytes);
|
|
2684
|
+
}
|
|
2685
|
+
catch (err) {
|
|
2686
|
+
if (!(err instanceof TypeError))
|
|
2687
|
+
throw err;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
// find the first character Postgres refuses: a NUL, or a character no UTF-8 decoder reads alone
|
|
2691
|
+
for (let at = 0; at < bytes.length;) {
|
|
2692
|
+
const b = bytes[at];
|
|
2693
|
+
const length = b < 0x80 ? 1 : (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 1;
|
|
2694
|
+
const char = bytes.subarray(at, at + length);
|
|
2695
|
+
let legal = b !== 0 && char.length === length;
|
|
2696
|
+
if (legal && b >= 0x80) {
|
|
2697
|
+
try {
|
|
2698
|
+
fatalUtf8.decode(char);
|
|
2699
|
+
}
|
|
2700
|
+
catch (err) {
|
|
2701
|
+
if (!(err instanceof TypeError))
|
|
2702
|
+
throw err;
|
|
2703
|
+
legal = false;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
if (!legal) {
|
|
2707
|
+
const shown = Array.from(char, (c) => `0x${c.toString(16).padStart(2, "0")}`).join(" ");
|
|
2708
|
+
throw new PgError({ code: "22021", message: `invalid byte sequence for encoding "UTF8": ${shown}` });
|
|
2709
|
+
}
|
|
2710
|
+
at += length;
|
|
2711
|
+
}
|
|
2712
|
+
throw new PgError({ code: "XX000", message: "a text[] element failed to decode as UTF-8 at no character: report it" });
|
|
2713
|
+
}
|
|
2309
2714
|
/** A binary-format parameter, as text Postgres's input function reads (DuckOptions.parameters). */
|
|
2310
2715
|
function decodeBinaryParam(type, bytes, i) {
|
|
2311
2716
|
const need = (n) => {
|
|
@@ -2395,7 +2800,7 @@ class Server {
|
|
|
2395
2800
|
if (c.typeOid === undefined || c.spelled === undefined)
|
|
2396
2801
|
continue;
|
|
2397
2802
|
// the type's name as pg_type holds it (`other.level` is `level`, `"My Mood"` is `My Mood`)
|
|
2398
|
-
const name = typeNameParts(c.spelled
|
|
2803
|
+
const name = typeNameParts(madeTypeName(c.spelled)).name;
|
|
2399
2804
|
// the element type and its array type, as pg_type names them (`citext`, `_citext`); an array column's own OID is
|
|
2400
2805
|
// its array type's (pg_attribute.atttypid), and it has none of its own (an array type's typarray is 0)
|
|
2401
2806
|
if (c.spelled.endsWith("[]"))
|
|
@@ -2417,7 +2822,7 @@ class Server {
|
|
|
2417
2822
|
* analysis starts the instance with the new DDL. A plan whose schema or generation moved while it was made is
|
|
2418
2823
|
* returned to its caller and not kept.
|
|
2419
2824
|
*/
|
|
2420
|
-
async duckPlan(text, paramOids,
|
|
2825
|
+
async duckPlan(text, paramOids, under, wireBound, role, make) {
|
|
2421
2826
|
const schema = this.schema();
|
|
2422
2827
|
const roles = this.rolesNow();
|
|
2423
2828
|
const generation = this.options.duck?.generation?.() ?? 0;
|
|
@@ -2430,7 +2835,8 @@ class Server {
|
|
|
2430
2835
|
// the zone too: a plan holds the values Postgres parsed its literals to, under the session's zone
|
|
2431
2836
|
// and the bound: an Execute's row count plans a LEFT lateral of a list otherwise (Session.boundOnWire), kept beside
|
|
2432
2837
|
// 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
|
-
|
|
2838
|
+
// and the DateStyle: Postgres reads a literal date in its order (`'01/02/2026'::date`), at analysis
|
|
2839
|
+
const key = `${under.searchPath}\u0000${under.timeZone}\u0000${under.dateStyle}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${role}\u0000${text}`;
|
|
2434
2840
|
const hit = this.duckPlans.get(key);
|
|
2435
2841
|
if (hit !== undefined)
|
|
2436
2842
|
return hit;
|
|
@@ -2452,7 +2858,8 @@ class Server {
|
|
|
2452
2858
|
constructor(options) {
|
|
2453
2859
|
this.options = options;
|
|
2454
2860
|
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
|
|
2861
|
+
recheck: options.recheck, recheckMs: options.recheckMs ?? DEFAULT_RECHECK_MS, who: "a mason user", loginHint: USER_LOGIN_HINT, public: false, logins: null,
|
|
2862
|
+
proxyFrom: options.proxyFrom ?? null };
|
|
2456
2863
|
this.lastSchema = options.schema();
|
|
2457
2864
|
this.lastRoles = options.roles?.() ?? NO_ROLES;
|
|
2458
2865
|
const version = versionText(options.product ?? "Mason", options.masonVersion);
|
|
@@ -2464,7 +2871,10 @@ class Server {
|
|
|
2464
2871
|
`CREATE OR REPLACE FUNCTION public.version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '${version.replaceAll("'", "''")}' $$`,
|
|
2465
2872
|
// `current_setting(name)` answers the session's settings, carried in by each of its requests (catalog.ts asSession)
|
|
2466
2873
|
...currentSettingSql(),
|
|
2467
|
-
|
|
2874
|
+
// latest(x ORDER BY …), Mason's aggregate, analysed as Postgres's own (catalog.ts LATEST_SQL)
|
|
2875
|
+
...LATEST_SQL,
|
|
2876
|
+
// the catalog's base state, which every request of a session leaves it in (catalog.ts CATALOG_BASE)
|
|
2877
|
+
CATALOG_BASE_SQL,
|
|
2468
2878
|
...databaseNameSql(options.database),
|
|
2469
2879
|
],
|
|
2470
2880
|
...(options.catalogStatementMs !== undefined ? { statementMs: options.catalogStatementMs } : {}),
|
|
@@ -2489,8 +2899,8 @@ class Server {
|
|
|
2489
2899
|
this.baseSettings.catch(() => { this.baseSettings = null; });
|
|
2490
2900
|
return this.baseSettings;
|
|
2491
2901
|
}
|
|
2492
|
-
/** the clock "now" is read from */
|
|
2493
|
-
now() { return (this.options.clock ??
|
|
2902
|
+
/** the clock "now" is read from: microseconds since the epoch (src/lib/instant.ts) */
|
|
2903
|
+
now() { return (this.options.clock ?? nowMicros)(); }
|
|
2494
2904
|
log(line) { (this.options.log ?? ((l) => console.log(l)))(`[postgres] ${line}`); }
|
|
2495
2905
|
/** the schema as of now; the catalog Postgres is replaced when it changed (or the roles did: `rolesNow`) */
|
|
2496
2906
|
schema() {
|
|
@@ -2503,6 +2913,17 @@ class Server {
|
|
|
2503
2913
|
}
|
|
2504
2914
|
return s;
|
|
2505
2915
|
}
|
|
2916
|
+
/** src/duck/generated-plan.ts `Planner`: null `grants` so the plan names what it reads, under no role */
|
|
2917
|
+
planner() {
|
|
2918
|
+
const duck = this.options.duck;
|
|
2919
|
+
if (duck === undefined)
|
|
2920
|
+
throw new Error("a generated table needs the listener's engine (Mason bug: report it)");
|
|
2921
|
+
const catalog = (b) => this.catalog.run(b);
|
|
2922
|
+
return {
|
|
2923
|
+
plan: (text, stmt, relations) => { this.schema(); return duck.plan(catalog, text, stmt, 0, [], GENERATED_SESSION, this.userTypes(), false, null, relations, true); },
|
|
2924
|
+
rows: (sql) => { this.schema(); return catalogRows(catalog, sql); },
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2506
2927
|
/** the roles as of now (src/duck/grants.ts RolesLive); the catalog is replaced when they changed, as for the schema */
|
|
2507
2928
|
rolesNow() {
|
|
2508
2929
|
this.schema();
|
|
@@ -2552,7 +2973,7 @@ class Server {
|
|
|
2552
2973
|
if (stmt === undefined)
|
|
2553
2974
|
throw new Error(`a filter's read does not parse: ${probe}`);
|
|
2554
2975
|
try {
|
|
2555
|
-
await this.options.duck.plan((b) => this.catalog.run(b), probe, stmt, 0, [],
|
|
2976
|
+
await this.options.duck.plan((b) => this.catalog.run(b), probe, stmt, 0, [], GENERATED_SESSION, this.userTypes(), false, undefined);
|
|
2556
2977
|
}
|
|
2557
2978
|
catch (e) {
|
|
2558
2979
|
if (!(e instanceof StatementError))
|
|
@@ -2697,7 +3118,9 @@ export async function startPostgresApi(options) {
|
|
|
2697
3118
|
port,
|
|
2698
3119
|
async openShare(o) {
|
|
2699
3120
|
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
|
|
3121
|
+
loginHint: o.loginHint, public: true, logins: new LoginQueue(o.pendingLimit ?? DEFAULT_PENDING_LIMIT, o.pendingMs ?? DEFAULT_PENDING_MS, o.dropped), refused: o.refused,
|
|
3122
|
+
// a tunnel's client comes from loopback: no header is believed on a share's socket
|
|
3123
|
+
proxyFrom: null };
|
|
2701
3124
|
// loopback whatever the run's own listener is bound to: a tunnel's client is on this machine
|
|
2702
3125
|
const share = await listenOn(server, door, 0, "127.0.0.1");
|
|
2703
3126
|
shares.add(share.listener);
|
|
@@ -2722,6 +3145,7 @@ export async function startPostgresApi(options) {
|
|
|
2722
3145
|
get sessions() { return server.sessions.size; },
|
|
2723
3146
|
roles: () => server.rolesChecked(),
|
|
2724
3147
|
checkRoles: () => server.checkRoles(),
|
|
3148
|
+
planner: () => server.planner(),
|
|
2725
3149
|
openSessions: () => server.openSessions(),
|
|
2726
3150
|
running: () => server.running(),
|
|
2727
3151
|
cancel: (session, statement) => server.cancelSession(session, statement),
|