@mason-data/cli 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -2
  3. package/dist/src/api/error.js +18 -0
  4. package/dist/src/api/postgres/catalog-worker.js +92 -0
  5. package/dist/src/api/postgres/catalog.js +477 -0
  6. package/dist/src/api/postgres/encode.js +541 -0
  7. package/dist/src/api/postgres/explain.js +143 -0
  8. package/dist/src/api/postgres/listener.js +60 -0
  9. package/dist/src/api/postgres/parser-worker.js +58 -0
  10. package/dist/src/api/postgres/parser.js +121 -0
  11. package/dist/src/api/postgres/plain.js +33 -0
  12. package/dist/src/api/postgres/protocol.js +404 -0
  13. package/dist/src/api/postgres/schema.js +54 -0
  14. package/dist/src/api/postgres/server.js +1771 -0
  15. package/dist/src/api/postgres/translate.js +248 -0
  16. package/dist/src/api/request-log.js +50 -0
  17. package/dist/src/cdc/pg-wire.js +588 -0
  18. package/dist/src/cdc/pgoutput.js +107 -0
  19. package/dist/src/cdc/poll.js +67 -0
  20. package/dist/src/cdc/source.js +65 -0
  21. package/dist/src/cli/background.js +198 -0
  22. package/dist/src/cli/command.js +173 -0
  23. package/dist/src/cli/commands.js +1277 -0
  24. package/dist/src/cli/control.js +36 -0
  25. package/dist/src/cli/demo.js +149 -0
  26. package/dist/src/cli/dev.js +669 -0
  27. package/dist/src/cli/doctor.js +260 -0
  28. package/dist/src/cli/guides.js +158 -0
  29. package/dist/src/cli/init.js +366 -0
  30. package/dist/src/cli/machine.js +162 -0
  31. package/dist/src/cli/main.js +83 -0
  32. package/dist/src/cli/mcp.js +726 -0
  33. package/dist/src/cli/pg-tls.js +100 -0
  34. package/dist/src/cli/pg-url.js +52 -0
  35. package/dist/src/cli/pin.js +36 -0
  36. package/dist/src/cli/ports.js +118 -0
  37. package/dist/src/cli/preflight.js +347 -0
  38. package/dist/src/cli/project.js +129 -0
  39. package/dist/src/cli/readiness.js +76 -0
  40. package/dist/src/cli/registry.js +220 -0
  41. package/dist/src/cli/replication-checks.js +120 -0
  42. package/dist/src/cli/replication-mode.js +148 -0
  43. package/dist/src/cli/run-server.js +311 -0
  44. package/dist/src/cli/runs.js +120 -0
  45. package/dist/src/cli/security.js +86 -0
  46. package/dist/src/cli/self-signed-cert.js +64 -0
  47. package/dist/src/cli/serve.js +301 -0
  48. package/dist/src/cli/source-edit.js +116 -0
  49. package/dist/src/cli/sources.js +564 -0
  50. package/dist/src/cli/state.js +58 -0
  51. package/dist/src/cli/style.js +187 -0
  52. package/dist/src/cli/supervise.js +248 -0
  53. package/dist/src/cli/sync.js +94 -0
  54. package/dist/src/cli/ui.js +107 -0
  55. package/dist/src/cli/user.js +229 -0
  56. package/dist/src/cli/version.js +23 -0
  57. package/dist/src/demo/seed.js +237 -0
  58. package/dist/src/demo/source.js +655 -0
  59. package/dist/src/duck/analyze.js +113 -0
  60. package/dist/src/duck/cancel.js +49 -0
  61. package/dist/src/duck/distinct-key.js +228 -0
  62. package/dist/src/duck/executor-worker.js +268 -0
  63. package/dist/src/duck/executor.js +567 -0
  64. package/dist/src/duck/explain.js +238 -0
  65. package/dist/src/duck/functions.js +819 -0
  66. package/dist/src/duck/gaps.js +94 -0
  67. package/dist/src/duck/jsonb-order.js +175 -0
  68. package/dist/src/duck/jsonb.js +188 -0
  69. package/dist/src/duck/kept-route.js +299 -0
  70. package/dist/src/duck/kept.js +964 -0
  71. package/dist/src/duck/late.js +221 -0
  72. package/dist/src/duck/libc-order.js +190 -0
  73. package/dist/src/duck/pg-eval-worker.js +34 -0
  74. package/dist/src/duck/pg-eval.js +59 -0
  75. package/dist/src/duck/pgtree.js +378 -0
  76. package/dist/src/duck/plan.js +259 -0
  77. package/dist/src/duck/publication.js +207 -0
  78. package/dist/src/duck/requests.js +209 -0
  79. package/dist/src/duck/resolved.js +248 -0
  80. package/dist/src/duck/rewrite.js +5101 -0
  81. package/dist/src/duck/store.js +907 -0
  82. package/dist/src/duck/stream.js +518 -0
  83. package/dist/src/duck/sync.js +2929 -0
  84. package/dist/src/duck/to-char.js +482 -0
  85. package/dist/src/duck/token.js +31 -0
  86. package/dist/src/duck/types.js +427 -0
  87. package/dist/src/duck/users.js +231 -0
  88. package/dist/src/duck/verified.js +1001 -0
  89. package/dist/src/duck/views.js +150 -0
  90. package/dist/src/lib/database-url.js +74 -0
  91. package/dist/src/lib/did-you-mean.js +43 -0
  92. package/dist/src/lib/dotenv.js +72 -0
  93. package/dist/src/lib/exit-when-written.js +20 -0
  94. package/dist/src/lib/float-equal.js +69 -0
  95. package/dist/src/lib/http-json.js +22 -0
  96. package/dist/src/lib/log.js +161 -0
  97. package/dist/src/lib/pg-numeric.js +489 -0
  98. package/dist/src/lib/port.js +22 -0
  99. package/dist/src/lib/project-file.js +49 -0
  100. package/dist/src/lib/project-files.js +43 -0
  101. package/dist/src/lib/project-version.js +76 -0
  102. package/dist/src/lib/runnable.js +14 -0
  103. package/dist/src/lib/shell-word.js +4 -0
  104. package/dist/src/lib/timezone.js +237 -0
  105. package/dist/src/lib/zone-names.js +114 -0
  106. package/dist/src/web/app.css +76 -0
  107. package/dist/src/web/app.js +491 -0
  108. package/dist/src/web/index.html +44 -0
  109. package/package.json +22 -4
@@ -0,0 +1,1771 @@
1
+ import { copyFacts } from "../../duck/explain.js";
2
+ import { explainedText, explainOptions } from "./explain.js";
3
+ import * as crypto from "node:crypto";
4
+ import * as net from "node:net";
5
+ import * as tls from "node:tls";
6
+ import { ApiError } from "../error.js";
7
+ import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
8
+ import { canonicalZone } from "../../lib/timezone.js";
9
+ import { databaseNameSql, PgCatalog } from "./catalog.js";
10
+ import { EncodeError, OID, fieldOf } from "./encode.js";
11
+ import { numericFromBinary, NumericError } from "../../lib/pg-numeric.js";
12
+ import { parseSql, PostgresSyntaxError, stopParser } from "./parser.js";
13
+ import { FrontendDecoder, ProtocolError, authenticationCleartextPassword, authenticationOk, backendKeyData, bindComplete, closeComplete, commandComplete, dataRow, emptyQueryResponse, errorResponse, noData, noticeResponse, parameterDescription, parameterStatus, parseComplete, portalSuspended, readyForQuery, rowDescription, sslResponse, cstr, int16, int32, frame, decodeNoticeFrame, splitFrames, MAX_MESSAGE_LENGTH, } from "./protocol.js";
14
+ import { typeNameParts } from "./schema.js";
15
+ import { Unsupported, duckTranslate } from "./translate.js";
16
+ /** what an EXPLAIN of a catalog statement says first: whose plan it is */
17
+ const CATALOG_EXPLAINED = "Mason: answered by the catalog Postgres (Mason's own, PGlite), not DuckDB: a statement of pg_catalog or information_schema, or of no table, is the catalog's, and this is its plan";
18
+ /** an EXPLAIN's one column, Postgres's: text, or json for FORMAT JSON (ExplainResultDesc) */
19
+ const explainField = (o, format) => ({ name: "QUERY PLAN", typeOid: o.format === "json" ? 114 : 25, typeSize: -1, typeModifier: -1, format });
20
+ /** a DataRow's values (null for NULL) */
21
+ function dataRowValues(frame) {
22
+ const n = frame.readInt16BE(5);
23
+ const out = [];
24
+ let at = 7;
25
+ for (let i = 0; i < n; i++) {
26
+ const len = frame.readInt32BE(at);
27
+ at += 4;
28
+ out.push(len < 0 ? null : frame.subarray(at, at + len));
29
+ if (len > 0)
30
+ at += len;
31
+ }
32
+ return out;
33
+ }
34
+ /** the catalog Postgres's EXPLAIN with the line that says whose it is: a first row of the text, a key of the JSON */
35
+ function catalogExplained(frames, o) {
36
+ const first = frames.findIndex((f) => f[0] === 0x44 /* D */);
37
+ if (first < 0)
38
+ return frames;
39
+ if (o.format === "text")
40
+ return [...frames.slice(0, first), dataRow([Buffer.from(CATALOG_EXPLAINED)]), ...frames.slice(first)];
41
+ const value = dataRowValues(frames[first])[0];
42
+ if (value === null || value === undefined)
43
+ return frames;
44
+ const doc = JSON.parse(value.toString("utf8"));
45
+ if (doc[0] !== undefined)
46
+ doc[0].Mason = { "Answered By": "the catalog Postgres (Mason's own, PGlite), not DuckDB", Why: "a statement of pg_catalog or information_schema, or of no table, is the catalog's" };
47
+ return [...frames.slice(0, first), dataRow([Buffer.from(JSON.stringify(doc, null, 2))]), ...frames.slice(first + 1)];
48
+ }
49
+ /** Postgres's names over DuckDB's: each named column renamed, a `*` (null) spanning what DuckDB expanded it to */
50
+ export function postgresNames(columns, names) {
51
+ if (names === null || names === undefined)
52
+ return [...columns];
53
+ const star = names.indexOf(null);
54
+ const width = columns.length - (names.length - (star >= 0 ? 1 : 0));
55
+ if (width < 0 || (star < 0 && names.length !== columns.length))
56
+ return [...columns];
57
+ const out = [];
58
+ let at = 0;
59
+ for (const n of names) {
60
+ if (n === null) {
61
+ for (let i = 0; i < width; i++)
62
+ out.push(columns[at++]);
63
+ continue;
64
+ }
65
+ out.push({ ...columns[at++], name: n });
66
+ }
67
+ return at === columns.length ? out : [...columns];
68
+ }
69
+ /** a statement_timeout's value in ms, Postgres's units (a bare number is ms; `1.5s`, `5min`), 0 for none; null where
70
+ * it is no duration */
71
+ export function timeoutMs(value) {
72
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(us|ms|s|min|h|d)?\s*$/i.exec(value);
73
+ if (m === null)
74
+ return null;
75
+ const per = { us: 0.001, ms: 1, s: 1000, min: 60_000, h: 3_600_000, d: 86_400_000 }[(m[2] ?? "ms").toLowerCase()];
76
+ return Math.round(Number(m[1]) * per);
77
+ }
78
+ /** A statement's error as Postgres raises it, from outside this module (the DuckDB runner's): the fields, and the SQL
79
+ * its position is in. */
80
+ export class StatementError extends Error {
81
+ fields;
82
+ sql;
83
+ constructor(fields, sql = "") { super(fields.message); this.fields = fields; this.sql = sql; }
84
+ }
85
+ export const SERVER_VERSION = "18.6";
86
+ export const SERVER_VERSION_NUM = "180006";
87
+ /** `version()`'s whole text: `PostgreSQL 18.6` FIRST, since tools parse it (as Redshift and YugabyteDB keep it), then
88
+ * whose it is, then the machine in Postgres's own words (`aarch64`, `x86_64`, never Node's `arm64` / `x64`) */
89
+ export function versionText(product, version) {
90
+ const arch = { arm64: "aarch64", x64: "x86_64" }[process.arch] ?? process.arch;
91
+ return `PostgreSQL ${SERVER_VERSION} (${product} ${version}) on ${arch}-${process.platform}`;
92
+ }
93
+ /**
94
+ * How often a user's session asks again whether its user is still live (`PgApiOptions.recheck`) when `recheckMs` is
95
+ * absent: a removed user's NEW connections are refused at once, and a session it already holds — a BI tool's pool
96
+ * keeps one for hours — ends at its first statement after this long.
97
+ */
98
+ export const DEFAULT_RECHECK_MS = 30_000;
99
+ // ---------------------------------------------------------------------------------------------------------------------
100
+ // bytes
101
+ /** the type byte of a RowDescription frame */
102
+ const ROW_DESCRIPTION = 0x54;
103
+ /** the first byte of a TLS handshake record (a ClientHello): never a startup packet's, whose length's high byte is 0
104
+ * under MAX_STARTUP_LENGTH, and never SSLRequest's (length 8) */
105
+ const TLS_HANDSHAKE = 0x16;
106
+ /** 1-based CHARACTER position of a byte offset into `sql`, as Postgres counts it. */
107
+ const positionOf = (sql, byteOffset) => byteOffset === null ? undefined : Buffer.from(sql, "utf8").subarray(0, byteOffset).toString("utf8").length + 1;
108
+ // ---------------------------------------------------------------------------------------------------------------------
109
+ // errors
110
+ /** A statement's failure as Postgres reports one. */
111
+ class PgError extends Error {
112
+ fields;
113
+ /** the HTTP-like status the request log records (400 for a refusal, 401 a refused login, 500 Mason's failure) */
114
+ status;
115
+ constructor(fields, status = 400) {
116
+ super(fields.message);
117
+ this.fields = { severity: "ERROR", ...fields };
118
+ this.status = status;
119
+ }
120
+ }
121
+ /** The SQLSTATE an ApiError's status is told as (a refused login's, src/duck/users.ts). */
122
+ function sqlstateOf(status, code) {
123
+ switch (status) {
124
+ case 401: return "28000";
125
+ case 403: return "42501";
126
+ case 404: return "42P01";
127
+ case 409: return "55000";
128
+ case 413: return "54000";
129
+ case 429:
130
+ case 503: return "57P03";
131
+ case 504: return "57014";
132
+ case 400: return code === "unknown_model" ? "42P01" : code === "invalid_value" ? "22P02" : "0A000";
133
+ default: return "XX000";
134
+ }
135
+ }
136
+ /**
137
+ * A refused or failed statement as its log line (`Session.refuse`): `failed` where the fault is Mason's or the
138
+ * machine's (SQLSTATE classes XX internal, 53 resources, 57 a timeout or cancel, 58 system), `refused` for the
139
+ * statement's own; the detail and the hint where there are, and the statement on one line, as the answered lines carry it.
140
+ */
141
+ export function refusalLine(session, f, sql) {
142
+ const word = outcomeOf(f.code) === "failed" ? "failed" : "refused";
143
+ const statement = sql.replace(/\s+/g, " ").trim();
144
+ return `session ${session} ${word}: ${f.code} ${f.message}${f.detail ? `; detail: ${f.detail}` : ""}${f.hint ? `; hint: ${f.hint}` : ""}` +
145
+ ` — ${statement === "" ? "(no statement)" : statement}`;
146
+ }
147
+ function asPgError(err, sql) {
148
+ if (err instanceof PgError)
149
+ return err;
150
+ if (err instanceof StatementError)
151
+ return new PgError({ ...err.fields, position: err.fields.position ?? undefined }, err.fields.code.startsWith("XX") ? 500 : 400);
152
+ if (err instanceof Unsupported) {
153
+ return new PgError({ code: err.sqlstate, message: err.message, position: positionOf(sql, err.offset), hint: err.hint, detail: "Mason answers the Postgres subset in docs/POSTGRES-API.md" });
154
+ }
155
+ if (err instanceof PostgresSyntaxError)
156
+ return new PgError({ code: "42601", message: err.message, position: positionOf(sql, err.offset) });
157
+ if (err instanceof ApiError)
158
+ return new PgError({ code: sqlstateOf(err.status, err.code), message: err.message, detail: `Mason: ${err.code}` }, err.status);
159
+ // Postgres's own error for a numeric (a division by zero, 22012; a field overflow, 22003), raised as Postgres raises it
160
+ if (err instanceof NumericError)
161
+ return new PgError({ code: err.sqlstate, message: err.message }, err.sqlstate === "XX000" ? 500 : 400);
162
+ if (err instanceof EncodeError)
163
+ return err.code === "XX000"
164
+ ? new PgError({ code: "XX000", message: err.message, detail: "Mason: a value the column's Postgres type cannot carry; report it" }, 500)
165
+ : new PgError({ code: err.code, message: err.message }, 400);
166
+ if (err instanceof ProtocolError)
167
+ return new PgError({ code: "08P01", message: err.message }, 500);
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ return new PgError({ code: "XX000", message }, 500);
170
+ }
171
+ // ---------------------------------------------------------------------------------------------------------------------
172
+ // the session's parameters
173
+ /** The parameters Postgres reports to the client (GUC_REPORT): a change is told as a ParameterStatus. */
174
+ const REPORTED = new Set(["application_name", "client_encoding", "datestyle", "intervalstyle", "timezone", "search_path", "standard_conforming_strings", "default_transaction_read_only", "in_hot_standby", "is_superuser", "session_authorization", "server_version", "server_encoding", "integer_datetimes"]);
175
+ /** Postgres's spelling of the parameters it reports, for the ParameterStatus name. */
176
+ const SPELLING = { datestyle: "DateStyle", intervalstyle: "IntervalStyle", timezone: "TimeZone" };
177
+ const spell = (name) => SPELLING[name] ?? name;
178
+ const defaultVars = () => ({ timeZone: "UTC", applicationName: "", searchPath: '"$user", public', other: new Map() });
179
+ /** What `SHOW` / `current_setting` answer for the parameters this server fixes. */
180
+ function fixedParameter(name, vars, user) {
181
+ switch (name) {
182
+ case "server_version": return SERVER_VERSION;
183
+ case "server_version_num": return SERVER_VERSION_NUM;
184
+ case "server_encoding":
185
+ case "client_encoding": return "UTF8";
186
+ case "datestyle": return "ISO, MDY";
187
+ case "intervalstyle": return "postgres";
188
+ case "timezone": return vars.timeZone;
189
+ case "application_name": return vars.applicationName;
190
+ case "search_path": return vars.searchPath;
191
+ case "integer_datetimes":
192
+ case "standard_conforming_strings":
193
+ case "default_transaction_read_only":
194
+ case "transaction_read_only":
195
+ case "in_hot_standby": return "on";
196
+ case "is_superuser": return "off";
197
+ case "session_authorization":
198
+ case "role": return user;
199
+ case "transaction_isolation":
200
+ case "default_transaction_isolation": return "read committed";
201
+ case "extra_float_digits": return "1";
202
+ case "max_identifier_length": return "63";
203
+ default: return null;
204
+ }
205
+ }
206
+ /**
207
+ * WHAT `SHOW` / `current_setting` SAY ABOUT TLS (`SHOW ssl` once said `off` on a TLS session, the catalog
208
+ * Postgres's answer — PGlite is built without TLS and serves none). The session's own: `on` with the parameters a server
209
+ * with ssl on answers where this listener knows better (its library, protocol versions, ciphers and groups: Node's, which
210
+ * the secure context is made with), else the catalog's — the certificate's and key's file names mean nothing to a client
211
+ * and the operator's paths are not a token holder's. Postgres's `ssl` is the SERVER's setting, and here it is each
212
+ * session's too: a TLS listener refuses a plain startup and a plain one serves no TLS, so the two never differ.
213
+ */
214
+ export function tlsParameters(on) {
215
+ if (!on)
216
+ return { ssl: "off" };
217
+ return {
218
+ ssl: "on", ssl_library: "OpenSSL", ssl_min_protocol_version: tls.DEFAULT_MIN_VERSION, ssl_max_protocol_version: tls.DEFAULT_MAX_VERSION,
219
+ ssl_ciphers: tls.DEFAULT_CIPHERS, ssl_groups: tls.DEFAULT_ECDH_CURVE, ssl_prefer_server_ciphers: "off",
220
+ };
221
+ }
222
+ /** The catalog's `current_setting(name[, missing_ok])`, answering the TLS parameters first (`tlsParameters`) and the rest
223
+ * as Postgres's own: the `version()` override's way (search_path lists pg_catalog after public), so a statement reading
224
+ * no table and calling it is forwarded as ever, and one table answers SHOW and current_setting alike. */
225
+ export function currentSettingSql(params) {
226
+ const cases = Object.entries(params).map(([name, value]) => `WHEN ${quoteLiteral(name)} THEN ${quoteLiteral(value)}`).join(" ");
227
+ return [
228
+ `CREATE OR REPLACE FUNCTION public.current_setting(text) RETURNS text LANGUAGE sql STABLE AS $$ SELECT CASE lower($1) ${cases} ELSE pg_catalog.current_setting($1) END $$`,
229
+ `CREATE OR REPLACE FUNCTION public.current_setting(text, boolean) RETURNS text LANGUAGE sql STABLE AS $$ SELECT CASE lower($1) ${cases} ELSE pg_catalog.current_setting($1, $2) END $$`,
230
+ ];
231
+ }
232
+ // ---------------------------------------------------------------------------------------------------------------------
233
+ // one connection
234
+ let nextSessionId = 1;
235
+ class Session {
236
+ id = nextSessionId++;
237
+ secret = crypto.randomInt(1, 2 ** 31);
238
+ socket;
239
+ decoder = new FrontendDecoder();
240
+ phase = "startup";
241
+ user = "";
242
+ token = null;
243
+ presented = null;
244
+ /** when the user was last found live (a user's session is asked again, `recheckMs`) */
245
+ verifiedAt = 0;
246
+ vars = defaultVars();
247
+ tx = "none";
248
+ /** the instant the transaction began (Postgres's `now()`), null between transactions */
249
+ txNow = null;
250
+ /** the instant of the statement being run (`statement_timestamp()`): its Query's arrival or its Bind */
251
+ statementNow = 0;
252
+ statements = new Map();
253
+ portals = new Map();
254
+ /** after an error in the extended protocol: every message until Sync is dropped */
255
+ skipToSync = false;
256
+ /** the query running now, for a CancelRequest and the socket closing */
257
+ running = null;
258
+ /** this session's DuckDB connection, opened at its first statement over a table */
259
+ duckRunner = null;
260
+ /** Closes the session's DuckDB connection, when it has one: only ever with no statement running on it (`end`, `settled`). */
261
+ closeRunner() {
262
+ if (this.duckRunner === null)
263
+ return;
264
+ const runner = this.duckRunner;
265
+ this.duckRunner = null;
266
+ runner.close().catch((e) => { this.server.log(`session ${this.id}: closing its DuckDB connection failed: ${e instanceof Error ? e.message : String(e)}`); });
267
+ }
268
+ /** A statement ended, however: the session's next one may run, and a session closed meanwhile closes its connection now. */
269
+ settled() {
270
+ this.running = null;
271
+ if (this.phase === "closed")
272
+ this.closeRunner();
273
+ }
274
+ /** THE STATEMENT BEING ASKED, for the request log (`recordRequest`): set as each message arrives, its
275
+ * text narrowed to one statement of a simple Query as each is run */
276
+ asked = null;
277
+ /** messages are handled one at a time, in order: a parse is asynchronous */
278
+ draining = false;
279
+ /** the connection's first bytes have been read, and told a TLS ClientHello from a startup packet */
280
+ sniffed = false;
281
+ closedBy = null;
282
+ server;
283
+ constructor(socket, server) {
284
+ this.server = server;
285
+ this.socket = socket;
286
+ this.attach(socket);
287
+ }
288
+ attach(socket) {
289
+ socket.on("data", (chunk) => {
290
+ // the connection's first byte says how it starts: a TLS ClientHello is TLS at once (`directTls`), anything else
291
+ // the protocol's startup phase, SSLRequest included
292
+ if (!this.sniffed) {
293
+ this.sniffed = true;
294
+ if (chunk[0] === TLS_HANDSHAKE) {
295
+ this.directTls(chunk);
296
+ return;
297
+ }
298
+ }
299
+ this.decoder.push(chunk);
300
+ void this.drain();
301
+ });
302
+ socket.on("error", (err) => { this.server.log(`session ${this.id}: socket error: ${err.message}`); this.end("socket error"); });
303
+ socket.on("close", () => this.end("closed by the client"));
304
+ }
305
+ send(...parts) { this.sendAll(parts); }
306
+ /** the frames as one write; an answer's rows are many (150,000 spread into a call's arguments overflow the stack) */
307
+ sendAll(parts) {
308
+ if (this.phase === "closed")
309
+ return;
310
+ this.socket.write(Buffer.concat(parts));
311
+ }
312
+ end(why) {
313
+ if (this.phase === "closed")
314
+ return;
315
+ const wasReady = this.phase === "ready";
316
+ this.phase = "closed";
317
+ this.closedBy = why;
318
+ if (wasReady)
319
+ this.server.log(`session ${this.id} closed: ${why}`);
320
+ this.running?.abort();
321
+ this.socket.destroy();
322
+ this.server.forget(this);
323
+ this.dropPortals();
324
+ // its DuckDB connection: closed now when no statement runs on it, else by `settled` once the interrupted statement
325
+ // has ended (the test server: a close under a running statement that calls a JavaScript scalar function
326
+ // hung the process for good — the close waits for the executor, and the executor waits for the main thread to run
327
+ // the function; tests/duck-wire.test.ts "a client leaving mid-statement")
328
+ if (this.running === null)
329
+ this.closeRunner();
330
+ // the sidecar's statements of this session, if any: closed so nothing of a gone session stays there
331
+ const held = [...this.statements.values()].filter((s) => s.catalogName !== null).map((s) => s.catalogName);
332
+ if (held.length) {
333
+ void this.server.catalog.run(Buffer.concat([...held.map((n) => frame("C", Buffer.from("S"), cstr(n))), frame("S")])).catch((e) => {
334
+ this.server.log(`session ${this.id}: closing its ${held.length} catalog statement(s) failed: ${e instanceof Error ? e.message : String(e)}`);
335
+ });
336
+ }
337
+ }
338
+ /** the connection's end as the log sees it */
339
+ get closeReason() { return this.closedBy; }
340
+ async drain() {
341
+ if (this.draining)
342
+ return;
343
+ this.draining = true;
344
+ try {
345
+ while (this.phase !== "closed") {
346
+ let m;
347
+ try {
348
+ m = this.decoder.next();
349
+ }
350
+ catch (err) {
351
+ const e = asPgError(err, "");
352
+ this.send(errorResponse({ ...e.fields, severity: "FATAL" }));
353
+ this.end(`protocol error: ${e.message}`);
354
+ return;
355
+ }
356
+ if (m === null)
357
+ return;
358
+ try {
359
+ await this.handle(m);
360
+ }
361
+ catch (err) {
362
+ // nothing below may reject (each handler answers its own errors); one that does would be an unhandled
363
+ // rejection, which exits the API (review of #480) — it ends THIS session instead, FATAL, with the reason
364
+ const e = asPgError(err, "");
365
+ this.server.log(`session ${this.id}: ${e.message}`);
366
+ this.send(errorResponse({ ...e.fields, severity: "FATAL" }));
367
+ this.end(`internal error: ${e.message}`);
368
+ return;
369
+ }
370
+ }
371
+ }
372
+ finally {
373
+ this.draining = false;
374
+ }
375
+ }
376
+ async handle(m) {
377
+ if (this.phase === "startup")
378
+ return this.startup(m);
379
+ if (this.phase === "password")
380
+ return this.password(m);
381
+ if (this.server.options.trace === true && (m.type === "Query" || m.type === "Parse")) {
382
+ this.server.log(`trace ${this.id} ${JSON.stringify(this.vars.applicationName)} ${m.type}: ${JSON.stringify(m.sql)}`);
383
+ }
384
+ if (m.type === "Query" || m.type === "Parse" || m.type === "Bind" || m.type === "Describe" || m.type === "Execute") {
385
+ this.asked = { sql: m.type === "Query" || m.type === "Parse" ? m.sql : this.sqlOf(m), method: m.type.toUpperCase(), startedAt: Date.now() };
386
+ }
387
+ // EVERY statement of a user's session is held to its removal (review of #507: a check inside the answer let a pool's
388
+ // keep-alive, its catalog reads, SET and SHOW go on for ever after a revocation)
389
+ if ((m.type === "Query" || m.type === "Parse" || m.type === "Execute") && !(m.type !== "Query" && this.skipToSync)) {
390
+ const refused = await this.stillLive(m.type);
391
+ if (this.phase === "closed")
392
+ return;
393
+ if (refused !== null) {
394
+ this.refuse(refused, m.type === "Execute" ? this.sqlOf(m) : m.sql);
395
+ if (this.tx === "open")
396
+ this.tx = "failed";
397
+ if (m.type === "Query")
398
+ this.send(readyForQuery(this.txStatus()));
399
+ else
400
+ this.skipToSync = true;
401
+ return;
402
+ }
403
+ }
404
+ switch (m.type) {
405
+ case "Query": return this.simpleQuery(m.sql);
406
+ case "Parse":
407
+ case "Bind":
408
+ case "Describe":
409
+ case "Execute":
410
+ case "Close":
411
+ case "Sync":
412
+ case "Flush": return this.extended(m);
413
+ case "Terminate":
414
+ this.end("terminated by the client");
415
+ return;
416
+ case "Password":
417
+ case "Startup":
418
+ case "SSLRequest":
419
+ case "GSSENCRequest":
420
+ case "CancelRequest":
421
+ this.fatal("08P01", `a ${m.type} message after the session started`);
422
+ return;
423
+ case "Unknown":
424
+ this.fatal("08P01", `unsupported frontend message type '${m.code}' (a FunctionCall or COPY message): this API answers the simple and extended query protocols only`);
425
+ return;
426
+ }
427
+ }
428
+ fatal(code, message, hint) {
429
+ this.send(errorResponse({ severity: "FATAL", code, message, hint }));
430
+ this.end(`fatal: ${message}`);
431
+ }
432
+ // ---------------------------------------------------------------- startup
433
+ startup(m) {
434
+ switch (m.type) {
435
+ case "SSLRequest": {
436
+ if (this.server.tls === null) {
437
+ this.send(sslResponse(false));
438
+ return;
439
+ }
440
+ this.send(sslResponse(true));
441
+ this.startTls(this.server.tls, null);
442
+ return;
443
+ }
444
+ case "GSSENCRequest":
445
+ this.send(sslResponse(false));
446
+ return;
447
+ case "CancelRequest": {
448
+ this.server.cancel(m.pid, m.secret);
449
+ this.end("cancel request");
450
+ return;
451
+ }
452
+ case "Startup": {
453
+ if (m.major !== 3 || m.minor !== 0) {
454
+ this.fatal("0A000", `unsupported frontend protocol ${m.major}.${m.minor}: server supports 3.0`);
455
+ return;
456
+ }
457
+ if (this.server.tls !== null && !(this.socket instanceof tls.TLSSocket)) {
458
+ this.fatal("28000", "this server requires TLS: the password must not travel in clear (connect with sslmode=require)");
459
+ return;
460
+ }
461
+ const user = m.params.user;
462
+ if (user === undefined || user === "") {
463
+ this.fatal("28000", "no PostgreSQL user name specified in startup packet");
464
+ return;
465
+ }
466
+ this.user = user;
467
+ const database = m.params.database ?? user;
468
+ if (database !== this.server.database) {
469
+ this.fatal("3D000", `database "${database}" does not exist`, `this Mason serves the project "${this.server.database}": connect with database=${this.server.database}`);
470
+ return;
471
+ }
472
+ const encoding = m.params.client_encoding;
473
+ if (encoding !== undefined && !isUtf8(encoding)) {
474
+ this.fatal("22023", `client_encoding ${encoding} is not supported: this API sends UTF8 only`);
475
+ return;
476
+ }
477
+ if (m.params.application_name !== undefined)
478
+ this.vars.applicationName = m.params.application_name;
479
+ try {
480
+ if (m.params.options !== undefined)
481
+ this.commandLineOptions(m.params.options);
482
+ for (const [k, v] of Object.entries(m.params)) {
483
+ if (["user", "database", "client_encoding", "application_name", "options", "replication"].includes(k))
484
+ continue;
485
+ if (k === "replication")
486
+ continue;
487
+ this.setParameter(k.toLowerCase(), v, true);
488
+ }
489
+ }
490
+ catch (err) {
491
+ const e = asPgError(err, "");
492
+ this.fatal(e.fields.code, e.message, e.fields.hint);
493
+ return;
494
+ }
495
+ this.phase = "password";
496
+ this.send(authenticationCleartextPassword());
497
+ return;
498
+ }
499
+ default:
500
+ this.fatal("08P01", `expected a startup message, got ${m.type}`);
501
+ }
502
+ }
503
+ /**
504
+ * TLS over this connection's socket, from its next byte: after SSLRequest's `S` (`early` null), or at once for a
505
+ * connection whose first bytes were a ClientHello (`early`: those bytes, handed back to the socket WHOLE — a TLSSocket
506
+ * reads what its socket holds before its handle, so the handshake sees every byte the client sent). ALPN `postgresql`
507
+ * as Postgres 17 offers it: libpq's `sslnegotiation=direct` requires the server to choose it.
508
+ */
509
+ startTls(context, early) {
510
+ const plain = this.socket;
511
+ plain.removeAllListeners("data");
512
+ plain.removeAllListeners("close");
513
+ plain.removeAllListeners("error");
514
+ if (early !== null) {
515
+ plain.pause();
516
+ plain.unshift(early);
517
+ }
518
+ const secure = new tls.TLSSocket(plain, { isServer: true, secureContext: context, ALPNProtocols: ["postgresql"] });
519
+ this.socket = secure;
520
+ this.decoder.expectStartup();
521
+ this.attach(secure);
522
+ }
523
+ /**
524
+ * A DIRECT TLS START: a client that opens with a TLS ClientHello instead of SSLRequest — Postgres 17's
525
+ * `sslnegotiation=direct`, or a platform's generic TLS terminator re-encrypting to the listener — is TLS at once, and
526
+ * its startup packet comes inside. A plain listener cannot answer one on the wire (the client waits for a TLS record,
527
+ * and this listener has no certificate to send), so it is refused in the log, naming the way out, and closed.
528
+ */
529
+ directTls(first) {
530
+ if (this.server.tls === null) {
531
+ this.server.log(`session ${this.id} refused: a direct TLS start (a TLS ClientHello as its first bytes: sslnegotiation=direct, or a TLS ` +
532
+ `terminator re-encrypting to Mason) on a plain listener — set MASON_PG_TLS_CERT and MASON_PG_TLS_KEY to serve TLS, or terminate TLS ` +
533
+ `in front and send Mason the plain protocol (docs/POSTGRES-API.md "Connecting")`);
534
+ this.end("a direct TLS start on a plain listener");
535
+ return;
536
+ }
537
+ this.startTls(this.server.tls, first);
538
+ }
539
+ /** libpq's `options=-c name=value …` (JDBC's `options`): each a SET before the session starts */
540
+ commandLineOptions(options) {
541
+ const re = /(?:--|-c\s+)([A-Za-z_][A-Za-z0-9_.]*)=(\S+)/g;
542
+ let m;
543
+ let any = false;
544
+ while ((m = re.exec(options)) !== null) {
545
+ any = true;
546
+ this.setParameter(m[1].toLowerCase(), m[2].replaceAll("\\ ", " "), true);
547
+ }
548
+ if (!any && options.trim() !== "")
549
+ throw new PgError({ code: "0A000", message: `startup options ${JSON.stringify(options)}: only -c name=value is supported` });
550
+ }
551
+ async password(m) {
552
+ if (m.type !== "Password") {
553
+ this.fatal("08P01", `expected a password message, got ${m.type}`);
554
+ return;
555
+ }
556
+ let token;
557
+ try {
558
+ token = await this.server.options.authenticate(m.password, this.user);
559
+ }
560
+ catch (err) {
561
+ const message = err instanceof Error ? err.message : String(err);
562
+ // a revocation record that could not be read is no wrong password: the server could not tell, said as such
563
+ const unknown = err instanceof ApiError && err.status === 503;
564
+ const refused = new PgError({ code: unknown ? "57P03" : "28P01",
565
+ message: unknown ? `the password could not be checked for user "${this.user}": ${message}` : `password authentication failed for user "${this.user}": ${message}`,
566
+ hint: unknown ? "connect again once Mason can read its list of users"
567
+ : "a tool that keeps one password connects as a user: mason user add <name> prints its password and URL" }, 401);
568
+ // a tool that cannot connect is the first thing to look for in the request log
569
+ this.asked = { sql: "", method: "CONNECT", startedAt: Date.now() };
570
+ this.recordRequest(refused, undefined, err instanceof ApiError ? err.userName : undefined);
571
+ this.send(errorResponse({ ...refused.fields, severity: "FATAL" }));
572
+ this.end("authentication failed");
573
+ return;
574
+ }
575
+ if (this.phase !== "password")
576
+ return; // the socket closed while the ledger was read
577
+ this.token = token;
578
+ this.presented = m.password;
579
+ this.verifiedAt = this.server.now();
580
+ this.phase = "ready";
581
+ this.decoder.maxLength = MAX_MESSAGE_LENGTH; // authenticated: a statement may be as long as the protocol allows
582
+ // what is happening, as it happens (mason logs once showed little): who connected, and (end) who left
583
+ this.server.log(`session ${this.id} connected: ${this.vars.applicationName === "" ? "a client with no application_name" : JSON.stringify(this.vars.applicationName)} as ${JSON.stringify(this.user)}` +
584
+ (token.user !== undefined ? " (a mason user)" : ""));
585
+ const status = [authenticationOk()];
586
+ for (const [name, value] of this.reportedParameters())
587
+ status.push(parameterStatus(name, value));
588
+ status.push(backendKeyData(this.id, this.secret), readyForQuery("I"));
589
+ this.sendAll(status);
590
+ }
591
+ reportedParameters() {
592
+ return [
593
+ ["application_name", this.vars.applicationName], ["client_encoding", "UTF8"], ["DateStyle", "ISO, MDY"],
594
+ ["default_transaction_read_only", "on"], ["in_hot_standby", "on"], ["integer_datetimes", "on"], ["IntervalStyle", "postgres"],
595
+ ["is_superuser", "off"], ["search_path", this.vars.searchPath], ["server_encoding", "UTF8"], ["server_version", SERVER_VERSION],
596
+ ["session_authorization", this.user], ["standard_conforming_strings", "on"], ["TimeZone", this.vars.timeZone],
597
+ ];
598
+ }
599
+ // ---------------------------------------------------------------- session parameters
600
+ /**
601
+ * SET of one parameter. Returns the ParameterStatus to send when it is one Postgres reports. The parameters this
602
+ * server has an opinion on are decided here; the rest go to the catalog Postgres for validation and are kept.
603
+ */
604
+ setParameter(name, value, atStartup = false) {
605
+ const report = (n, v) => REPORTED.has(n) ? [parameterStatus(spell(n), v)] : [];
606
+ switch (name) {
607
+ case "timezone":
608
+ case "time zone": {
609
+ const zone = value === null || /^(default|local|localtime)$/i.test(value) ? "UTC" : canonicalZone(value);
610
+ if (zone === null)
611
+ throw new PgError({ code: "22023", message: `invalid value for parameter "TimeZone": "${value}"`, hint: "an IANA zone name (Europe/London) or UTC: this API knows no abbreviation or fixed offset" });
612
+ this.vars.timeZone = zone;
613
+ return report("timezone", zone);
614
+ }
615
+ case "application_name":
616
+ this.vars.applicationName = value ?? "";
617
+ return report(name, this.vars.applicationName);
618
+ case "search_path":
619
+ this.vars.searchPath = value ?? '"$user", public';
620
+ return report(name, this.vars.searchPath);
621
+ case "client_encoding":
622
+ case "names":
623
+ if (value !== null && !isUtf8(value))
624
+ throw new PgError({ code: "22023", message: `invalid value for parameter "client_encoding": "${value}"`, hint: "this API sends UTF8 only" });
625
+ return report("client_encoding", "UTF8");
626
+ case "datestyle":
627
+ if (value !== null && !/^\s*iso\b/i.test(value) && !/,\s*iso\s*$/i.test(value) && value.trim() !== "") {
628
+ throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
629
+ }
630
+ return report("datestyle", "ISO, MDY");
631
+ case "extra_float_digits": {
632
+ const n = value === null ? 1 : Number(value);
633
+ if (!Number.isInteger(n) || n < -15 || n > 3)
634
+ throw new PgError({ code: "22023", message: `${n} is outside the valid range for parameter "extra_float_digits" (-15 .. 3)` });
635
+ if (n < 1)
636
+ throw new PgError({ code: "0A000", message: `extra_float_digits ${n} is not supported: this API sends every float as the shortest text that reads back exactly (Postgres's extra_float_digits >= 1, the default since Postgres 12); fewer digits would be a different number` });
637
+ return [];
638
+ }
639
+ case "standard_conforming_strings":
640
+ if (value !== null && !/^(on|true|yes|1)$/i.test(value))
641
+ throw new PgError({ code: "0A000", message: "standard_conforming_strings off is not supported: a string literal is read as Postgres reads it by default" });
642
+ return report(name, "on");
643
+ case "intervalstyle": return report(name, "postgres");
644
+ case "transaction_isolation":
645
+ case "default_transaction_isolation":
646
+ case "transaction":
647
+ case "session characteristics":
648
+ // read committed on a store that is never written: every level answers the same rows
649
+ return [];
650
+ case "default_transaction_read_only":
651
+ case "transaction_read_only":
652
+ if (value !== null && /^(off|false|no|0)$/i.test(value))
653
+ throw new PgError({ code: "25006", message: "cannot set transaction read-write mode: this API is read-only" });
654
+ return report(name, "on");
655
+ case "statement_timeout":
656
+ case "lock_timeout":
657
+ case "idle_in_transaction_session_timeout":
658
+ case "idle_session_timeout":
659
+ case "transaction_timeout":
660
+ case "client_min_messages":
661
+ case "log_min_messages":
662
+ case "bytea_output":
663
+ case "default_transaction_deferrable":
664
+ case "xmloption":
665
+ case "backslash_quote":
666
+ case "escape_string_warning":
667
+ case "synchronize_seqscans":
668
+ case "work_mem":
669
+ case "max_parallel_workers_per_gather":
670
+ case "jit":
671
+ case "row_security":
672
+ case "lc_messages":
673
+ case "lc_monetary":
674
+ case "lc_numeric":
675
+ case "lc_time":
676
+ case "check_function_bodies":
677
+ // accepted and remembered; none changes an answer (statement_timeout is honoured, armTimeout: it may only shorten
678
+ // the run's own limit, MASON_STATEMENT_TIMEOUT)
679
+ if (value === null)
680
+ this.vars.other.delete(name);
681
+ else
682
+ this.vars.other.set(name, value);
683
+ return [];
684
+ default: {
685
+ // a parameter Mason knows nothing of: the catalog Postgres decides whether it exists
686
+ if (atStartup)
687
+ throw new PgError({ code: "42704", message: `unrecognized configuration parameter "${name}" in the startup packet` });
688
+ if (value === null)
689
+ this.vars.other.delete(name);
690
+ else
691
+ this.vars.other.set(name, value);
692
+ return [];
693
+ }
694
+ }
695
+ }
696
+ /** The catalog Postgres validates a parameter this server has no opinion on; its own error is the answer. Inside a
697
+ * transaction that is ROLLED BACK: the sidecar is one connection every session shares, and a SET that stuck there
698
+ * was every session's (review of #480). The value itself lives in this session's `vars.other`. */
699
+ async validateElsewhere(name, value) {
700
+ const sql = value === null ? `RESET ${quoteIdent(name)}` : `SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}`;
701
+ // one batch, so no other session's statement lands inside the transaction: the ROLLBACK in the first query is not
702
+ // reached when the SET fails (an error ends a simple query's string), so a second query rolls back for that case
703
+ // (a WARNING "no transaction in progress" when the first one did, never an error)
704
+ const reply = splitFrames(await this.server.catalog.run(Buffer.concat([frame("Q", cstr(`BEGIN; ${sql}; ROLLBACK`)), frame("Q", cstr("ROLLBACK"))])));
705
+ const err = reply.find((f) => f.type === "E");
706
+ if (err !== undefined)
707
+ throw relayedError(err.frame);
708
+ }
709
+ // ---------------------------------------------------------------- the simple protocol
710
+ async simpleQuery(sql) {
711
+ // the Query's arrival is its statements' instant, and the start of the implicit transaction they run in
712
+ this.statementNow = this.server.now();
713
+ try {
714
+ await this.simpleStatements(sql);
715
+ }
716
+ finally {
717
+ if (this.tx === "none")
718
+ this.txNow = null;
719
+ }
720
+ }
721
+ /** the transaction's "now", the transaction begun at `statementNow` when none is open */
722
+ txStart(statementNow) {
723
+ this.txNow ??= statementNow;
724
+ return this.txNow;
725
+ }
726
+ async simpleStatements(sql) {
727
+ let stmts;
728
+ try {
729
+ stmts = (await parseSql(sql)).stmts ?? [];
730
+ }
731
+ catch (err) {
732
+ const e = asPgError(err, sql);
733
+ this.refuse(e, sql);
734
+ if (this.tx === "open")
735
+ this.tx = "failed";
736
+ this.send(readyForQuery(this.txStatus()));
737
+ return;
738
+ }
739
+ if (stmts.length === 0) {
740
+ this.send(emptyQueryResponse(), readyForQuery(this.txStatus()));
741
+ return;
742
+ }
743
+ for (const raw of stmts) {
744
+ const text = statementText(sql, raw);
745
+ this.asked = { sql: text, method: "QUERY", startedAt: Date.now() };
746
+ try {
747
+ const out = await this.runSimple(raw, text, sql);
748
+ this.sendAll(out);
749
+ }
750
+ catch (err) {
751
+ const e = asPgError(err, sql);
752
+ this.refuse(e, text);
753
+ if (this.tx === "open")
754
+ this.tx = "failed";
755
+ break;
756
+ }
757
+ }
758
+ this.send(readyForQuery(this.txStatus()));
759
+ }
760
+ /**
761
+ * EVERY STATEMENT'S ErrorResponse goes out here, and is logged here at the normal level (a BI tool met an
762
+ * error on the test server and the log said only that its session connected and closed — the one refusal line was the trace's).
763
+ * One line, `session <id> refused: <SQLSTATE> <message> — <sql>` (`failed` for Mason's own failure, `refusalLine`),
764
+ * so `mason logs` answers "why did my query fail"; the trace adds its own line beside it. A FATAL that ends the session
765
+ * is said by its close (`end`), not here; tests/postgres-api/server.test.ts holds this to being the only other sender.
766
+ */
767
+ refuse(e, sql, at) {
768
+ this.server.log(refusalLine(this.id, e.fields, sql));
769
+ this.recordRequest(e, sql);
770
+ this.traceRefusal(e, at);
771
+ this.send(errorResponse(e.fields));
772
+ }
773
+ traceRefusal(e, at) {
774
+ if (this.server.options.trace !== true)
775
+ return;
776
+ this.server.log(`trace ${this.id} refused${at === undefined ? "" : ` at ${at}`}: ${e.fields.code} ${JSON.stringify(e.fields.message)}`);
777
+ }
778
+ /**
779
+ * THE REQUEST LOG (src/duck/requests.ts; a BI tool met an error on the test server and nothing could be found
780
+ * afterwards): a row for every statement DuckDB answered — its rows sent, its time from arrival to the last row — for
781
+ * every refused or failed statement, with its SQLSTATE, message, DETAIL and HINT (from `refuse`, the one place a
782
+ * statement's ErrorResponse goes out), and for a refused password (CONNECT), through `options.record`. A catalog
783
+ * read, a SET or a SHOW answered is the protocol's chatter, not recorded. Never throws: `record` buffers.
784
+ */
785
+ recordRequest(answer, sql, refusedUser, of) {
786
+ // `of`: a statement recorded after its own messages (a portal dropped suspended), not the one being asked now
787
+ const asked = of === undefined ? this.asked : { sql: sql ?? "", method: of.method, startedAt: Date.now(), ...(of.jsonReads !== undefined ? { jsonReads: of.jsonReads } : {}) };
788
+ const startedAt = (!(answer instanceof PgError) ? answer.startedAt : undefined) ?? asked?.startedAt ?? Date.now();
789
+ // a refusal's statement is the one refuse() names (a simple Query's own statement, an Execute's portal's)
790
+ const stored = storedPayload(sql ?? asked?.sql ?? "");
791
+ const user = this.token?.user ?? refusedUser;
792
+ this.server.options.record({
793
+ requestId: crypto.randomUUID(), at: new Date(startedAt), method: asked?.method ?? "QUERY", durationMs: Date.now() - startedAt,
794
+ client: `postgres:${this.vars.applicationName || "unnamed"}`, session: this.id, payload: stored.text, payloadTruncated: stored.truncated,
795
+ caller: hashCaller(this.presented),
796
+ ...(user !== undefined ? { user } : {}),
797
+ ...(asked?.jsonReads !== undefined && asked.jsonReads.length > 0 ? { jsonReads: asked.jsonReads } : {}),
798
+ ...(answer instanceof PgError
799
+ ? { status: answer.status, sqlstate: answer.fields.code, error: answer.fields.message,
800
+ ...(answer.fields.detail ? { detail: answer.fields.detail } : {}), ...(answer.fields.hint ? { hint: answer.fields.hint } : {}) }
801
+ : { status: 200, rows: answer.rows }),
802
+ });
803
+ }
804
+ txStatus() { return this.tx === "none" ? "I" : this.tx === "open" ? "T" : "E"; }
805
+ /** A statement classified (translate.ts `duckTranslate`), the catalog replaced first when the copied tables changed
806
+ * (`Server.schema`), so the plan made next is made over them. */
807
+ classify(stmt, text) {
808
+ this.server.schema();
809
+ return duckTranslate(stmt, text);
810
+ }
811
+ /** One statement of a simple query: the whole reply but the ReadyForQuery. */
812
+ async runSimple(raw, text, whole) {
813
+ if (raw.stmt === undefined)
814
+ return [emptyQueryResponse()];
815
+ const now = this.txStart(this.statementNow);
816
+ const t = this.classify(raw.stmt, text);
817
+ if (this.tx === "failed" && !(t.kind === "transaction" && t.tag !== "BEGIN" && t.tag !== "START TRANSACTION")) {
818
+ throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
819
+ }
820
+ switch (t.kind) {
821
+ case "empty": return [emptyQueryResponse()];
822
+ case "explain": {
823
+ const e = await this.explaining(raw, whole, t, []);
824
+ if (e.plan === null)
825
+ return catalogExplained(await this.forwardSimple(text), e.options);
826
+ const controller = new AbortController();
827
+ const disarm = this.armTimeout(controller);
828
+ try {
829
+ const { field, rows } = await this.duckExplain({ options: e.options, plan: e.plan }, [], [], { parse: now, exec: now }, controller);
830
+ this.recordRequest({ rows: rows.length });
831
+ return [rowDescription([field]), ...rows, commandComplete("EXPLAIN")];
832
+ }
833
+ finally {
834
+ disarm();
835
+ }
836
+ }
837
+ case "catalog": return this.forwardSimple(text);
838
+ case "set": return this.setStatement(t);
839
+ case "show": return this.showStatement(t.name);
840
+ case "transaction": return [...this.transaction(t.tag)];
841
+ case "discard":
842
+ this.statements.clear();
843
+ this.dropPortals();
844
+ this.vars = { ...defaultVars(), applicationName: this.vars.applicationName };
845
+ return [commandComplete("DISCARD ALL")];
846
+ case "deallocate":
847
+ this.statements.clear();
848
+ return [commandComplete("DEALLOCATE ALL")];
849
+ case "duckdb": {
850
+ const plan = await this.duckPlan(raw, whole, []);
851
+ // a simple Query's statement is parsed and run at once, in the transaction its arrival began (or the open one)
852
+ const controller = new AbortController();
853
+ const disarm = this.armTimeout(controller);
854
+ try {
855
+ const { fields, cursor } = await this.duckAnswer(plan, [], [], { parse: now, exec: now }, controller);
856
+ try {
857
+ this.send(rowDescription(fields));
858
+ await this.streamRest(cursor);
859
+ }
860
+ finally {
861
+ cursor.answer.close();
862
+ }
863
+ this.recordRequest({ rows: cursor.sent });
864
+ return [commandComplete(`SELECT ${cursor.sent}`)];
865
+ }
866
+ finally {
867
+ disarm();
868
+ }
869
+ }
870
+ }
871
+ }
872
+ /** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone and
873
+ * declared parameter types until the schema changes. Every statement DuckDB runs has one: never its text as written. */
874
+ async duckPlan(raw, whole, paramOids, under = this.vars, wireBound = false) {
875
+ const stmt = raw.stmt;
876
+ const bytes = Buffer.from(whole, "utf8");
877
+ const base = raw.stmt_location ?? 0;
878
+ const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
879
+ const { searchPath, timeZone } = under;
880
+ return this.server.duckPlan(text, paramOids, searchPath, timeZone, wireBound, () => this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, searchPath, timeZone, this.server.userTypes(), wireBound));
881
+ }
882
+ /**
883
+ * 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
884
+ * Execute's row count (JDBC's setMaxRows or fetch size, DBeaver's, a native question's) arrives after the Parse, so a
885
+ * LEFT lateral of a list with no LIMIT in its SQL was left DuckDB's delimiter join, which reads the whole base before
886
+ * the first row and hands its rows back in its own order — the first rows sent were not Postgres's. Where the plan
887
+ * says such a lateral was left so (PlannedSql.unboundedLateral), the statement is planned again with the bound
888
+ * asserted, under the search_path and TimeZone of its Parse, kept on the statement and in the plans by text. The
889
+ * portal's result is begun ONCE, from whichever plan its first Execute picked; an Execute after a PortalSuspended
890
+ * reads on from that result, never runs it again. A plan made again over a schema moved since the Parse, whose
891
+ * result or parameters are not the ones the Parse described, is not run: the Parse's plan is (what a client was told
892
+ * of the result stands). Planning again is the statement's own time: a CancelRequest landing meanwhile aborts its
893
+ * controller (`running`, answered 57014 as the statement begins), and a session closed meanwhile runs nothing. A
894
+ * plan made again that Postgres or the rewrite refuses (a StatementError, never a cancel's or a timeout's 57014) is
895
+ * logged and the Parse's plan runs, kept: the Parse answered, and a row count must not make it fail.
896
+ */
897
+ async boundOnWire(statement, plan, controller) {
898
+ if (plan.sql.unboundedLateral !== true || statement.raw === null)
899
+ return plan;
900
+ if (statement.duckOnWire === undefined) {
901
+ this.running = controller;
902
+ let bounded;
903
+ try {
904
+ bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.vars, true);
905
+ }
906
+ catch (e) {
907
+ if (!(e instanceof StatementError) || e.fields.code === "57014")
908
+ throw e;
909
+ 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`);
910
+ bounded = plan;
911
+ }
912
+ finally {
913
+ this.settled();
914
+ }
915
+ const shape = (p) => JSON.stringify([p.params, p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null]);
916
+ statement.duckOnWire = shape(bounded) === shape(plan) ? bounded : plan;
917
+ if (statement.duckOnWire === plan && bounded !== plan)
918
+ 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`);
919
+ }
920
+ if (this.phase === "closed") {
921
+ this.server.log(`session ${this.id}: closed while its statement was planned again for an Execute's row count: it runs nothing`);
922
+ throw new PgError({ code: "08006", message: "the session closed while its statement was planned" });
923
+ }
924
+ return statement.duckOnWire;
925
+ }
926
+ /**
927
+ * AN EXPLAIN (src/api/postgres/explain.ts, src/duck/explain.ts): its options read as Postgres reads them, then the
928
+ * statement it explains classified as that statement alone would be — refused with that statement's own error — and,
929
+ * for DuckDB's, planned afresh (an ANALYZE's Planning Time is this one's), never from the plans kept per text.
930
+ */
931
+ async explaining(raw, whole, t, paramOids) {
932
+ const options = explainOptions(t.options);
933
+ const inner = this.classify(t.query, "");
934
+ if (inner.kind === "catalog")
935
+ return { options, plan: null };
936
+ if (inner.kind !== "duckdb")
937
+ throw new PgError({ code: "0A000", message: "EXPLAIN of this statement is not supported: a SELECT is explained" });
938
+ const bytes = Buffer.from(whole, "utf8");
939
+ const base = raw.stmt_location ?? 0;
940
+ const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
941
+ const found = await explainedText(text, t.query);
942
+ if (found === null)
943
+ throw new PgError({ code: "0A000", message: "Mason cannot find the statement this EXPLAIN explains in its text (a Mason gap: report it, with the statement)" });
944
+ let plan;
945
+ try {
946
+ 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());
947
+ }
948
+ catch (e) {
949
+ // a refusal of the statement explained is placed as Postgres places it: in the whole text, past `EXPLAIN …`
950
+ if (!(e instanceof StatementError) || e.fields.position === undefined)
951
+ throw e;
952
+ const before = bytes.subarray(0, base).toString("utf8").length + found.offset;
953
+ throw new StatementError({ ...e.fields, position: e.fields.position + before }, whole);
954
+ }
955
+ return { options, plan };
956
+ }
957
+ /** an EXPLAIN of a DuckDB statement, run: its QUERY PLAN rows (ANALYZE runs the statement and sends none of its rows) */
958
+ async duckExplain(e, params, resultFormats, clock, controller) {
959
+ await this.readAside();
960
+ const runner = await this.duck();
961
+ this.running = controller;
962
+ try {
963
+ const values = await this.duckValues(e.plan, params, clock);
964
+ const lines = await runner.explain(e.plan.sql, values, this.duckSession(), controller.signal, { options: e.options, fields: e.plan.fields, exact: e.plan.exact ?? [], planning: e.plan.timing ?? { analysisMs: 0, rewriteMs: 0 },
965
+ // how each copy is kept current, as the sync says it to mason status, read now (src/duck/sync.ts KeptCurrent)
966
+ copies: copyFacts(this.server.options.duck.copies?.() ?? []) });
967
+ return { field: explainField(e.options, formatAt(resultFormats, 0)), rows: lines.map((l) => dataRow([Buffer.from(l)])) };
968
+ }
969
+ finally {
970
+ this.settled();
971
+ }
972
+ }
973
+ /** a plan's parameter values as DuckDB reads them: the bound ones through Postgres's input functions, then the
974
+ * transaction's start where the plan reads it (its positions follow the statement's own parameters) */
975
+ async duckValues(plan, params, clock) {
976
+ const own = params.length === 0 ? params
977
+ : await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.vars.timeZone);
978
+ const values = [...own];
979
+ for (const [which, at] of Object.entries(plan.clock ?? {}))
980
+ values[at - 1] = new Date(clock[which]).toISOString();
981
+ return values;
982
+ }
983
+ async duck() {
984
+ this.duckRunner ??= await this.server.options.duck.open();
985
+ return this.duckRunner;
986
+ }
987
+ duckSession() { return { timeZone: this.vars.timeZone, searchPath: this.vars.searchPath }; }
988
+ /** A DuckDB statement run: its columns (Postgres's, where it has a plan), and its rows to read, encoded in the
989
+ * formats asked */
990
+ async duckAnswer(plan, params, resultFormats, clock, controller) {
991
+ this.readsOf(plan);
992
+ await this.readAside();
993
+ const runner = await this.duck();
994
+ this.running = controller;
995
+ try {
996
+ const values = await this.duckValues(plan, params, clock);
997
+ const a = await runner.run(plan.sql, values, this.duckSession(), controller.signal, plan.fields, plan.exact ?? []);
998
+ // a SELECT's analysis always has its RowDescription; without one, DuckDB's own columns are said as they are
999
+ const fields = plan.fields !== null
1000
+ ? 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) }))
1001
+ : a.columns.map((c, i) => ({ name: c.name, typeOid: c.oid, typeSize: c.size, typeModifier: -1, format: formatAt(resultFormats, i) }));
1002
+ return { fields, cursor: { answer: a, formats: resultFormats, controller, pending: [], sent: 0, done: false, startedAt: this.asked?.startedAt ?? Date.now() } };
1003
+ }
1004
+ finally {
1005
+ this.settled();
1006
+ }
1007
+ }
1008
+ /**
1009
+ * THE STATEMENT'S TIME LIMIT, armed for one simple Query's statement or one Execute (as Postgres's statement_timeout
1010
+ * times each): the session's statement_timeout or the server's, the shorter. When it fires the statement's
1011
+ * controller is aborted with the timeout as its reason, so DuckDB is interrupted (or the wait for the client woken)
1012
+ * and the client is told 57014 "canceling statement due to statement timeout". Returns the disarm.
1013
+ */
1014
+ armTimeout(controller) {
1015
+ const own = timeoutMs(this.vars.other.get("statement_timeout") ?? "0") ?? 0;
1016
+ const server = this.server.options.statementTimeoutMs ?? 0;
1017
+ const ms = [own, server].filter((x) => x > 0);
1018
+ if (ms.length === 0)
1019
+ return () => undefined;
1020
+ const limit = Math.min(...ms);
1021
+ const byServer = server > 0 && limit === server && !(own > 0 && own <= server);
1022
+ const t = setTimeout(() => controller.abort(new StatementError({ code: "57014", message: "canceling statement due to statement timeout",
1023
+ ...(byServer ? { hint: `Mason runs one statement for at most ${limit / 1000} s (MASON_STATEMENT_TIMEOUT): ask for less at once (a filter, a LIMIT, a coarser GROUP BY), or raise it and start mason again` } : {}) })), limit);
1024
+ return () => clearTimeout(t);
1025
+ }
1026
+ /** the next chunk of a cursor into its `pending` rows (none at its end, then `done`); a cancel interrupts it */
1027
+ async fill(c) {
1028
+ if (c.done)
1029
+ return;
1030
+ const mine = this.running === null;
1031
+ if (mine)
1032
+ this.running = c.controller;
1033
+ try {
1034
+ const rows = await c.answer.fetch(c.formats, { timeZone: this.vars.timeZone });
1035
+ if (rows === null)
1036
+ c.done = true;
1037
+ else
1038
+ for (const r of rows)
1039
+ c.pending.push(r);
1040
+ }
1041
+ catch (e) {
1042
+ c.done = true;
1043
+ throw e;
1044
+ }
1045
+ finally {
1046
+ if (mine)
1047
+ this.settled();
1048
+ }
1049
+ }
1050
+ /** what is pending of a cursor, sent */
1051
+ flushPending(c) {
1052
+ if (c.pending.length === 0)
1053
+ return;
1054
+ c.sent += c.pending.length;
1055
+ this.sendAll(c.pending);
1056
+ c.pending = [];
1057
+ }
1058
+ /**
1059
+ * EVERY ROW LEFT OF A CURSOR, WRITTEN AS IT IS READ, a chunk at a time: the next chunk is read only once the socket
1060
+ * has room (`writable`), so a client that reads slowly slows the reading and never fills this process's memory. A
1061
+ * session that ends meanwhile closes it.
1062
+ */
1063
+ async streamRest(c) {
1064
+ // a CancelRequest is the statement's until its last row is written, the waits for the client included (Postgres
1065
+ // cancels a backend blocked on its socket too)
1066
+ this.running = c.controller;
1067
+ try {
1068
+ for (;;) {
1069
+ this.flushPending(c);
1070
+ await this.writable(c.controller.signal);
1071
+ if (this.phase === "closed") {
1072
+ c.answer.close();
1073
+ c.done = true;
1074
+ return;
1075
+ }
1076
+ if (c.done)
1077
+ return;
1078
+ await this.fill(c);
1079
+ }
1080
+ }
1081
+ finally {
1082
+ this.settled();
1083
+ }
1084
+ }
1085
+ /** resolves once the socket can take more, is gone, or the statement is cancelled */
1086
+ async writable(signal) {
1087
+ if (this.phase === "closed" || !this.socket.writableNeedDrain || signal.aborted)
1088
+ return;
1089
+ const socket = this.socket;
1090
+ await new Promise((resolve) => {
1091
+ const done = () => { socket.off("drain", done); socket.off("close", done); signal.removeEventListener("abort", done); resolve(); };
1092
+ socket.on("drain", done);
1093
+ socket.on("close", done);
1094
+ signal.addEventListener("abort", done);
1095
+ });
1096
+ }
1097
+ /**
1098
+ * DuckDB starts nothing on a connection whose streamed result is half read: before a statement, a portal suspended
1099
+ * part way (an Execute's row limit) is read to its end into its `pending` rows, and its failure kept for its next
1100
+ * Execute. Rare — one statement at a time is what clients do — and the one case that holds a whole result.
1101
+ */
1102
+ async readAside() {
1103
+ for (const p of this.portals.values()) {
1104
+ const c = p.cursor;
1105
+ if (c === undefined)
1106
+ continue;
1107
+ try {
1108
+ while (!c.done)
1109
+ await this.fill(c);
1110
+ }
1111
+ catch (e) {
1112
+ c.failed = e;
1113
+ }
1114
+ }
1115
+ }
1116
+ /**
1117
+ * A portal gone (Close, Sync, a Bind of its name, DISCARD, the session's end): its DuckDB result with it. One the
1118
+ * client stopped reading at a row count (an Execute's: JDBC's setMaxRows, `mason mcp`'s max_rows) was never read to
1119
+ * its end, so its row of the request log is written here, with the rows it sent: a statement answered is in the log
1120
+ * however far it was read.
1121
+ */
1122
+ dropPortal(name) {
1123
+ const portal = this.portals.get(name);
1124
+ const c = portal?.cursor;
1125
+ if (portal !== undefined && c !== undefined && c.suspended === true && c.recorded !== true && c.failed === undefined) {
1126
+ c.recorded = true;
1127
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt }, portal.statement.sql, undefined, { method: "EXECUTE", jsonReads: portal.statement.duck?.jsonReads });
1128
+ }
1129
+ c?.answer.close();
1130
+ this.portals.delete(name);
1131
+ }
1132
+ dropPortals() { for (const name of [...this.portals.keys()])
1133
+ this.dropPortal(name); }
1134
+ async forwardSimple(text) {
1135
+ const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(text))));
1136
+ const err = reply.find((f) => f.type === "E");
1137
+ if (err !== undefined)
1138
+ throw relayedError(err.frame);
1139
+ return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1140
+ }
1141
+ async setStatement(t) {
1142
+ if (t.name === "all") {
1143
+ this.vars = { ...defaultVars(), applicationName: this.vars.applicationName };
1144
+ return [commandComplete("RESET")];
1145
+ }
1146
+ const tag = t.value === null ? "RESET" : "SET";
1147
+ if (!this.decidesHere(t.name))
1148
+ await this.validateElsewhere(t.name, t.value);
1149
+ const status = this.setParameter(t.name, t.value);
1150
+ return [...status, commandComplete(tag)];
1151
+ }
1152
+ decidesHere(name) {
1153
+ return ["timezone", "time zone", "application_name", "search_path", "client_encoding", "names", "datestyle", "extra_float_digits",
1154
+ "standard_conforming_strings", "intervalstyle", "transaction_isolation", "default_transaction_isolation", "transaction", "session characteristics",
1155
+ "default_transaction_read_only", "transaction_read_only"].includes(name);
1156
+ }
1157
+ async showStatement(name) {
1158
+ if (name === "all")
1159
+ throw new PgError({ code: "0A000", message: "SHOW ALL is not supported: SHOW one parameter" });
1160
+ const value = tlsParameters(this.socket instanceof tls.TLSSocket)[name] ?? fixedParameter(name, this.vars, this.user) ?? this.vars.other.get(name) ?? null;
1161
+ if (value === null) {
1162
+ const reply = splitFrames(await this.server.catalog.run(frame("Q", cstr(`SHOW ${quoteIdent(name)}`))));
1163
+ const err = reply.find((f) => f.type === "E");
1164
+ if (err !== undefined)
1165
+ throw relayedError(err.frame);
1166
+ return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1167
+ }
1168
+ return [rowDescription([fieldOf(spell(name), "text", 0)]), dataRow([Buffer.from(value, "utf8")]), commandComplete("SHOW")];
1169
+ }
1170
+ /** a SHOW's RowDescription, for a Describe: the frame its answer starts with (the column is named by the parameter) */
1171
+ async showDescription(name) {
1172
+ return (await this.showStatement(name)).filter((f) => f[0] === ROW_DESCRIPTION);
1173
+ }
1174
+ transaction(tag) {
1175
+ if (tag === "BEGIN" || tag === "START TRANSACTION") {
1176
+ if (this.tx !== "none")
1177
+ return [noticeResponse({ severity: "WARNING", code: "25001", message: "there is already a transaction in progress" }), commandComplete(tag)];
1178
+ this.tx = "open";
1179
+ return [commandComplete(tag)];
1180
+ }
1181
+ if (this.tx === "none") {
1182
+ return [noticeResponse({ severity: "WARNING", code: "25P01", message: "there is no transaction in progress" }), commandComplete(tag)];
1183
+ }
1184
+ const wasFailed = this.tx === "failed";
1185
+ this.tx = "none";
1186
+ // the transaction's "now" ends with it; a statement after it in the same Query begins the next at the Query's arrival
1187
+ this.txNow = null;
1188
+ return [commandComplete(tag === "COMMIT" && wasFailed ? "ROLLBACK" : tag)];
1189
+ }
1190
+ // ---------------------------------------------------------------- the extended protocol
1191
+ async extended(m) {
1192
+ if (m.type === "Sync") {
1193
+ // the unnamed portal ends with the transaction, as Postgres's does (a catalog statement's holds nothing in the
1194
+ // sidecar: it was run and closed at its Bind)
1195
+ this.dropPortal("");
1196
+ this.skipToSync = false;
1197
+ // an implicit transaction ends at Sync; an open one keeps its "now"
1198
+ if (this.tx === "none")
1199
+ this.txNow = null;
1200
+ this.send(readyForQuery(this.txStatus()));
1201
+ return;
1202
+ }
1203
+ if (this.skipToSync || m.type === "Flush")
1204
+ return;
1205
+ // its statement, read as the message arrives: a portal dropped while it runs (the session ended under a cancelled
1206
+ // statement) leaves the refusal its statement, never ""
1207
+ const sql = m.type === "Parse" ? m.sql : this.sqlOf(m);
1208
+ try {
1209
+ switch (m.type) {
1210
+ case "Parse": return this.sendAll(await this.parseMessage(m));
1211
+ case "Bind": return this.sendAll(await this.bindMessage(m));
1212
+ case "Describe": return this.sendAll(await this.describeMessage(m));
1213
+ case "Execute": return this.sendAll(await this.executeMessage(m));
1214
+ case "Close": return this.sendAll(await this.closeMessage(m));
1215
+ }
1216
+ }
1217
+ catch (err) {
1218
+ const e = asPgError(err, sql);
1219
+ this.refuse(e, sql, m.type);
1220
+ this.skipToSync = true;
1221
+ if (this.tx === "open")
1222
+ this.tx = "failed";
1223
+ }
1224
+ }
1225
+ sqlOf(m) {
1226
+ if (m.type === "Bind")
1227
+ return this.statements.get(m.statement)?.sql ?? "";
1228
+ if (m.type === "Close")
1229
+ return "";
1230
+ if (m.type === "Execute")
1231
+ return this.portals.get(m.portal)?.statement.sql ?? "";
1232
+ return (m.kind === "S" ? this.statements.get(m.name)?.sql : this.portals.get(m.name)?.statement.sql) ?? "";
1233
+ }
1234
+ /** the sidecar's names for this session's statements and portals: unnamed ones get a name of their own, so two
1235
+ * sessions' unnamed statements never meet in the one Postgres */
1236
+ catalogName(kind, name) {
1237
+ return `${kind === "S" ? "s" : "p"}${this.id}${name === "" ? "" : `_${name}`}`;
1238
+ }
1239
+ /** runs frames in the sidecar; throws the first ErrorResponse; returns the rest, less ReadyForQuery */
1240
+ async catalogRun(...parts) {
1241
+ const reply = splitFrames(await this.server.catalog.run(Buffer.concat(parts)));
1242
+ // the sidecar answered NOTHING to a batch: it is ignoring everything until a Sync (an earlier error nobody synced)
1243
+ // — a protocol state this server must never leave it in; said, never relayed as an empty answer
1244
+ if (reply.length === 0)
1245
+ throw new PgError({ code: "XX000", message: "the catalog Postgres answered nothing: report it (its extended-protocol state is out of step)" }, 500);
1246
+ const err = reply.find((f) => f.type === "E");
1247
+ if (err !== undefined) {
1248
+ // AFTER AN ERROR IN THE EXTENDED PROTOCOL POSTGRES IGNORES EVERY MESSAGE UNTIL A SYNC (ignore_till_sync), and the
1249
+ // sidecar is one connection every session shares: without this Sync one refused Parse silenced the catalog for
1250
+ // every session until a restart (review of #480). The Sync's own ReadyForQuery is consumed here.
1251
+ if (!parts.some((p) => p[0] === 0x53 /* S */))
1252
+ await this.server.catalog.run(frame("S"));
1253
+ throw relayedError(err.frame);
1254
+ }
1255
+ return reply.filter((f) => f.type !== "Z").map((f) => f.frame);
1256
+ }
1257
+ async parseMessage(m) {
1258
+ if (m.name !== "" && this.statements.has(m.name))
1259
+ throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
1260
+ const stmts = (await parseSql(m.sql)).stmts ?? [];
1261
+ if (stmts.length > 1)
1262
+ throw new PgError({ code: "42601", message: "cannot insert multiple commands into a prepared statement" });
1263
+ const raw = stmts[0] ?? null;
1264
+ const statement = { sql: m.sql, raw, paramOids: [...m.paramTypes], shape: null, catalogName: null };
1265
+ if (raw?.stmt !== undefined) {
1266
+ statement.shape = this.classify(raw.stmt, statementText(m.sql, raw));
1267
+ if (statement.shape.kind === "explain") {
1268
+ const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes);
1269
+ if (e.plan === null)
1270
+ statement.explainCatalog = e.options;
1271
+ else {
1272
+ statement.explain = { options: e.options, plan: e.plan };
1273
+ statement.parsedNow = this.txNow ??= this.server.now();
1274
+ }
1275
+ }
1276
+ // Postgres's analysis says the result's columns and the parameters' types, and DuckDB's SQL is made from it
1277
+ if (statement.shape.kind === "duckdb") {
1278
+ statement.duck = await this.duckPlan(raw, m.sql, m.paramTypes);
1279
+ statement.plannedUnder = { searchPath: this.vars.searchPath, timeZone: this.vars.timeZone };
1280
+ // the implicit transaction begins with the Parse, as Postgres's does (start_xact_command): its literals read it
1281
+ statement.parsedNow = this.txNow ??= this.server.now();
1282
+ }
1283
+ if (statement.shape.kind === "catalog" || statement.explainCatalog !== undefined) {
1284
+ statement.catalogName = this.catalogName("S", m.name);
1285
+ const own = this.statements.get(m.name);
1286
+ // the unnamed statement is replaced by a Parse; the sidecar's must be closed first
1287
+ const close = own?.catalogName !== null && own !== undefined ? [frame("C", Buffer.from("S"), cstr(own.catalogName))] : m.name === "" ? [frame("C", Buffer.from("S"), cstr(statement.catalogName))] : [];
1288
+ const reply = await this.catalogRun(...close, frame("P", cstr(statement.catalogName), cstr(m.sql), int16(m.paramTypes.length), ...m.paramTypes.map(int32)));
1289
+ this.statements.set(m.name, statement);
1290
+ // the CloseComplete of the close this server added is not the client's
1291
+ return reply.slice(close.length);
1292
+ }
1293
+ }
1294
+ this.statements.set(m.name, statement);
1295
+ return [parseComplete()];
1296
+ }
1297
+ async bindMessage(m) {
1298
+ const statement = this.statements.get(m.statement);
1299
+ if (statement === undefined)
1300
+ throw new PgError({ code: "26000", message: `prepared statement "${m.statement}" does not exist` });
1301
+ if (m.portal !== "" && this.portals.has(m.portal))
1302
+ throw new PgError({ code: "42P03", message: `cursor "${m.portal}" already exists` });
1303
+ if (statement.catalogName !== null) {
1304
+ // bound, described, run whole and closed in one call, Sync included (the Portal's `catalog`): nothing of this
1305
+ // session's is left open in the sidecar for another session's Sync to close
1306
+ const catalogName = this.catalogName("P", m.portal);
1307
+ const values = m.params.map((p) => p === null ? int32(-1) : Buffer.concat([int32(p.length), p]));
1308
+ const reply = await this.catalogRun(frame("B", cstr(catalogName), cstr(statement.catalogName), int16(m.paramFormats.length), ...m.paramFormats.map(int16), int16(m.params.length), ...values, int16(m.resultFormats.length), ...m.resultFormats.map(int16)), frame("D", Buffer.from("P"), cstr(catalogName)), frame("E", cstr(catalogName), int32(0)), frame("C", Buffer.from("P"), cstr(catalogName)), frame("S"));
1309
+ // BindComplete, RowDescription | NoData, DataRow…, CommandComplete | EmptyQueryResponse, CloseComplete
1310
+ const [bound, description] = [reply[0], reply[1]];
1311
+ const done = reply[reply.length - 2];
1312
+ if (bound?.[0] !== 0x32 || description === undefined || done === undefined || reply[reply.length - 1]?.[0] !== 0x33) {
1313
+ throw new PgError({ code: "XX000", message: `the catalog Postgres answered a portal out of step (${reply.map((f) => String.fromCharCode(f[0])).join("")}): report it` }, 500);
1314
+ }
1315
+ this.dropPortal(m.portal);
1316
+ const rows = reply.slice(2, -2);
1317
+ this.portals.set(m.portal, { statement, params: [], boundAt: this.server.now(), resultFormats: m.resultFormats, rows: null, sent: 0, fields: null,
1318
+ catalog: { description, rows: statement.explainCatalog === undefined ? rows : catalogExplained(rows, statement.explainCatalog), sent: 0, done } });
1319
+ return [bound];
1320
+ }
1321
+ const planned = statement.duck ?? statement.explain?.plan;
1322
+ const expected = planned !== undefined ? planned.params.length : 0;
1323
+ if (m.params.length !== expected)
1324
+ throw new PgError({ code: "08P01", message: `bind message supplies ${m.params.length} parameters, but prepared statement "${m.statement}" requires ${expected}` });
1325
+ const params = m.params.map((p, i) => {
1326
+ if (p === null)
1327
+ return null;
1328
+ const format = m.paramFormats.length === 0 ? 0 : m.paramFormats.length === 1 ? m.paramFormats[0] : m.paramFormats[i] ?? 0;
1329
+ if (format === 0)
1330
+ return p.toString("utf8");
1331
+ const type = oidOf(statement.paramOids[i] ?? 0) ?? (planned !== undefined ? oidOf(planned.params[i] ?? 0) : undefined) ?? "text";
1332
+ return decodeBinaryParam(type, p, i);
1333
+ });
1334
+ // the Bind is the statement's instant, and begins the implicit transaction when none is open
1335
+ const boundAt = this.server.now();
1336
+ this.txNow ??= boundAt;
1337
+ this.dropPortal(m.portal);
1338
+ this.portals.set(m.portal, { statement, params, boundAt, resultFormats: m.resultFormats, rows: null, sent: 0, fields: null });
1339
+ return [bindComplete()];
1340
+ }
1341
+ async describeMessage(m) {
1342
+ if (m.kind === "S") {
1343
+ const statement = this.statements.get(m.name);
1344
+ if (statement === undefined)
1345
+ throw new PgError({ code: "26000", message: `prepared statement "${m.name}" does not exist` });
1346
+ if (statement.catalogName !== null)
1347
+ return this.catalogRun(frame("D", Buffer.from("S"), cstr(statement.catalogName)));
1348
+ const shape = statement.shape;
1349
+ if (shape?.kind === "show")
1350
+ return [parameterDescription([]), ...await this.showDescription(shape.name)];
1351
+ if (statement.explain !== undefined) {
1352
+ const e = statement.explain;
1353
+ return [parameterDescription(e.plan.params.map((o, i) => { const given = statement.paramOids[i] ?? 0; return given !== 0 ? given : o; })), rowDescription([explainField(e.options, 0)])];
1354
+ }
1355
+ if (statement.duck !== undefined) {
1356
+ const d = statement.duck;
1357
+ const oids = d.params.map((o, i) => { const given = statement.paramOids[i] ?? 0; return given !== 0 ? given : o; });
1358
+ return [parameterDescription(oids), d.fields !== null && d.fields.length ? rowDescription(d.fields.map((c) => ({ name: c.name, typeOid: c.oid, typeSize: c.size, typeModifier: c.typmod, format: 0 }))) : noData()];
1359
+ }
1360
+ return [parameterDescription([]), noData()];
1361
+ }
1362
+ const portal = this.portals.get(m.name);
1363
+ if (portal === undefined)
1364
+ throw new PgError({ code: "34000", message: `portal "${m.name}" does not exist` });
1365
+ if (portal.catalog !== undefined)
1366
+ return [portal.catalog.description];
1367
+ const shape = portal.statement.shape;
1368
+ if (shape?.kind === "show")
1369
+ return this.showDescription(shape.name);
1370
+ if (portal.statement.explain !== undefined)
1371
+ return [rowDescription([explainField(portal.statement.explain.options, formatAt(portal.resultFormats, 0))])];
1372
+ const d = portal.statement.duck;
1373
+ if (d !== undefined)
1374
+ return [d.fields !== null && d.fields.length ? rowDescription(d.fields.map((c, i) => ({ name: c.name, typeOid: c.oid, typeSize: c.size, typeModifier: c.typmod, format: formatAt(portal.resultFormats, i) }))) : noData()];
1375
+ return [noData()];
1376
+ }
1377
+ async executeMessage(m) {
1378
+ const portal = this.portals.get(m.portal);
1379
+ if (portal === undefined)
1380
+ throw new PgError({ code: "34000", message: `portal "${m.portal}" does not exist` });
1381
+ if (portal.catalog !== undefined) {
1382
+ const c = portal.catalog;
1383
+ const take = m.maxRows > 0 ? Math.min(m.maxRows, c.rows.length - c.sent) : c.rows.length - c.sent;
1384
+ const out = c.rows.slice(c.sent, c.sent + take);
1385
+ c.sent += take;
1386
+ return c.sent < c.rows.length ? [...out, portalSuspended()] : [...out, c.done];
1387
+ }
1388
+ const statement = portal.statement;
1389
+ if (statement.raw?.stmt === undefined)
1390
+ return [emptyQueryResponse()];
1391
+ const t = statement.shape ?? this.classify(statement.raw.stmt, statementText(statement.sql, statement.raw));
1392
+ if (this.tx === "failed" && !(t.kind === "transaction" && t.tag !== "BEGIN" && t.tag !== "START TRANSACTION")) {
1393
+ throw new PgError({ code: "25P02", message: "current transaction is aborted, commands ignored until end of transaction block" });
1394
+ }
1395
+ switch (t.kind) {
1396
+ case "empty": return [emptyQueryResponse()];
1397
+ case "catalog": throw new PgError({ code: "XX000", message: "a catalog statement bound as a query (Mason bug: report it)" }, 500);
1398
+ case "set": return this.setStatement(t);
1399
+ // as Postgres: the extended protocol's Execute sends the rows and the tag, the RowDescription is the Describe's
1400
+ case "show": return (await this.showStatement(t.name)).filter((f) => f[0] !== ROW_DESCRIPTION);
1401
+ case "transaction": return this.transaction(t.tag);
1402
+ case "discard":
1403
+ this.statements.clear();
1404
+ this.dropPortals();
1405
+ return [commandComplete("DISCARD ALL")];
1406
+ case "deallocate":
1407
+ this.statements.clear();
1408
+ return [commandComplete("DEALLOCATE ALL")];
1409
+ case "duckdb": {
1410
+ const controller = portal.cursor?.controller ?? new AbortController();
1411
+ const disarm = this.armTimeout(controller);
1412
+ try {
1413
+ return await this.executeDuck(m, portal, statement, controller);
1414
+ }
1415
+ finally {
1416
+ disarm();
1417
+ }
1418
+ }
1419
+ case "explain": {
1420
+ if (statement.explain === undefined)
1421
+ throw new PgError({ code: "XX000", message: "an EXPLAIN without its plan: report it (Mason's Postgres listener)" });
1422
+ if (portal.rows === null) {
1423
+ const controller = new AbortController();
1424
+ const disarm = this.armTimeout(controller);
1425
+ try {
1426
+ const exec = this.txNow ??= this.server.now();
1427
+ portal.rows = (await this.duckExplain(statement.explain, portal.params, portal.resultFormats, { parse: statement.parsedNow ?? exec, exec }, controller)).rows;
1428
+ portal.sent = 0;
1429
+ }
1430
+ finally {
1431
+ disarm();
1432
+ }
1433
+ }
1434
+ const rows = portal.rows;
1435
+ const take = m.maxRows > 0 ? Math.min(m.maxRows, rows.length - portal.sent) : rows.length - portal.sent;
1436
+ const out = rows.slice(portal.sent, portal.sent + take);
1437
+ portal.sent += take;
1438
+ if (portal.sent < rows.length)
1439
+ return [...out, portalSuspended()];
1440
+ this.recordRequest({ rows: rows.length });
1441
+ return [...out, commandComplete("EXPLAIN")];
1442
+ }
1443
+ }
1444
+ }
1445
+ /** the statement being answered reads these JSON paths: its request log row says so, answered or failed */
1446
+ readsOf(plan) {
1447
+ if (this.asked !== null && plan.jsonReads !== undefined)
1448
+ this.asked.jsonReads = plan.jsonReads;
1449
+ }
1450
+ /** an Execute of a DuckDB statement's portal: its cursor begun at the first, then paged or streamed */
1451
+ async executeDuck(m, portal, statement, controller) {
1452
+ // each Execute of a paged portal is a message of its own: the last one's row in the request log carries the reads
1453
+ if (statement.duck !== undefined)
1454
+ this.readsOf(statement.duck);
1455
+ if (portal.cursor === undefined) {
1456
+ if (statement.duck === undefined)
1457
+ throw new PgError({ code: "XX000", message: "a DuckDB statement without its plan: report it (Mason's Postgres listener)" });
1458
+ // a row count on the wire bounds the rows: the plan made for it where it differs (boundOnWire)
1459
+ const plan = m.maxRows > 0 ? await this.boundOnWire(statement, statement.duck, controller) : statement.duck;
1460
+ const exec = this.txNow ??= this.server.now();
1461
+ const answer = await this.duckAnswer(plan, portal.params, portal.resultFormats, { parse: statement.parsedNow ?? exec, exec }, controller);
1462
+ portal.cursor = answer.cursor;
1463
+ portal.fields = answer.fields;
1464
+ }
1465
+ const c = portal.cursor;
1466
+ if (c.failed !== undefined)
1467
+ throw c.failed;
1468
+ c.suspended = false;
1469
+ try {
1470
+ // every row: streamed
1471
+ if (m.maxRows <= 0) {
1472
+ try {
1473
+ await this.streamRest(c);
1474
+ }
1475
+ finally {
1476
+ c.answer.close();
1477
+ }
1478
+ c.recorded = true;
1479
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt });
1480
+ return [commandComplete(`SELECT ${c.sent}`)];
1481
+ }
1482
+ // at most maxRows (JDBC's fetch size, a cursor): suspended while a row is left, read one chunk ahead to know it
1483
+ while (!c.done && c.pending.length <= m.maxRows)
1484
+ await this.fill(c);
1485
+ const out = c.pending.splice(0, m.maxRows);
1486
+ c.sent += out.length;
1487
+ // suspended: its row is written at its last Execute, or when it is dropped (dropPortal)
1488
+ if (c.pending.length > 0) {
1489
+ c.suspended = true;
1490
+ return [...out, portalSuspended()];
1491
+ }
1492
+ c.answer.close();
1493
+ c.recorded = true;
1494
+ this.recordRequest({ rows: c.sent, startedAt: c.startedAt });
1495
+ return [...out, commandComplete(`SELECT ${c.sent}`)];
1496
+ }
1497
+ catch (e) {
1498
+ // its refusal is its row (refuse)
1499
+ c.recorded = true;
1500
+ throw e;
1501
+ }
1502
+ }
1503
+ async closeMessage(m) {
1504
+ if (m.kind === "S") {
1505
+ const s = this.statements.get(m.name);
1506
+ this.statements.delete(m.name);
1507
+ if (s?.catalogName !== null && s !== undefined)
1508
+ return this.catalogRun(frame("C", Buffer.from("S"), cstr(s.catalogName)));
1509
+ }
1510
+ else {
1511
+ this.dropPortal(m.name);
1512
+ }
1513
+ return [closeComplete()];
1514
+ }
1515
+ // ---------------------------------------------------------------- a query
1516
+ /**
1517
+ * A `mason user` is asked again while its session lasts (`recheckMs`, DEFAULT_RECHECK_MS by default): removed, the
1518
+ * session ends FATAL with the reason, as a new connection would be refused; a list of users that cannot be read
1519
+ * refuses this statement only (recorded by `refuse`).
1520
+ */
1521
+ async stillLive(asked) {
1522
+ const recheck = this.server.options.recheck;
1523
+ const user = this.token?.user;
1524
+ if (recheck === undefined || user === undefined)
1525
+ return null;
1526
+ const now = this.server.now();
1527
+ if (now - this.verifiedAt < (this.server.options.recheckMs ?? DEFAULT_RECHECK_MS))
1528
+ return null;
1529
+ try {
1530
+ await recheck(this.token);
1531
+ }
1532
+ catch (err) {
1533
+ const message = err instanceof Error ? err.message : String(err);
1534
+ // could not tell: this statement is refused, the session kept, and the next statement asks again
1535
+ if (err instanceof ApiError && err.status === 503)
1536
+ return new PgError({ code: "57P03", message, hint: "run the statement again once Mason can read its list of users" }, 503);
1537
+ if (err instanceof ApiError)
1538
+ this.recordRefusal(asked.toUpperCase(), err);
1539
+ this.fatal("28000", `the session's user is no longer accepted: ${message}`, `a removed user's sessions end at their next statement; mason user add ${user} makes it again, with a new password`);
1540
+ return null;
1541
+ }
1542
+ this.verifiedAt = now;
1543
+ return null;
1544
+ }
1545
+ /** A removed user's session ended, as a row of the request log: who, when, why, and the statement it was ended at. */
1546
+ recordRefusal(method, err) {
1547
+ const user = err.userName ?? this.token?.user;
1548
+ const stored = this.asked !== null ? storedPayload(this.asked.sql) : null;
1549
+ this.server.log(`session ${this.id}: ${method} refused for the user ${JSON.stringify(user ?? "")}: ${err.message}`);
1550
+ this.server.options.record({
1551
+ requestId: crypto.randomUUID(), at: new Date(), method, status: err.status, durationMs: 0,
1552
+ client: `postgres:${this.vars.applicationName || "unnamed"}`, caller: hashCaller(this.presented),
1553
+ error: err.message, sqlstate: "28000", session: this.id, ...(stored !== null ? { payload: stored.text, payloadTruncated: stored.truncated } : {}), ...(user !== undefined ? { user } : {}),
1554
+ });
1555
+ }
1556
+ }
1557
+ /** The result format of column `i`: none given is text, one given applies to all, else each its own. */
1558
+ const formatAt = (formats, i) => (formats.length === 0 ? 0 : formats.length === 1 ? formats[0] : formats[i] ?? 0) === 1 ? 1 : 0;
1559
+ const isUtf8 = (encoding) => /^(utf-?8|unicode)$/i.test(encoding.trim());
1560
+ const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
1561
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
1562
+ /** the text of one statement of a simple query, from the parser's byte offsets */
1563
+ function statementText(sql, raw) {
1564
+ const bytes = Buffer.from(sql, "utf8");
1565
+ const from = raw.stmt_location ?? 0;
1566
+ const len = raw.stmt_len ?? 0;
1567
+ return bytes.subarray(from, len === 0 ? bytes.length : from + len).toString("utf8").trim();
1568
+ }
1569
+ const oidOf = (oid) => Object.keys(OID).find((t) => OID[t].oid === oid);
1570
+ const PG_EPOCH_MS = Date.UTC(2000, 0, 1);
1571
+ /** A binary-format parameter, as text Postgres's input function reads (DuckOptions.parameters). */
1572
+ function decodeBinaryParam(type, bytes, i) {
1573
+ const need = (n) => {
1574
+ if (bytes.length !== n)
1575
+ throw new PgError({ code: "22P03", message: `incorrect binary data format in bind parameter ${i + 1}: ${bytes.length} byte(s) for ${type}` });
1576
+ };
1577
+ switch (type) {
1578
+ case "int2":
1579
+ need(2);
1580
+ return String(bytes.readInt16BE(0));
1581
+ case "int4":
1582
+ need(4);
1583
+ return String(bytes.readInt32BE(0));
1584
+ case "int8":
1585
+ need(8);
1586
+ return bytes.readBigInt64BE(0).toString();
1587
+ case "float4":
1588
+ need(4);
1589
+ return String(bytes.readFloatBE(0));
1590
+ case "float8":
1591
+ need(8);
1592
+ return String(bytes.readDoubleBE(0));
1593
+ case "numeric": {
1594
+ try {
1595
+ return numericFromBinary(bytes);
1596
+ }
1597
+ catch (err) {
1598
+ // numericFromBinary throws exactly NumericError on bytes that are no numeric: the Bind's 22P03, naming the parameter
1599
+ if (err instanceof NumericError)
1600
+ throw new PgError({ code: err.sqlstate, message: `${err.message} in bind parameter ${i + 1}` });
1601
+ throw err;
1602
+ }
1603
+ }
1604
+ case "bool":
1605
+ need(1);
1606
+ return bytes[0] === 0 ? "f" : "t";
1607
+ case "uuid": {
1608
+ need(16);
1609
+ const h = bytes.toString("hex");
1610
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1611
+ }
1612
+ case "date": {
1613
+ need(4);
1614
+ return new Date(PG_EPOCH_MS + bytes.readInt32BE(0) * 86_400_000).toISOString().slice(0, 10);
1615
+ }
1616
+ case "timestamptz": {
1617
+ need(8);
1618
+ const micros = bytes.readBigInt64BE(0);
1619
+ const ms = Number(micros / 1000n) + PG_EPOCH_MS;
1620
+ const rest = Number(micros % 1000n);
1621
+ return new Date(ms).toISOString().replace("Z", `${rest ? String(rest).padStart(3, "0") : ""}Z`);
1622
+ }
1623
+ case "text": return bytes.toString("utf8");
1624
+ case "jsonb": return bytes.subarray(bytes[0] === 1 ? 1 : 0).toString("utf8");
1625
+ }
1626
+ }
1627
+ /** The catalog Postgres's ErrorResponse, as this session's: its fields, less the source-file ones. */
1628
+ function relayedError(frame) {
1629
+ const fields = decodeNoticeFrame(frame);
1630
+ return new PgError({
1631
+ severity: fields.V ?? fields.S ?? "ERROR", code: fields.C ?? "XX000", message: fields.M ?? "the catalog Postgres refused the statement",
1632
+ detail: fields.D, hint: fields.H, position: fields.P !== undefined ? Number(fields.P) : undefined,
1633
+ });
1634
+ }
1635
+ // ---------------------------------------------------------------------------------------------------------------------
1636
+ // the listener
1637
+ class Server {
1638
+ sessions = new Map();
1639
+ /** the DuckDB plans by text, search_path, TimeZone, declared parameter types and an Execute's bound (Session.boundOnWire), for
1640
+ * the schema they were made under */
1641
+ duckPlans = new Map();
1642
+ duckPlansOf = null;
1643
+ duckPlansAt = 0;
1644
+ /** the source's OIDs for the types an extension made, by name (a copied citext column's), from the schema */
1645
+ userTypes() {
1646
+ const out = new Map();
1647
+ for (const t of this.options.schema().tables) {
1648
+ // a table's row type, by its schema and name (src/duck/plan.ts names the catalog's alike)
1649
+ if (t.rowTypeOid !== undefined)
1650
+ out.set(`rowtype:${t.schema ?? "public"}.${t.name}`, t.rowTypeOid);
1651
+ }
1652
+ for (const t of this.options.schema().tables)
1653
+ for (const c of t.columns) {
1654
+ if (c.typeOid === undefined || c.spelled === undefined)
1655
+ continue;
1656
+ // the type's name as pg_type holds it (`other.level` is `level`, `"My Mood"` is `My Mood`)
1657
+ const name = typeNameParts(c.spelled.replace(/\[\]$/, "")).name;
1658
+ // the element type and its array type, as pg_type names them (`citext`, `_citext`); an array column's own OID is
1659
+ // its array type's (pg_attribute.atttypid), and it has none of its own (an array type's typarray is 0)
1660
+ if (c.spelled.endsWith("[]"))
1661
+ out.set(`_${name}`, c.typeOid);
1662
+ else {
1663
+ out.set(name, c.typeOid);
1664
+ if (c.typeArrayOid !== undefined)
1665
+ out.set(`_${name}`, c.typeArrayOid);
1666
+ }
1667
+ }
1668
+ return out;
1669
+ }
1670
+ /**
1671
+ * EVERY PLAN IS MADE OVER THE CATALOG OF THE SCHEMA IT IS KEPT UNDER: the catalog is replaced here (`schema`), not
1672
+ * only at a statement's classify, because a plan is also made at an Execute (Session.boundOnWire) — a re-plan that
1673
+ * read the schema raw analysed against the old catalog and was kept under the new one (a review finding of #23).
1674
+ * The ordering relied on: `schema()` calls `catalog.replace` synchronously, which queues the old instance's close on
1675
+ * the catalog's one chain (src/api/postgres/catalog.ts "SERIALISED") before `make` queues its analysis, so the
1676
+ * analysis starts the instance with the new DDL. A plan whose schema or generation moved while it was made is
1677
+ * returned to its caller and not kept.
1678
+ */
1679
+ async duckPlan(text, paramOids, searchPath, timeZone, wireBound, make) {
1680
+ const schema = this.schema();
1681
+ const generation = this.options.duck?.generation?.() ?? 0;
1682
+ if (schema !== this.duckPlansOf || generation !== this.duckPlansAt) {
1683
+ this.duckPlans.clear();
1684
+ this.duckPlansOf = schema;
1685
+ this.duckPlansAt = generation;
1686
+ }
1687
+ // the zone too: a plan holds the values Postgres parsed its literals to, under the session's zone
1688
+ // and the bound: an Execute's row count plans a LEFT lateral of a list otherwise (Session.boundOnWire), kept beside
1689
+ const key = `${searchPath}\u0000${timeZone}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${text}`;
1690
+ const hit = this.duckPlans.get(key);
1691
+ if (hit !== undefined)
1692
+ return hit;
1693
+ const plan = await make();
1694
+ if (this.duckPlansOf !== schema || this.duckPlansAt !== generation)
1695
+ return plan;
1696
+ // bounded: a tool that inlines its values sends a new text per value
1697
+ if (this.duckPlans.size >= 2000)
1698
+ this.duckPlans.delete(this.duckPlans.keys().next().value);
1699
+ this.duckPlans.set(key, plan);
1700
+ return plan;
1701
+ }
1702
+ tls;
1703
+ catalog;
1704
+ lastSchema;
1705
+ options;
1706
+ constructor(options) {
1707
+ this.options = options;
1708
+ this.tls = options.tls === null ? null : tls.createSecureContext({ cert: options.tls.cert, key: options.tls.key });
1709
+ this.lastSchema = options.schema();
1710
+ const version = versionText(options.product ?? "Mason", options.masonVersion);
1711
+ this.catalog = new PgCatalog(this.lastSchema, {
1712
+ log: (line) => this.log(line),
1713
+ setup: [
1714
+ // `version()` says whose Postgres this is; search_path lists pg_catalog AFTER public so the override wins
1715
+ `CREATE OR REPLACE FUNCTION public.version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '${version.replaceAll("'", "''")}' $$`,
1716
+ // `current_setting('ssl')` says what every session of this listener is (tlsParameters), as SHOW does
1717
+ ...currentSettingSql(tlsParameters(options.tls !== null)),
1718
+ "SET search_path = public, pg_catalog",
1719
+ ...databaseNameSql(options.database),
1720
+ ],
1721
+ ...(options.catalogStatementMs !== undefined ? { statementMs: options.catalogStatementMs } : {}),
1722
+ ...(options.trace === true ? { trace: true } : {}),
1723
+ });
1724
+ }
1725
+ get database() { return this.options.database; }
1726
+ /** the clock "now" is read from */
1727
+ now() { return (this.options.clock ?? Date.now)(); }
1728
+ log(line) { (this.options.log ?? ((l) => console.log(l)))(`[postgres] ${line}`); }
1729
+ /** the schema as of now; the catalog Postgres is replaced when it changed */
1730
+ schema() {
1731
+ const s = this.options.schema();
1732
+ if (s !== this.lastSchema) {
1733
+ this.lastSchema = s;
1734
+ void this.catalog.replace(s).catch((e) => this.log(`replacing the catalog failed: ${e instanceof Error ? e.message : String(e)}`));
1735
+ }
1736
+ return s;
1737
+ }
1738
+ forget(s) { this.sessions.delete(s.id); }
1739
+ cancel(pid, secret) {
1740
+ const s = this.sessions.get(pid);
1741
+ if (s === undefined || s.secret !== secret)
1742
+ return; // as Postgres: a wrong key is ignored, never answered
1743
+ s.running?.abort();
1744
+ }
1745
+ }
1746
+ export async function startPostgresApi(options) {
1747
+ const server = new Server(options);
1748
+ const listener = net.createServer((socket) => {
1749
+ socket.setNoDelay(true);
1750
+ const session = new Session(socket, server);
1751
+ server.sessions.set(session.id, session);
1752
+ });
1753
+ await new Promise((resolve, reject) => {
1754
+ listener.once("error", reject);
1755
+ listener.listen(options.port, options.host, () => { listener.off("error", reject); resolve(); });
1756
+ });
1757
+ listener.on("error", (err) => server.log(`listener error: ${err.message}`));
1758
+ const address = listener.address();
1759
+ const port = typeof address === "object" && address !== null ? address.port : options.port;
1760
+ return {
1761
+ port,
1762
+ get sessions() { return server.sessions.size; },
1763
+ async close() {
1764
+ for (const s of [...server.sessions.values()])
1765
+ s.end("server closing");
1766
+ await new Promise((resolve) => listener.close(() => resolve()));
1767
+ await server.catalog.stop();
1768
+ await stopParser();
1769
+ },
1770
+ };
1771
+ }