@mason-data/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +13 -4
  2. package/dist/src/api/postgres/catalog-worker.js +49 -2
  3. package/dist/src/api/postgres/catalog.js +166 -32
  4. package/dist/src/api/postgres/listener.js +11 -3
  5. package/dist/src/api/postgres/proxy.js +283 -0
  6. package/dist/src/api/postgres/schema.js +6 -0
  7. package/dist/src/api/postgres/server.js +505 -99
  8. package/dist/src/api/postgres/translate.js +47 -2
  9. package/dist/src/cli/background.js +18 -19
  10. package/dist/src/cli/bridge-stdin.js +101 -0
  11. package/dist/src/cli/command.js +169 -11
  12. package/dist/src/cli/commands.js +207 -51
  13. package/dist/src/cli/credentials.js +182 -0
  14. package/dist/src/cli/demo.js +2 -2
  15. package/dist/src/cli/deploy.js +574 -0
  16. package/dist/src/cli/dev.js +148 -27
  17. package/dist/src/cli/doctor.js +116 -33
  18. package/dist/src/cli/generated-files.js +143 -0
  19. package/dist/src/cli/guides.js +97 -6
  20. package/dist/src/cli/hosted.js +850 -0
  21. package/dist/src/cli/init.js +47 -11
  22. package/dist/src/cli/login.js +289 -0
  23. package/dist/src/cli/machine.js +1 -1
  24. package/dist/src/cli/main.js +8 -2
  25. package/dist/src/cli/mason.cjs +17 -0
  26. package/dist/src/cli/mcp.js +2 -2
  27. package/dist/src/cli/output-gate.js +176 -0
  28. package/dist/src/cli/page-share.js +96 -0
  29. package/dist/src/cli/pg-tls.js +1 -1
  30. package/dist/src/cli/pin.js +6 -2
  31. package/dist/src/cli/pool-size.cjs +85 -0
  32. package/dist/src/cli/ports.js +2 -0
  33. package/dist/src/cli/preflight.js +130 -39
  34. package/dist/src/cli/project.js +99 -0
  35. package/dist/src/cli/readiness.js +42 -6
  36. package/dist/src/cli/registry.js +12 -4
  37. package/dist/src/cli/replica-hosted.js +665 -0
  38. package/dist/src/cli/replica.js +76 -0
  39. package/dist/src/cli/roles.js +2 -1
  40. package/dist/src/cli/run-server.js +154 -7
  41. package/dist/src/cli/security.js +57 -10
  42. package/dist/src/cli/serve.js +63 -7
  43. package/dist/src/cli/share-run.js +51 -8
  44. package/dist/src/cli/share.js +3 -3
  45. package/dist/src/cli/source-edit.js +10 -4
  46. package/dist/src/cli/source-role.js +344 -0
  47. package/dist/src/cli/sources.js +30 -20
  48. package/dist/src/cli/style.js +2 -1
  49. package/dist/src/cli/supervise.js +15 -23
  50. package/dist/src/cli/token-hosted.js +337 -0
  51. package/dist/src/cli/ui-ssh.js +460 -0
  52. package/dist/src/cli/ui.js +115 -11
  53. package/dist/src/cli/user-hosted.js +299 -0
  54. package/dist/src/cli/user.js +126 -74
  55. package/dist/src/demo/seed.js +26 -0
  56. package/dist/src/duck/analyze.js +36 -20
  57. package/dist/src/duck/collation.js +266 -0
  58. package/dist/src/duck/constant-key.js +94 -0
  59. package/dist/src/duck/distinct-key.js +283 -26
  60. package/dist/src/duck/events.js +39 -8
  61. package/dist/src/duck/executor-worker.js +2 -2
  62. package/dist/src/duck/executor.js +6 -5
  63. package/dist/src/duck/explain.js +4 -0
  64. package/dist/src/duck/functions.js +448 -49
  65. package/dist/src/duck/gaps.js +34 -16
  66. package/dist/src/duck/generated-plan.js +1070 -0
  67. package/dist/src/duck/generated.js +906 -0
  68. package/dist/src/duck/grants.js +1 -1
  69. package/dist/src/duck/held-types.js +64 -0
  70. package/dist/src/duck/history.js +4 -0
  71. package/dist/src/duck/jsonb-order.js +9 -12
  72. package/dist/src/duck/kept-route.js +6 -5
  73. package/dist/src/duck/kept.js +9 -7
  74. package/dist/src/duck/late.js +1469 -40
  75. package/dist/src/duck/libc-bound.js +341 -0
  76. package/dist/src/duck/libc-order.js +892 -126
  77. package/dist/src/duck/pg-eval-worker.js +7 -1
  78. package/dist/src/duck/pg-eval.js +6 -4
  79. package/dist/src/duck/pgtree.js +774 -23
  80. package/dist/src/duck/plan.js +275 -30
  81. package/dist/src/duck/publication.js +14 -0
  82. package/dist/src/duck/requests.js +9 -5
  83. package/dist/src/duck/resolved.js +117 -2
  84. package/dist/src/duck/rewrite.js +4567 -583
  85. package/dist/src/duck/row-filter.js +12 -6
  86. package/dist/src/duck/statement.js +19 -0
  87. package/dist/src/duck/store.js +294 -68
  88. package/dist/src/duck/stream.js +9 -5
  89. package/dist/src/duck/sync.js +602 -126
  90. package/dist/src/duck/token.js +4 -1
  91. package/dist/src/duck/types.js +63 -7
  92. package/dist/src/duck/usage.js +6 -4
  93. package/dist/src/duck/users.js +5 -0
  94. package/dist/src/duck/verified.js +159 -2
  95. package/dist/src/duck/views.js +4 -3
  96. package/dist/src/lib/database-url.js +55 -10
  97. package/dist/src/lib/instant.js +50 -0
  98. package/dist/src/lib/log-buffer.js +13 -4
  99. package/dist/src/lib/log-read.js +196 -0
  100. package/dist/src/lib/pg-numeric.js +387 -49
  101. package/dist/src/lib/secret-mask.js +142 -0
  102. package/dist/src/lib/table-sync.js +24 -9
  103. package/dist/src/web/app.js +9 -4
  104. package/dist/src/web/pages/status.js +178 -13
  105. package/dist/src/web/pages/syncs.js +1 -1
  106. package/dist/src/web/pages/tables.js +8 -3
  107. package/dist/src/web/pages/users.js +4 -3
  108. package/dist/src/web/shared.js +19 -3
  109. package/package.json +8 -7
@@ -3,20 +3,23 @@ import { explainedText, explainOptions } from "./explain.js";
3
3
  import * as crypto from "node:crypto";
4
4
  import * as net from "node:net";
5
5
  import * as tls from "node:tls";
6
+ import { catalogRows } from "../../duck/analyze.js";
6
7
  import { ApiError, USER_DISABLED } from "../error.js";
7
8
  import { hashCaller, outcomeOf, storedPayload } from "../request-log.js";
8
- import { gapMark } from "../../duck/gaps.js";
9
+ import { gapRefusal, StatementError } from "../../duck/statement.js";
9
10
  import { canonicalZone } from "../../lib/timezone.js";
10
11
  import { catalogRolesOf, exampleRole, grantRefusal, NO_ROLE_CATALOG, NO_ROLES } from "../../duck/grants.js";
11
- import { asRole, asSession, catalogPolicies, currentSettingSql, databaseNameSql, PgCatalog, unwrapRole, unwrapSession } from "./catalog.js";
12
+ import { asRole, asSession, CATALOG_BASE_SQL, catalogPolicies, currentSettingSql, databaseNameSql, LATEST_SQL, PgCatalog, unwrapRole, unwrapSession } from "./catalog.js";
12
13
  import { wrongDatabaseHint } from "../../lib/database-name.js";
13
14
  import { attributeSetting, bindFilter, filterProbe, filterTypes, missingAttributes } from "../../duck/row-filter.js";
14
15
  import { EncodeError, OID, fieldOf } from "./encode.js";
15
16
  import { numericFromBinary, NumericError } from "../../lib/pg-numeric.js";
17
+ import { instantText, nowMicros } from "../../lib/instant.js";
16
18
  import { parseSql, PostgresSyntaxError, stopParser } from "./parser.js";
17
19
  import { FrontendDecoder, ProtocolError, authenticationCleartextPassword, authenticationOk, backendKeyData, bindComplete, closeComplete, commandComplete, dataRow, emptyQueryResponse, errorResponse, noData, noticeResponse, parameterDescription, parameterStatus, parseComplete, portalSuspended, readyForQuery, rowDescription, sslResponse, cstr, int16, int32, frame, decodeBackend, decodeNoticeFrame, splitFrames, MAX_MESSAGE_LENGTH, } from "./protocol.js";
18
- import { typeNameParts } from "./schema.js";
20
+ import { madeTypeName, typeNameParts } from "./schema.js";
19
21
  import { Unsupported, duckTranslate, endsFailedBlock } from "./translate.js";
22
+ import { clientAddress, PROXY_HEADER_MS, PROXY_SETTING, proxyNames, readProxyHeader, startsProxyHeader } from "./proxy.js";
20
23
  /** what an EXPLAIN of a catalog statement says first: whose plan it is */
21
24
  const CATALOG_EXPLAINED = "Mason: answered by the catalog Postgres (Mason's own, PGlite), not the synced data: a statement of pg_catalog or information_schema, or of no table, is the catalog's, and this is its plan";
22
25
  /** an EXPLAIN's one column, Postgres's: text, or json for FORMAT JSON (ExplainResultDesc) */
@@ -88,24 +91,6 @@ export function timeoutMs(value) {
88
91
  const per = { us: 0.001, ms: 1, s: 1000, min: 60_000, h: 3_600_000, d: 86_400_000 }[(m[2] ?? "ms").toLowerCase()];
89
92
  return Math.round(Number(m[1]) * per);
90
93
  }
91
- /** A statement's error as Postgres raises it, from outside this module (the DuckDB runner's): the fields, and the SQL
92
- * its position is in. */
93
- export class StatementError extends Error {
94
- fields;
95
- sql;
96
- /** the registry entry (src/duck/gaps.ts) that refused the statement, as the refusal was built with it (`gapRefusal`,
97
- * src/duck/store.ts pgErrorOf): what the request log records as `gap`; null for every other error */
98
- gap;
99
- constructor(fields, sql = "", gap = null) { super(fields.message); this.fields = fields; this.sql = sql; this.gap = gap; }
100
- }
101
- /**
102
- * A REFUSAL BY A REGISTRY ENTRY (src/duck/gaps.ts), the one way one is built: 0A000, `Mason gap <id>` in its DETAIL (what
103
- * the oracle and a reader read), followed by `detail` (docs/SQL-SUPPORT.md, or what it names), and the id itself on the
104
- * error, so the request log records which entry refused it without reading the text back.
105
- */
106
- export function gapRefusal(gap, message, hint, sql = "", detail = " (docs/SQL-SUPPORT.md)") {
107
- return new StatementError({ code: "0A000", message, hint, detail: `${gapMark(gap)}${detail}` }, sql, gap);
108
- }
109
94
  export const SERVER_VERSION = "18.6";
110
95
  export const SERVER_VERSION_NUM = "180006";
111
96
  /** `version()`'s whole text: `PostgreSQL 18.6` FIRST, since tools parse it (as Redshift and YugabyteDB keep it), then
@@ -204,7 +189,7 @@ function asPgError(err, sql) {
204
189
  return new PgError({ code: sqlstateOf(err.status, err.code), message: err.message, detail: `Mason: ${err.code}` }, err.status);
205
190
  // Postgres's own error for a numeric (a division by zero, 22012; a field overflow, 22003), raised as Postgres raises it
206
191
  if (err instanceof NumericError)
207
- return new PgError({ code: err.sqlstate, message: err.message }, err.sqlstate === "XX000" ? 500 : 400, err.gap);
192
+ return new PgError({ code: err.sqlstate, message: err.message, ...(err.detail === null ? {} : { detail: err.detail }) }, err.sqlstate === "XX000" ? 500 : 400, err.gap);
208
193
  if (err instanceof EncodeError)
209
194
  return err.code === "XX000"
210
195
  ? new PgError({ code: "XX000", message: err.message, detail: "Mason: a value the column's Postgres type cannot carry; report it" }, 500)
@@ -221,8 +206,10 @@ const REPORTED = new Set(["application_name", "client_encoding", "datestyle", "i
221
206
  /** Postgres's spelling of the parameters it reports, for the ParameterStatus name. */
222
207
  const SPELLING = { datestyle: "DateStyle", intervalstyle: "IntervalStyle", timezone: "TimeZone" };
223
208
  const spell = (name) => SPELLING[name] ?? name;
224
- const defaultVars = () => ({ timeZone: "UTC", applicationName: "", searchPath: '"$user", public', other: new Map() });
209
+ const defaultVars = () => ({ timeZone: "UTC", dateStyle: "ISO, MDY", applicationName: "", searchPath: '"$user", public', other: new Map() });
225
210
  const copyVars = (v) => ({ ...v, other: new Map(v.other) });
211
+ /** a generated table's plan is made under no session: UTC, under `public`, ISO with month-day-year */
212
+ const GENERATED_SESSION = { timeZone: "UTC", searchPath: "public", dateStyle: "ISO, MDY" };
226
213
  /** One parameter's value carried from one set of values to another, from where `setParameter` keeps it. */
227
214
  function copySetting(name, from, to) {
228
215
  switch (name) {
@@ -230,6 +217,9 @@ function copySetting(name, from, to) {
230
217
  case "time zone":
231
218
  to.timeZone = from.timeZone;
232
219
  return;
220
+ case "datestyle":
221
+ to.dateStyle = from.dateStyle;
222
+ return;
233
223
  case "application_name":
234
224
  to.applicationName = from.applicationName;
235
225
  return;
@@ -237,11 +227,24 @@ function copySetting(name, from, to) {
237
227
  to.searchPath = from.searchPath;
238
228
  return;
239
229
  }
240
- const value = from.other.get(name);
230
+ const key = SETTING_KEY[name] ?? name;
231
+ const value = from.other.get(key);
241
232
  if (value === undefined)
242
- to.other.delete(name);
233
+ to.other.delete(key);
243
234
  else
244
- to.other.set(name, value);
235
+ to.other.set(key, value);
236
+ }
237
+ /** the parameter a SET of another name moves: SET TRANSACTION and SET SESSION CHARACTERISTICS are Postgres's spellings of
238
+ * transaction_isolation and default_transaction_isolation (translate.ts setStatement) */
239
+ const SETTING_KEY = { transaction: "transaction_isolation", "session characteristics": "default_transaction_isolation" };
240
+ /** the isolation levels Postgres knows, as SHOW answers them */
241
+ const ISOLATION_LEVELS = ["serializable", "repeatable read", "read committed", "read uncommitted"];
242
+ /** the one refusal of a DateStyle that is Mason's own: an output style other than ISO (its value as the catalog
243
+ * Postgres canonicalised it, `German, DMY`), which this API never writes */
244
+ function isoDateStyle(canonical, value) {
245
+ if (!/^ISO,/.test(canonical))
246
+ throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
247
+ return canonical;
245
248
  }
246
249
  /** A placeholder's name as Postgres's `valid_custom_variable_name` reads it: two or more simple identifiers, dotted. */
247
250
  function placeholderName(name) {
@@ -253,14 +256,18 @@ function placeholderName(name) {
253
256
  const FIXED = new Map([
254
257
  ["server_version", () => SERVER_VERSION], ["server_version_num", () => SERVER_VERSION_NUM],
255
258
  ["server_encoding", () => "UTF8"], ["client_encoding", () => "UTF8"],
256
- ["datestyle", () => "ISO, MDY"], ["intervalstyle", () => "postgres"],
259
+ ["datestyle", (v) => v.dateStyle], ["intervalstyle", () => "postgres"],
257
260
  ["timezone", (v) => v.timeZone], ["application_name", (v) => v.applicationName], ["search_path", (v) => v.searchPath],
258
261
  ...["integer_datetimes", "standard_conforming_strings", "default_transaction_read_only", "transaction_read_only", "in_hot_standby"].map((n) => [n, () => "on"]),
259
262
  ["is_superuser", () => "off"],
260
263
  // the session's user, and no role SET over it (SET ROLE is its own name or NONE, setStatement): Postgres's `none`
261
264
  ["session_authorization", (_v, user) => user], ["role", () => "none"],
262
- ["transaction_isolation", () => "read committed"], ["default_transaction_isolation", () => "read committed"],
263
- ["extra_float_digits", () => "1"], ["max_identifier_length", () => "63"],
265
+ // what SET TRANSACTION and SET SESSION CHARACTERISTICS said, remembered (`setParameter`): nothing is ever written, so
266
+ // every level answers the same rows
267
+ ["transaction_isolation", (v) => v.other.get("transaction_isolation") ?? v.other.get("default_transaction_isolation") ?? "read committed"],
268
+ ["default_transaction_isolation", (v) => v.other.get("default_transaction_isolation") ?? "read committed"],
269
+ // remembered: every float is sent as the shortest text that reads back exactly, Postgres's output at 1 and above
270
+ ["extra_float_digits", (v) => v.other.get("extra_float_digits") ?? "1"], ["max_identifier_length", () => "63"],
264
271
  ]);
265
272
  function fixedParameter(name, vars, user) {
266
273
  return FIXED.get(name)?.(vars, user) ?? null;
@@ -331,11 +338,14 @@ class Session {
331
338
  presented = null;
332
339
  /** the database a public socket's startup named that is not this one: refused once the login is good */
333
340
  wrongDatabase = null;
334
- /** when the user was last found live (a user's session is asked again, `recheckMs`) */
341
+ /** when the user was last found live, in microseconds as the session's clock (a user's session is asked again,
342
+ * `recheckMs`) */
335
343
  verifiedAt = 0;
336
344
  vars = defaultVars();
337
345
  /** the session's parameters as its startup left them (its packet's `application_name` and `options`): what RESET restores */
338
346
  startupVars = defaultVars();
347
+ /** a startup packet's DateStyle, canonicalised by the catalog Postgres once the password is read (`password`) */
348
+ startupDateStyle = null;
339
349
  /** each reported parameter's value as the client was last told it (`reportChanges`) */
340
350
  reported = new Map();
341
351
  tx = "none";
@@ -348,9 +358,10 @@ class Session {
348
358
  /** the statement runs in an IMPLICIT transaction block, as Postgres's IsTransactionBlock reads one: a Query of several
349
359
  * statements, or a Sync's batch once an Execute completed (`extended`). A plain transaction of one statement is none */
350
360
  implicitBlock = false;
351
- /** the instant the transaction began (Postgres's `now()`), null between transactions */
361
+ /** the instant the transaction began (Postgres's `now()`), in microseconds since the epoch (src/lib/instant.ts),
362
+ * null between transactions */
352
363
  txNow = null;
353
- /** the instant of the statement being run (`statement_timestamp()`): its Query's arrival or its Bind */
364
+ /** the instant of the statement being run (`statement_timestamp()`), in microseconds: its Query's arrival or its Bind */
354
365
  statementNow = 0;
355
366
  statements = new Map();
356
367
  portals = new Map();
@@ -387,23 +398,56 @@ class Session {
387
398
  server;
388
399
  /** the socket it arrived on: how it may start, and who may log in */
389
400
  door;
401
+ /**
402
+ * THE CLIENT'S ADDRESS (proxy.ts `clientAddress`, its one reader): the socket's peer, or the source the PROXY header
403
+ * of a proxy MASON_PG_PROXY_FROM names carries, once read; null on a public socket (a share's: its peer is the
404
+ * tunnel's, never the client's) and where the peer could not be read. The request log's `client_addr` and the log's
405
+ * connect line read it.
406
+ */
407
+ clientAddr;
408
+ /** the PROXY header a named proxy's connection begins with, while it is read: its bytes so far and its deadline */
409
+ proxyHead = null;
410
+ /** a named proxy's header was read: a second one is refused as one */
411
+ proxied = false;
390
412
  constructor(socket, server, door) {
391
413
  this.server = server;
392
414
  this.door = door;
393
415
  this.socket = socket;
416
+ this.clientAddr = door.public ? null : clientAddress(socket.remoteAddress, null);
417
+ if (door.proxyFrom !== null) {
418
+ const peer = clientAddress(socket.remoteAddress, null);
419
+ if (peer === null) {
420
+ // fail closed: an address that cannot be read cannot be told from a named proxy's
421
+ this.server.log(`session ${this.id} refused: its address could not be read, so whether ${PROXY_SETTING} names it could not be told`);
422
+ process.nextTick(() => this.end("its address could not be read"));
423
+ }
424
+ else if (proxyNames(door.proxyFrom, peer)) {
425
+ const timer = setTimeout(() => this.proxyRefused(`no whole PROXY protocol v2 header in ${PROXY_HEADER_MS / 1000} s`), PROXY_HEADER_MS);
426
+ timer.unref();
427
+ this.proxyHead = { bytes: Buffer.alloc(0), timer };
428
+ }
429
+ }
394
430
  this.attach(socket);
395
431
  door.logins?.admit(this);
396
432
  }
397
433
  attach(socket) {
398
- socket.on("data", (chunk) => {
399
- // the connection's first byte says how it starts: a TLS ClientHello is TLS at once (`directTls`), anything else
400
- // the protocol's startup phase, SSLRequest included
434
+ socket.on("data", (data) => {
435
+ // a named proxy's PROXY header comes first, before anything else on the connection is looked at
436
+ const chunk = this.proxyHead === null ? data : this.readProxy(data);
437
+ if (chunk === null || chunk.length === 0)
438
+ return;
439
+ // the connection's first byte says how it starts: a TLS ClientHello is TLS at once (`directTls`), a PROXY header
440
+ // where none is believed is refused, anything else the protocol's startup phase, SSLRequest included
401
441
  if (!this.sniffed) {
402
442
  this.sniffed = true;
403
443
  if (chunk[0] === TLS_HANDSHAKE) {
404
444
  this.directTls(chunk);
405
445
  return;
406
446
  }
447
+ if (startsProxyHeader(chunk[0])) {
448
+ this.unbelievedProxy();
449
+ return;
450
+ }
407
451
  }
408
452
  this.decoder.push(chunk);
409
453
  void this.drain();
@@ -429,11 +473,54 @@ class Session {
429
473
  startedAt: new Date(this.asked.startedAt).toISOString(), runningMs: now - this.asked.startedAt, rows: this.asked.rows ?? 0,
430
474
  query: stored.text, queryTruncated: stored.truncated };
431
475
  }
476
+ /**
477
+ * The PROXY header of a named proxy's connection, as its bytes arrive (proxy.ts `readProxyHeader`): null while it is
478
+ * not whole or once it is refused, else the bytes after it, which are the client's. Bounded: past its 16 fixed bytes
479
+ * the header says its length, and one past MAX_PROXY_BODY is refused there.
480
+ */
481
+ readProxy(data) {
482
+ const head = this.proxyHead;
483
+ head.bytes = head.bytes.length === 0 ? data : Buffer.concat([head.bytes, data]);
484
+ const read = readProxyHeader(head.bytes);
485
+ if ("more" in read)
486
+ return null;
487
+ clearTimeout(head.timer);
488
+ this.proxyHead = null;
489
+ if ("refused" in read) {
490
+ this.proxyRefused(read.refused);
491
+ return null;
492
+ }
493
+ this.clientAddr = clientAddress(this.socket.remoteAddress, read.header);
494
+ this.proxied = true;
495
+ return head.bytes.subarray(read.length);
496
+ }
497
+ /** A named proxy's connection that did not begin with a valid PROXY v2 header: refused, said in the log with why. */
498
+ proxyRefused(why) {
499
+ if (this.phase === "closed")
500
+ return;
501
+ this.server.log(`session ${this.id} refused: ${why}, from ${this.clientAddr ?? "an address that could not be read"}, which ${PROXY_SETTING} names, ` +
502
+ `so it must begin with one — set the proxy to send the PROXY protocol v2 (HAProxy: send-proxy-v2), or take its address out of ${PROXY_SETTING} (docs/POSTGRES-API.md "Behind a TCP proxy")`);
503
+ this.fatal("08P01", "the connection did not begin with a valid PROXY protocol v2 header");
504
+ }
505
+ /** A PROXY header where none is believed: from an address MASON_PG_PROXY_FROM does not name, or a second one. Refused,
506
+ * never read: a header is a claim about who connected, and only a named proxy may make it. */
507
+ unbelievedProxy() {
508
+ // a share's socket is public: a stranger's bytes write no line of the log there
509
+ if (!this.door.public)
510
+ this.server.log(`session ${this.id} refused: a PROXY protocol header from ${this.clientAddr ?? "an address that is not shown"}, ` +
511
+ (this.proxied ? "after the header its proxy sent: one header a connection" : this.door.proxyFrom === null ? "and this listener believes none" : `which ${PROXY_SETTING} does not name`) +
512
+ ` — a header is believed only from the addresses ${PROXY_SETTING} names: the proxy's own (docs/POSTGRES-API.md "Behind a TCP proxy")`);
513
+ this.fatal("08P01", "a PROXY protocol header, which this server takes only from the addresses of its own proxy");
514
+ }
432
515
  end(why) {
433
516
  if (this.phase === "closed")
434
517
  return;
435
518
  const wasReady = this.phase === "ready";
436
519
  this.phase = "closed";
520
+ if (this.proxyHead !== null) {
521
+ clearTimeout(this.proxyHead.timer);
522
+ this.proxyHead = null;
523
+ }
437
524
  this.closedBy = why;
438
525
  this.door.logins?.settled(this);
439
526
  if (wasReady)
@@ -720,6 +807,22 @@ class Session {
720
807
  this.fatal("3D000", `database "${this.wrongDatabase}" does not exist`, wrongDatabaseHint(this.server.database));
721
808
  return;
722
809
  }
810
+ // a startup packet's DateStyle, read as Postgres reads it (`canonicalElsewhere`): refused as the startup's other
811
+ // parameters are, before the session begins
812
+ if (this.startupDateStyle !== null) {
813
+ try {
814
+ const canonical = await this.canonicalElsewhere("datestyle", this.startupDateStyle, this.sessionState().dateStyle);
815
+ this.setParameter("datestyle", isoDateStyle(canonical, this.startupDateStyle));
816
+ this.startupVars = copyVars(this.vars);
817
+ }
818
+ catch (err) {
819
+ const e = asPgError(err, "");
820
+ this.fatal(e.fields.code, e.message, e.fields.hint);
821
+ return;
822
+ }
823
+ if (this.phase !== "password")
824
+ return; // the socket closed while the catalog was asked
825
+ }
723
826
  this.token = token;
724
827
  this.presented = m.password;
725
828
  this.verifiedAt = this.server.now();
@@ -727,7 +830,7 @@ class Session {
727
830
  this.decoder.maxLength = MAX_MESSAGE_LENGTH; // authenticated: a statement may be as long as the protocol allows
728
831
  // what is happening, as it happens (mason logs once showed little): who connected, and (end) who left
729
832
  this.server.log(`session ${this.id} connected: ${this.vars.applicationName === "" ? "a client with no application_name" : JSON.stringify(this.vars.applicationName)} as ${JSON.stringify(this.user)}` +
730
- (token.user !== undefined ? ` (${this.door.who})` : ""));
833
+ (token.user !== undefined ? ` (${this.door.who})` : "") + (this.clientAddr !== null ? ` from ${this.clientAddr}` : ""));
731
834
  this.sendAll([authenticationOk(), ...this.reportChanges(), backendKeyData(this.id, this.secret), readyForQuery("I")]);
732
835
  }
733
836
  reportedParameters() {
@@ -766,9 +869,14 @@ class Session {
766
869
  throw new PgError({ code: "22023", message: `invalid value for parameter "client_encoding": "${value}"`, hint: "this API sends UTF8 only" });
767
870
  return;
768
871
  case "datestyle":
769
- if (value !== null && !/^\s*iso\b/i.test(value) && !/,\s*iso\s*$/i.test(value) && value.trim() !== "") {
770
- throw new PgError({ code: "0A000", message: `DateStyle ${value} is not supported: this API writes every date and time in ISO 8601 (DateStyle ISO)` });
872
+ // the value is the catalog Postgres's canonical one (`canonicalElsewhere`): its validity and the order a part not
873
+ // named keeps are Postgres's check_datestyle, never derived here. A startup packet's is read after the password
874
+ // (`startupDateStyle`), where the catalog can be asked
875
+ if (atStartup) {
876
+ this.startupDateStyle = value;
877
+ return;
771
878
  }
879
+ this.vars.dateStyle = value === null ? "ISO, MDY" : isoDateStyle(value, value);
772
880
  return;
773
881
  case "extra_float_digits": {
774
882
  const n = value === null ? 1 : Number(value);
@@ -776,19 +884,44 @@ class Session {
776
884
  throw new PgError({ code: "22023", message: `${n} is outside the valid range for parameter "extra_float_digits" (-15 .. 3)` });
777
885
  if (n < 1)
778
886
  throw new PgError({ code: "0A000", message: `extra_float_digits ${n} is not supported: this API sends every float as the shortest text that reads back exactly (Postgres's extra_float_digits >= 1, the default since Postgres 12); fewer digits would be a different number` });
887
+ if (value === null)
888
+ this.vars.other.delete(name);
889
+ else
890
+ this.vars.other.set(name, String(n));
779
891
  return;
780
892
  }
781
893
  case "standard_conforming_strings":
782
894
  if (value !== null && !/^(on|true|yes|1)$/i.test(value))
783
895
  throw new PgError({ code: "0A000", message: "standard_conforming_strings off is not supported: a string literal is read as Postgres reads it by default" });
784
896
  return;
785
- case "intervalstyle": return;
897
+ case "intervalstyle": {
898
+ const style = value?.trim().toLowerCase() ?? "postgres";
899
+ if (style === "postgres")
900
+ return;
901
+ if (["postgres_verbose", "sql_standard", "iso_8601"].includes(style)) {
902
+ throw gapRefusal("interval-style", `IntervalStyle ${value} is not supported: every interval is written in Postgres's default style`, "SET IntervalStyle = postgres");
903
+ }
904
+ throw new PgError({ code: "22023", message: `invalid value for parameter "IntervalStyle": "${value}"`, hint: "Available values: postgres, postgres_verbose, sql_standard, iso_8601." });
905
+ }
786
906
  case "transaction_isolation":
787
907
  case "default_transaction_isolation":
788
908
  case "transaction":
789
- case "session characteristics":
790
- // read committed on a store that is never written: every level answers the same rows
909
+ case "session characteristics": {
910
+ // remembered, so SHOW and current_setting read back what was set; a store that is never written answers the same
911
+ // rows at every level. SET TRANSACTION … READ ONLY and the like name no level: nothing to remember
912
+ const key = SETTING_KEY[name] ?? name;
913
+ if (value === null) {
914
+ this.vars.other.delete(key);
915
+ return;
916
+ }
917
+ const level = value.trim().toLowerCase().replace(/\s+/g, " ");
918
+ if (level === "" && (name === "transaction" || name === "session characteristics"))
919
+ return;
920
+ if (!ISOLATION_LEVELS.includes(level))
921
+ throw new PgError({ code: "22023", message: `invalid value for parameter "${key}": "${value}"`, hint: `Available values: ${ISOLATION_LEVELS.join(", ")}.` });
922
+ this.vars.other.set(key, level);
791
923
  return;
924
+ }
792
925
  case "default_transaction_read_only":
793
926
  case "transaction_read_only":
794
927
  if (value !== null && /^(off|false|no|0)$/i.test(value))
@@ -847,6 +980,9 @@ class Session {
847
980
  case "time zone":
848
981
  this.vars.timeZone = start.timeZone;
849
982
  return;
983
+ case "datestyle":
984
+ this.vars.dateStyle = start.dateStyle;
985
+ return;
850
986
  case "application_name":
851
987
  this.vars.applicationName = start.applicationName;
852
988
  return;
@@ -952,6 +1088,24 @@ class Session {
952
1088
  /** The catalog Postgres validates a parameter this server has no opinion on; its own error is the answer. Inside a
953
1089
  * transaction that is ROLLED BACK: the sidecar is one connection every session shares, and a SET that stuck there
954
1090
  * was every session's (review of #480). The value itself lives in this session's `vars.other`. */
1091
+ /**
1092
+ * A PARAMETER'S VALUE AS THE CATALOG POSTGRES READS IT, from the session's `current` one: SET LOCAL to `current`, then
1093
+ * to `value`, then SHOW, in a transaction rolled back (as `validateElsewhere`, nothing stays on the shared connection).
1094
+ * Postgres's refusal (22023 and its DETAIL) is relayed as its own; its answer is the canonical value SHOW gives.
1095
+ */
1096
+ async canonicalElsewhere(name, value, current) {
1097
+ const sql = `BEGIN; SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(current)}; SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}; SHOW ${quoteIdent(name)}; ROLLBACK`;
1098
+ const messages = decodeBackend(Buffer.from(await this.server.catalog.run(Buffer.concat([frame("Q", cstr(sql)), frame("Q", cstr("ROLLBACK"))]))));
1099
+ const err = messages.find((x) => x.type === "ErrorResponse");
1100
+ if (err?.type === "ErrorResponse") {
1101
+ throw new PgError({ code: err.fields.C ?? "22023", message: err.fields.M ?? `invalid value for parameter "${name}"`, ...(err.fields.D !== undefined ? { detail: err.fields.D } : {}), ...(err.fields.H !== undefined ? { hint: err.fields.H } : {}) });
1102
+ }
1103
+ const row = messages.find((x) => x.type === "DataRow");
1104
+ const shown = row?.type === "DataRow" ? row.values[0]?.toString("utf8") : undefined;
1105
+ if (shown === undefined)
1106
+ throw new PgError({ code: "XX000", message: `SHOW ${name} answered no row in the catalog (Mason bug: report it)` }, 500);
1107
+ return shown;
1108
+ }
955
1109
  async validateElsewhere(name, value) {
956
1110
  const sql = value === null ? `RESET ${quoteIdent(name)}` : `SET LOCAL ${quoteIdent(name)} = ${quoteLiteral(value)}`;
957
1111
  // one batch, so no other session's statement lands inside the transaction: the ROLLBACK in the first query is not
@@ -1057,6 +1211,7 @@ class Session {
1057
1211
  requestId: crypto.randomUUID(), at: new Date(startedAt), method: asked?.method ?? "QUERY", durationMs: Date.now() - startedAt,
1058
1212
  client: `postgres:${this.vars.applicationName || "unnamed"}`, session: this.id, payload: stored.text, payloadTruncated: stored.truncated,
1059
1213
  caller: hashCaller(this.presented), bytesSent,
1214
+ ...(this.clientAddr !== null ? { clientAddress: this.clientAddr } : {}),
1060
1215
  ...(user !== undefined ? { user } : {}),
1061
1216
  ...(role !== undefined ? { role } : {}),
1062
1217
  ...(this.token?.attributes !== undefined && Object.keys(this.token.attributes).length > 0 ? { attributes: this.token.attributes } : {}),
@@ -1075,17 +1230,78 @@ class Session {
1075
1230
  this.server.schema();
1076
1231
  return duckTranslate(stmt, text);
1077
1232
  }
1233
+ /**
1234
+ * A SELECT OF NO TABLE THAT ORDERS TEXT (issue 122): the catalog's database is C, so one whose analysis orders text
1235
+ * under a collation that is not byte order is the synced data's (src/duck/plan.ts `ordersText`, decided by the
1236
+ * analysis, never the text), planned and refused like any other; every other stays the catalog's (SELECT 1, version(),
1237
+ * 'a' = 'A', anything under C).
1238
+ * ONE THAT MAPS CASE (upper, lower, initcap: issue 406), which the catalog answered before by its own C.UTF-8's
1239
+ * mapping, is the synced data's only where the synced data's RUN answers it (`answersCase`): the route's probe is the
1240
+ * run, never the plan alone, since a construct the rewrite leaves as written (`pg_catalog.lower(…)`, `char 'abc'`,
1241
+ * `FROM (SELECT)`) plans and fails only in the engine; where it fails, the catalog answers, as before.
1242
+ */
1243
+ async routed(t, text, paramOids, bindable) {
1244
+ const ask = this.server.options.duck.ordersText;
1245
+ if (t.kind !== "catalog" || t.fromless === undefined || ask === undefined)
1246
+ return t;
1247
+ const why = await ask((b) => this.server.catalog.run(b), text, paramOids, this.sessionState(), bindable);
1248
+ if (why === null || why === "case" && !await this.answersCase(text, paramOids))
1249
+ return t;
1250
+ return { kind: "duckdb", sql: text, names: t.fromless.names };
1251
+ }
1252
+ /**
1253
+ * WHETHER THE SYNCED DATA ANSWERS A STATEMENT OF NO TABLE THAT MAPS CASE: its plan made and RUN on this session's
1254
+ * connection, under its state and clock, every row read and none sent. A statement the plan or the run refuses, or
1255
+ * one with a parameter (no value before its Bind), is the catalog's, which answered it before (Mason gap
1256
+ * fromless-numeric-text-order). Only a statement's failure is read so; any other is thrown.
1257
+ */
1258
+ async answersCase(text, paramOids) {
1259
+ const raw = (await parseSql(text)).stmts?.[0];
1260
+ if (raw?.stmt === undefined)
1261
+ return false;
1262
+ const controller = new AbortController();
1263
+ const disarm = this.armTimeout(controller);
1264
+ try {
1265
+ const plan = await this.duckPlan(raw, text, paramOids);
1266
+ if (plan.params.length > 0)
1267
+ return false;
1268
+ const now = this.txNow ?? this.statementNow;
1269
+ const { cursor } = await this.duckAnswer(plan, [], [], { parse: now, exec: now }, controller);
1270
+ try {
1271
+ while (!cursor.done) {
1272
+ await this.fill(cursor);
1273
+ cursor.pending = [];
1274
+ }
1275
+ }
1276
+ finally {
1277
+ cursor.answer.close();
1278
+ }
1279
+ return true;
1280
+ }
1281
+ catch (e) {
1282
+ // the statement's own failure (Postgres's analysis, the rewrite's refusal, the engine's error at the run): said in
1283
+ // the log and answered by the catalog, as before this route existed
1284
+ // (a cancel or the statement's timeout is the statement's answer, never a reason to run it again on the catalog)
1285
+ if (controller.signal.aborted || !(e instanceof StatementError || e instanceof PgError || e instanceof EncodeError || e instanceof NumericError))
1286
+ throw e;
1287
+ this.server.log(`session ${this.id}: a statement of no table that maps case is answered by the catalog: the synced data's run failed (${e instanceof StatementError || e instanceof PgError ? `${e.fields.code} ` : ""}${e.message})`);
1288
+ return false;
1289
+ }
1290
+ finally {
1291
+ disarm();
1292
+ }
1293
+ }
1078
1294
  /** One statement of a simple query: the whole reply but the ReadyForQuery. */
1079
1295
  async runSimple(raw, text, whole) {
1080
1296
  if (raw.stmt === undefined)
1081
1297
  return [emptyQueryResponse()];
1082
1298
  const now = this.txStart(this.statementNow);
1083
1299
  this.heldToFailedBlock(raw.stmt);
1084
- const t = this.classify(raw.stmt, text);
1300
+ const t = await this.routed(this.classify(raw.stmt, text), text, [], false);
1085
1301
  switch (t.kind) {
1086
1302
  case "empty": return [emptyQueryResponse()];
1087
1303
  case "explain": {
1088
- const e = await this.explaining(raw, whole, t, []);
1304
+ const e = await this.explaining(raw, whole, t, [], false);
1089
1305
  if (e.plan === null)
1090
1306
  return catalogExplained(await this.forwardSimple(this.spliced(text, raw)), e.options);
1091
1307
  const controller = new AbortController();
@@ -1139,20 +1355,19 @@ class Session {
1139
1355
  /** A DuckDB statement's plan (Postgres's analysis and the DuckDB SQL), kept per text, search_path, TimeZone,
1140
1356
  * declared parameter types and the session's role until the schema or the roles change. Every statement DuckDB
1141
1357
  * runs has one: never its text as written. */
1142
- async duckPlan(raw, whole, paramOids, under = this.vars, wireBound = false) {
1358
+ async duckPlan(raw, whole, paramOids, under = this.sessionState(), wireBound = false) {
1143
1359
  const stmt = raw.stmt;
1144
1360
  const bytes = Buffer.from(whole, "utf8");
1145
1361
  const base = raw.stmt_location ?? 0;
1146
1362
  const text = bytes.subarray(base, raw.stmt_len ? base + raw.stmt_len : bytes.length).toString("utf8");
1147
- const { searchPath, timeZone } = under;
1148
1363
  const grants = this.grants();
1149
1364
  // the plan cache's key is the role the plan is held to: the effective one (a default's plans are that role's), and
1150
1365
  // a user with no role under a file with no default its own key, never a token's ("") — and, where the role filters
1151
1366
  // rows, the filters bound to the user's values (a plan holds them as literals)
1152
1367
  const rowsKey = rowsKeyOf(grants);
1153
1368
  const key = `${grants?.noRole !== undefined ? "\u0000no role" : this.roleName() ?? ""}${rowsKey === "" ? "" : `\u0000${rowsKey}`}`;
1154
- return this.server.duckPlan(text, paramOids, searchPath, timeZone, wireBound, key, async () => {
1155
- const plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, searchPath, timeZone, this.server.userTypes(), wireBound, grants);
1369
+ return this.server.duckPlan(text, paramOids, under, wireBound, key, async () => {
1370
+ const plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), text, stmt, base, paramOids, under, this.server.userTypes(), wireBound, grants);
1156
1371
  return rowsKey === "" ? plan : { ...plan, rowsKey };
1157
1372
  });
1158
1373
  }
@@ -1280,7 +1495,8 @@ class Session {
1280
1495
  const live = this.server.rolesNow();
1281
1496
  return live.file !== null && live.problem === null && live.default === null;
1282
1497
  }
1283
- /** the statement as the catalog runs it for this session: the user's name spliced for the keywords that name it */
1498
+ /** the statement as the catalog runs it for this session: the user's name spliced for the keywords that name it, and
1499
+ * every call of an override (`userSpliced`) */
1284
1500
  spliced(text, raw) {
1285
1501
  return userSpliced(text, raw.stmt, raw.stmt_location ?? 0, this.user, this.heldToNothing() ? HELD_TO_NOTHING_OPS : SESSION_OPS);
1286
1502
  }
@@ -1321,7 +1537,12 @@ class Session {
1321
1537
  // fail closed first (a role the file lacks, a file that cannot be read), then the catalog's role of the session
1322
1538
  // (`catalogRole`; both read the roles once, with no await between)
1323
1539
  const role = this.catalogRole();
1324
- const reply = unwrapSession(splitFrames(await this.server.catalog.run(asSession(role === undefined ? message : asRole(message, role), this.sessionSettings()))));
1540
+ // the transaction's instant, which a block's statements on either path read (`txNow`): begun here when none is
1541
+ // (reset at the end of a statement outside a block, as executeDuck's)
1542
+ const now = this.txNow ??= this.server.now();
1543
+ // the request's transaction begins at that instant (asSession, catalog-worker.ts pinnedClock): the catalog's own
1544
+ // transaction start is the session's, whatever reads it
1545
+ const reply = unwrapSession(splitFrames(await this.server.catalog.run(asSession(role === undefined ? message : asRole(message, role), this.sessionState(), this.sessionSettings()), now)));
1325
1546
  if (role === undefined)
1326
1547
  return reply;
1327
1548
  const { frames, setRoleRefused } = unwrapRole(reply);
@@ -1353,7 +1574,7 @@ class Session {
1353
1574
  this.running = controller;
1354
1575
  let bounded;
1355
1576
  try {
1356
- bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.vars, true);
1577
+ bounded = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.sessionState(), true);
1357
1578
  }
1358
1579
  catch (e) {
1359
1580
  if (!(e instanceof StatementError) || e.fields.code === "57014")
@@ -1380,12 +1601,12 @@ class Session {
1380
1601
  * statement it explains classified as that statement alone would be — refused with that statement's own error — and,
1381
1602
  * for DuckDB's, planned afresh (an ANALYZE's Planning Time is this one's), never from the plans kept per text.
1382
1603
  */
1383
- async explaining(raw, whole, t, paramOids) {
1604
+ async explaining(raw, whole, t, paramOids, bindable) {
1384
1605
  const options = explainOptions(t.options);
1385
- const inner = this.classify(t.query, "");
1386
- if (inner.kind === "catalog")
1606
+ const classified = this.classify(t.query, "");
1607
+ if (classified.kind === "catalog" && classified.fromless === undefined)
1387
1608
  return { options, plan: null };
1388
- if (inner.kind !== "duckdb")
1609
+ if (classified.kind !== "duckdb" && classified.kind !== "catalog")
1389
1610
  throw new PgError({ code: "0A000", message: "EXPLAIN of this statement is not supported: a SELECT is explained" });
1390
1611
  const bytes = Buffer.from(whole, "utf8");
1391
1612
  const base = raw.stmt_location ?? 0;
@@ -1393,10 +1614,13 @@ class Session {
1393
1614
  const found = await explainedText(text, t.query);
1394
1615
  if (found === null)
1395
1616
  throw new PgError({ code: "0A000", message: "Mason cannot find the statement this EXPLAIN explains in its text (a Mason gap: report it, with the statement)" });
1617
+ // a statement of no table is explained as what answers it: the synced data's plan where it orders text (`routed`)
1618
+ if ((await this.routed(classified, found.text, paramOids, bindable)).kind === "catalog")
1619
+ return { options, plan: null };
1396
1620
  let plan;
1397
1621
  try {
1398
1622
  const grants = this.grants();
1399
- plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), found.text, t.query, base + Buffer.byteLength(text.slice(0, found.offset)), paramOids, this.vars.searchPath, this.vars.timeZone, this.server.userTypes(), false, grants);
1623
+ plan = await this.server.options.duck.plan((b) => this.server.catalog.run(b), found.text, t.query, base + Buffer.byteLength(text.slice(0, found.offset)), paramOids, this.sessionState(), this.server.userTypes(), false, grants);
1400
1624
  // the row filters it was made under, as every plan of a session carries them (Session.duckPlan)
1401
1625
  const rowsKey = rowsKeyOf(grants);
1402
1626
  if (rowsKey !== "")
@@ -1438,10 +1662,10 @@ class Session {
1438
1662
  * transaction's start where the plan reads it (its positions follow the statement's own parameters) */
1439
1663
  async duckValues(plan, params, clock) {
1440
1664
  const own = params.length === 0 ? params
1441
- : await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.vars.timeZone);
1665
+ : await this.server.options.duck.parameters((b) => this.server.catalog.run(b), plan.params, params, this.sessionState());
1442
1666
  const values = [...own];
1443
1667
  for (const [which, at] of Object.entries(plan.clock ?? {}))
1444
- values[at - 1] = new Date(clock[which]).toISOString();
1668
+ values[at - 1] = instantText(clock[which]);
1445
1669
  return values;
1446
1670
  }
1447
1671
  async duck() {
@@ -1456,7 +1680,17 @@ class Session {
1456
1680
  */
1457
1681
  async duckSession() {
1458
1682
  const settings = new Map([...await this.server.catalogSettings(), ...this.sessionSettings()]);
1459
- return { timeZone: settings.get("timezone") ?? "UTC", searchPath: settings.get("search_path") ?? "", settings: Object.fromEntries(settings) };
1683
+ return { ...this.sessionState(), settings: Object.fromEntries(settings) };
1684
+ }
1685
+ /**
1686
+ * THE SESSION'S STATE FOR AN ANSWER, AS ONE VALUE (GitHub issue 253): its TimeZone, search_path and DateStyle, each
1687
+ * `sessionSetting`'s — the one reader SHOW answers from — so a statement planned, analysed, run on DuckDB or answered by
1688
+ * the catalog Postgres is made under the same values (src/api/postgres/catalog.ts SessionState). Nothing else reads
1689
+ * the session's zone, path or order.
1690
+ */
1691
+ sessionState() {
1692
+ const read = (name, otherwise) => this.sessionSetting(name) ?? otherwise;
1693
+ return { timeZone: read("timezone", "UTC"), searchPath: read("search_path", '"$user", public'), dateStyle: read("datestyle", "ISO, MDY") };
1460
1694
  }
1461
1695
  /** A DuckDB statement run: its columns (Postgres's, where it has a plan), and its rows to read, encoded in the
1462
1696
  * formats asked */
@@ -1505,7 +1739,7 @@ class Session {
1505
1739
  if (mine)
1506
1740
  this.running = c.controller;
1507
1741
  try {
1508
- const rows = await c.answer.fetch(c.formats, { timeZone: this.vars.timeZone });
1742
+ const rows = await c.answer.fetch(c.formats, { timeZone: this.sessionState().timeZone });
1509
1743
  if (rows === null)
1510
1744
  c.done = true;
1511
1745
  else
@@ -1652,12 +1886,16 @@ class Session {
1652
1886
  placeholderName(t.name);
1653
1887
  else if (!this.decidesHere(t.name))
1654
1888
  await this.validateElsewhere(t.name, t.value);
1889
+ // DateStyle: Postgres's check_datestyle decides its validity (22023 with its DETAIL) and the order a part not named
1890
+ // keeps, from the session's value (GitHub issue 253 review r1); Mason adds only its refusal of a non-ISO style
1891
+ const value = t.name === "datestyle" && t.value !== null ? await this.canonicalElsewhere("datestyle", t.value, this.sessionState().dateStyle) : t.value;
1655
1892
  const tx = this.settingsAtStart();
1656
- if (t.value === null)
1893
+ if (value === null)
1657
1894
  await this.resetParameter(t.name);
1658
1895
  else
1659
- this.setParameter(t.name, t.value);
1660
- if (!t.local)
1896
+ this.setParameter(t.name, value === t.value ? value : isoDateStyle(value, t.value));
1897
+ // transaction_isolation is the transaction's alone, however it is SET (Postgres resets it as the transaction ends)
1898
+ if (!t.local && t.name !== "transaction_isolation")
1661
1899
  copySetting(t.name, this.vars, tx.commit);
1662
1900
  // once named a placeholder stays defined, '', whichever way the transaction ends: its definition is no value it undoes
1663
1901
  if (placeholder)
@@ -1882,9 +2120,9 @@ class Session {
1882
2120
  throw new PgError({ code: "42P05", message: `prepared statement "${m.name}" already exists` });
1883
2121
  const statement = { sql: m.sql, raw, paramOids: [...m.paramTypes], shape: null, catalogName: null };
1884
2122
  if (raw?.stmt !== undefined) {
1885
- statement.shape = this.classify(raw.stmt, statementText(m.sql, raw));
2123
+ statement.shape = await this.routed(this.classify(raw.stmt, statementText(m.sql, raw)), statementText(m.sql, raw), m.paramTypes, true);
1886
2124
  if (statement.shape.kind === "explain") {
1887
- const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes);
2125
+ const e = await this.explaining(raw, m.sql, statement.shape, m.paramTypes, true);
1888
2126
  if (e.plan === null)
1889
2127
  statement.explainCatalog = e.options;
1890
2128
  else {
@@ -1894,8 +2132,9 @@ class Session {
1894
2132
  }
1895
2133
  // Postgres's analysis says the result's columns and the parameters' types, and DuckDB's SQL is made from it
1896
2134
  if (statement.shape.kind === "duckdb") {
1897
- statement.duck = await this.duckPlan(raw, m.sql, m.paramTypes);
1898
- statement.plannedUnder = { searchPath: this.vars.searchPath, timeZone: this.vars.timeZone };
2135
+ const under = this.sessionState();
2136
+ statement.duck = await this.duckPlan(raw, m.sql, m.paramTypes, under);
2137
+ statement.plannedUnder = under;
1899
2138
  // the implicit transaction begins with the Parse, as Postgres's does (start_xact_command): its literals read it
1900
2139
  statement.parsedNow = this.txNow ??= this.server.now();
1901
2140
  }
@@ -1939,6 +2178,7 @@ class Session {
1939
2178
  catalog: { description, rows: statement.explainCatalog === undefined ? rows : catalogExplained(rows, statement.explainCatalog), sent: 0, done } });
1940
2179
  return [bound];
1941
2180
  }
2181
+ await this.plannedAgainIfMoved(statement);
1942
2182
  const planned = statement.duck ?? statement.explain?.plan;
1943
2183
  const expected = planned !== undefined ? planned.params.length : 0;
1944
2184
  if (m.params.length !== expected)
@@ -1949,6 +2189,9 @@ class Session {
1949
2189
  const format = m.paramFormats.length === 0 ? 0 : m.paramFormats.length === 1 ? m.paramFormats[0] : m.paramFormats[i] ?? 0;
1950
2190
  if (format === 0)
1951
2191
  return p.toString("utf8");
2192
+ const oid = statement.paramOids[i] || planned?.params[i] || 0;
2193
+ if (TEXT_ARRAYS.has(oid))
2194
+ return textArrayFromBinary(p, i, oid === 1015 ? 1043 : 25);
1952
2195
  const type = oidOf(statement.paramOids[i] ?? 0) ?? (planned !== undefined ? oidOf(planned.params[i] ?? 0) : undefined) ?? "text";
1953
2196
  return decodeBinaryParam(type, p, i);
1954
2197
  });
@@ -2090,6 +2333,25 @@ class Session {
2090
2333
  if (this.asked !== null && plan.jsonReads !== undefined)
2091
2334
  this.asked.jsonReads = plan.jsonReads;
2092
2335
  }
2336
+ /**
2337
+ * THE SEARCH_PATH MOVED SINCE A STATEMENT WAS PLANNED: at its Bind it is planned again under the session's state now,
2338
+ * as Postgres's plancache plans a cached statement again (`GetCachedPlan` in exec_bind_message; GitHub issue 63) —
2339
+ * never run against tables named under another path. Its parameters keep the types the Parse fixed; a result that
2340
+ * moved (names, types, count) is Postgres's 0A000, and a refusal of the new plan (42P01) is the Bind's, as Postgres's.
2341
+ * The portal keeps the plan it was bound with. The zone alone plans nothing again: the plan reads it at execution.
2342
+ */
2343
+ async plannedAgainIfMoved(statement) {
2344
+ const now = this.sessionState();
2345
+ if (statement.raw === null || statement.duck === undefined || statement.plannedUnder === undefined || statement.plannedUnder.searchPath === now.searchPath)
2346
+ return;
2347
+ const again = await this.duckPlan(statement.raw, statement.sql, statement.duck.params, now);
2348
+ const result = (p) => JSON.stringify(p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null);
2349
+ if (result(again) !== result(statement.duck))
2350
+ throw new PgError({ code: "0A000", message: "cached plan must not change result type" });
2351
+ statement.duck = again;
2352
+ statement.plannedUnder = now;
2353
+ delete statement.duckOnWire;
2354
+ }
2093
2355
  /** an Execute of a DuckDB statement's portal: its cursor begun at the first, then paged or streamed */
2094
2356
  async executeDuck(m, portal, statement, controller) {
2095
2357
  // each Execute of a paged portal is a message of its own: the last one's row in the request log carries the reads
@@ -2101,7 +2363,7 @@ class Session {
2101
2363
  // A ROLE'S ROW FILTERS MOVED SINCE THE PARSE (the roles file edited): planned again under them, as Postgres plans a
2102
2364
  // prepared statement again after its policies change — where its columns and parameters are what the Parse said
2103
2365
  if (statement.raw !== null && (statement.duck.rowsKey ?? "") !== rowsKeyOf(this.grants())) {
2104
- const again = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.vars);
2366
+ const again = await this.duckPlan(statement.raw, statement.sql, statement.paramOids, statement.plannedUnder ?? this.sessionState());
2105
2367
  const shape = (p) => JSON.stringify([p.params, p.fields?.map((f) => [f.name, f.oid, f.typmod]) ?? null]);
2106
2368
  if (shape(again) === shape(statement.duck)) {
2107
2369
  statement.duck = again;
@@ -2179,7 +2441,8 @@ class Session {
2179
2441
  if (recheck === undefined || user === undefined)
2180
2442
  return null;
2181
2443
  const now = this.server.now();
2182
- if (now - this.verifiedAt < this.door.recheckMs)
2444
+ // the instant is microseconds (src/lib/instant.ts), the interval milliseconds
2445
+ if (now - this.verifiedAt < this.door.recheckMs * 1000)
2183
2446
  return null;
2184
2447
  try {
2185
2448
  // the user as the file has it NOW: its role given or taken since applies to this statement
@@ -2225,8 +2488,10 @@ const USER_OPS = { SVFOP_SESSION_USER: "session_user", SVFOP_CURRENT_USER: "curr
2225
2488
  const SESSION_OPS = new Set(["SVFOP_SESSION_USER"]);
2226
2489
  /** a user the roles file holds to nothing: its catalog role is `NO_ROLE_CATALOG`, never a name it should answer */
2227
2490
  const HELD_TO_NOTHING_OPS = new Set(Object.keys(USER_OPS));
2228
- /** `userSpliced`'s mark for the `pg_catalog.` of a qualified `current_setting`, never a keyword's text */
2229
- const SETTING = "\u0000setting";
2491
+ /** the functions Mason overrides in the catalog's `public` (src/api/postgres/catalog.ts currentSettingSql,
2492
+ * sourceTypeNames; `version()` in `Server`'s setup), each with what a call of it may be written with: `pg_catalog.` too,
2493
+ * or unqualified only (`pg_catalog.format_type`, written out, is Postgres's own) */
2494
+ const OVERRIDES = { version: { qualified: true }, current_setting: { qualified: true }, format_type: { qualified: false } };
2230
2495
  /**
2231
2496
  * WHAT A STATEMENT THE CATALOG ANSWERS READS OF ITS SESSION, spliced in its text at the parse tree's locations:
2232
2497
  * - `session_user` IS THIS SESSION'S USER, never the catalog's superuser (it answered `postgres` for every session while
@@ -2237,13 +2502,16 @@ const SETTING = "\u0000setting";
2237
2502
  * src/api/postgres/catalog.ts asRole), spliced only for a user the roles file holds to nothing
2238
2503
  * (`HELD_TO_NOTHING_OPS`), whose catalog role is `NO_ROLE_CATALOG`: Postgres answers its own name for all three to a
2239
2504
  * login with no grant.
2240
- * - `pg_catalog.current_setting(…)`, so written, is the session's override (`public.current_setting`, catalog.ts
2241
- * currentSettingSql): Postgres's own is called by its OID's C function whatever its catalog row says, so it cannot be
2242
- * replaced, and so qualified it answered the catalog's values after a SET (GitHub issue 40).
2505
+ * - EVERY CALL OF AN OVERRIDE (`OVERRIDES`: `version()`, `current_setting(…)`, `format_type(…)`), however written, is
2506
+ * the override's (`public.<name>`): the catalog runs a request under the session's search_path, which puts pg_catalog
2507
+ * first as Postgres's does (GitHub issue 253), so the override is reached by name, never by the path's order — and
2508
+ * Postgres's own `pg_catalog.current_setting` is called by its OID's C function whatever its catalog row says, so it
2509
+ * cannot be replaced (GitHub issue 40). The call is read from the parse tree's FuncCall, never from the text.
2510
+ * The clock is never spliced: the catalog's transaction begins at the session's instant (catalog.ts asSession), so
2511
+ * every reader of it is the session's as written (review r3 of PR 333).
2243
2512
  * `base`: the statement's offset in the text the parse tree's locations count from.
2244
2513
  */
2245
2514
  function userSpliced(text, stmt, base, user, ops) {
2246
- // a user keyword by its location, or `setting`: the `pg_catalog.` of a qualified current_setting
2247
2515
  const at = new Map();
2248
2516
  const walk = (n) => {
2249
2517
  if (Array.isArray(n)) {
@@ -2254,12 +2522,16 @@ function userSpliced(text, stmt, base, user, ops) {
2254
2522
  if (typeof n !== "object" || n === null)
2255
2523
  return;
2256
2524
  const f = n.SQLValueFunction;
2257
- if (f !== undefined && f.op !== undefined && ops.has(f.op) && typeof f.location === "number")
2258
- at.set(f.location, USER_OPS[f.op]);
2525
+ if (f !== undefined && f.op !== undefined && typeof f.location === "number" && ops.has(f.op))
2526
+ at.set(f.location, { kind: "user", keyword: USER_OPS[f.op] });
2259
2527
  const c = n.FuncCall;
2260
2528
  const name = c?.funcname?.map((x) => x.String?.sval ?? "");
2261
- if (c !== undefined && typeof c.location === "number" && name?.length === 2 && name[0] === "pg_catalog" && name[1] === "current_setting")
2262
- at.set(c.location, SETTING);
2529
+ if (c !== undefined && name !== undefined && typeof c.location === "number" && c.over === undefined && (name.length === 1 || (name.length === 2 && name[0] === "pg_catalog"))) {
2530
+ const fn = name.at(-1);
2531
+ const override = OVERRIDES[fn];
2532
+ if (override !== undefined && (name.length === 1 || override.qualified))
2533
+ at.set(c.location, { kind: "override", name: fn });
2534
+ }
2263
2535
  for (const v of Object.values(n))
2264
2536
  walk(v);
2265
2537
  };
@@ -2269,34 +2541,41 @@ function userSpliced(text, stmt, base, user, ops) {
2269
2541
  const bytes = Buffer.from(text, "utf8");
2270
2542
  const parts = [];
2271
2543
  let end = bytes.length;
2272
- for (const loc of [...at.keys()].sort((a, b) => b - a)) {
2273
- const keyword = at.get(loc);
2544
+ const qualified = (name) => new RegExp(`^(?:(?:pg_catalog|"pg_catalog")\\s*\\.\\s*)?(?:${name}|"${name}")(?![A-Za-z0-9_$])`, "i");
2545
+ for (const loc of [...at.keys()].sort((x, y) => y - x)) {
2546
+ const splice = at.get(loc);
2274
2547
  const from = loc - base;
2275
- if (from < 0)
2548
+ if (from < 0 || from >= end)
2276
2549
  continue;
2277
- let written;
2278
- if (keyword === SETTING) {
2279
- // the qualifier as written (`pg_catalog.`, `"pg_catalog" .`): what is spliced is its span, never a guess at it
2280
- const q = /^(?:pg_catalog|"pg_catalog")\s*\.\s*/i.exec(bytes.subarray(from, from + 64).toString("utf8"));
2281
- if (q === null)
2550
+ const ahead = bytes.subarray(from, Math.min(end, from + 128)).toString("utf8");
2551
+ let written, put;
2552
+ if (splice.kind === "override") {
2553
+ // the name as written (`version`, `pg_catalog.current_setting`, `"pg_catalog" . "version"`): its span, never a guess
2554
+ const m = qualified(splice.name).exec(ahead);
2555
+ if (m === null)
2282
2556
  continue;
2283
- written = q[0];
2557
+ written = m[0];
2558
+ put = `public.${splice.name}`;
2284
2559
  }
2285
2560
  else {
2286
- if (bytes.subarray(from, from + keyword.length).toString("utf8").toLowerCase() !== keyword)
2561
+ const keyword = splice.keyword;
2562
+ if (ahead.slice(0, keyword.length).toLowerCase() !== keyword)
2287
2563
  continue;
2288
2564
  // the keyword must end there (`user` is the start of `user_name`): the parser placed it, so a word character after is not it
2289
- if (/^[A-Za-z0-9_$]/.test(bytes.subarray(from + keyword.length, from + keyword.length + 1).toString("utf8")))
2565
+ if (/^[A-Za-z0-9_$]/.test(ahead.slice(keyword.length, keyword.length + 1)))
2290
2566
  continue;
2291
2567
  written = keyword;
2568
+ put = `(SELECT ${quoteLiteral(user)}::name AS ${keyword})`;
2292
2569
  }
2293
2570
  parts.unshift(bytes.subarray(from + Buffer.byteLength(written), end));
2294
- parts.unshift(Buffer.from(keyword === SETTING ? "public." : `(SELECT ${quoteLiteral(user)}::name AS ${keyword})`, "utf8"));
2571
+ parts.unshift(Buffer.from(put, "utf8"));
2295
2572
  end = from;
2296
2573
  }
2297
2574
  parts.unshift(bytes.subarray(0, end));
2298
2575
  return Buffer.concat(parts).toString("utf8");
2299
2576
  }
2577
+ /** the splice of a statement the catalog answers, exported for its unit tests (tests/postgres-api/server.test.ts) */
2578
+ export const userSplicedForTest = userSpliced;
2300
2579
  /** the text of one statement of a simple query, from the parser's byte offsets */
2301
2580
  function statementText(sql, raw) {
2302
2581
  const bytes = Buffer.from(sql, "utf8");
@@ -2306,6 +2585,114 @@ function statementText(sql, raw) {
2306
2585
  }
2307
2586
  const oidOf = (oid) => Object.keys(OID).find((t) => OID[t].oid === oid);
2308
2587
  const PG_EPOCH_MS = Date.UTC(2000, 0, 1);
2588
+ /** an element type's name, as array_recv's 42804 says it */
2589
+ const ELEMENT_NAMES = { 25: "text", 1043: "character varying", 16: "boolean", 20: "bigint", 21: "smallint", 23: "integer", 700: "real", 701: "double precision", 1700: "numeric", 2950: "uuid", 1082: "date", 1114: "timestamp without time zone", 1184: "timestamp with time zone", 3802: "jsonb", 114: "json" };
2590
+ /** text[] and varchar[], the array types whose binary form a Bind is decoded from (textArrayFromBinary) */
2591
+ const TEXT_ARRAYS = new Set([1009, 1015]);
2592
+ /**
2593
+ * A BINARY text[] (or varchar[]) PARAMETER, as the literal array_in reads to the same array (Npgsql sends every
2594
+ * parameter binary): array_send's form, its dimensions, a NULL flag, the element type, each dimension's length and lower
2595
+ * bound, then each element's length (-1 a NULL) and bytes. Every element is quoted, so any character is itself; a lower
2596
+ * bound other than 1 and a second dimension are written as array_in reads them, and refused where every text[] value is
2597
+ * (src/duck/analyze.ts readParameters). Bytes that are no such array are refused as array_recv refuses them, in its
2598
+ * order: too few 08P01 (22P03 within an element), flags other than 0 or 1 22P03, an element type other than the
2599
+ * parameter's own 42804, a negative or too large dimension 54000, an element that is no UTF-8 or holds a NUL 22021, too
2600
+ * many bytes 22P03 naming the parameter.
2601
+ */
2602
+ function textArrayFromBinary(bytes, i, expected) {
2603
+ const bad = () => new PgError({ code: "22P03", message: `incorrect binary data format in bind parameter ${i + 1}` });
2604
+ const short = () => new PgError({ code: "08P01", message: "insufficient data left in message" });
2605
+ let at = 0;
2606
+ const int = () => { if (at + 4 > bytes.length)
2607
+ throw short(); const v = bytes.readInt32BE(at); at += 4; return v; };
2608
+ const ndim = int();
2609
+ if (ndim < 0 || ndim > 6)
2610
+ throw new PgError({ code: ndim < 0 ? "22P03" : "54000", message: ndim < 0 ? `invalid number of dimensions: ${ndim}` : `number of array dimensions (${ndim}) exceeds the maximum allowed (6)` });
2611
+ // array_recv's order: the dimensions, the flags, the element type, then each dimension's size
2612
+ const flags = int();
2613
+ if (flags !== 0 && flags !== 1)
2614
+ throw new PgError({ code: "22P03", message: "invalid array flags" });
2615
+ const element = int() >>> 0;
2616
+ if (element !== expected) {
2617
+ throw new PgError({ code: "42804", message: `binary data has array element type ${element} (${ELEMENT_NAMES[element] ?? "another type"}) instead of expected ${expected} (${ELEMENT_NAMES[expected]})` });
2618
+ }
2619
+ const dims = [];
2620
+ for (let d = 0; d < ndim; d++) {
2621
+ const length = int();
2622
+ dims.push({ length, lower: int() });
2623
+ }
2624
+ // ArrayGetNItems: a negative dimension, a product past an int32 at any step, or past MaxArraySize at the end
2625
+ const MAX_ARRAY_SIZE = 268435455;
2626
+ const tooBig = () => new PgError({ code: "54000", message: `array size exceeds the maximum allowed (${MAX_ARRAY_SIZE})` });
2627
+ let items = 1;
2628
+ for (const d of dims) {
2629
+ if (d.length < 0)
2630
+ throw tooBig();
2631
+ items *= d.length;
2632
+ if (items > 0x7fffffff)
2633
+ throw tooBig();
2634
+ }
2635
+ if (items > MAX_ARRAY_SIZE)
2636
+ throw tooBig();
2637
+ const elementText = () => {
2638
+ const length = int();
2639
+ if (length === -1)
2640
+ return "NULL";
2641
+ // ReadArrayBinary's own check is 22P03, where reading a length past the end is pq_getmsgint's 08P01
2642
+ if (length < 0 || at + length > bytes.length)
2643
+ throw new PgError({ code: "22P03", message: "insufficient data left in message" });
2644
+ const text = utf8Element(bytes.subarray(at, at + length));
2645
+ at += length;
2646
+ return `"${text.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
2647
+ };
2648
+ const level = (d) => `{${Array.from({ length: dims[d].length }, () => d === ndim - 1 ? elementText() : level(d + 1)).join(",")}}`;
2649
+ const body = ndim === 0 || dims.some((d) => d.length === 0) ? "{}" : level(0);
2650
+ if (at !== bytes.length)
2651
+ throw bad();
2652
+ const bounds = dims.every((d) => d.lower === 1) || body === "{}" ? "" : `${dims.map((d) => `[${d.lower}:${d.lower + d.length - 1}]`).join("")}=`;
2653
+ return bounds + body;
2654
+ }
2655
+ const fatalUtf8 = new TextDecoder("utf-8", { fatal: true });
2656
+ /**
2657
+ * An element's text, as textrecv reads it (pg_verify_mbstr): bytes that are no UTF-8, or a NUL, are refused 22021 naming
2658
+ * the first bad character's bytes (report_invalid_encoding: as many as its lead byte says, as many as are left), never
2659
+ * decoded with a replacement character.
2660
+ */
2661
+ function utf8Element(bytes) {
2662
+ if (!bytes.includes(0)) {
2663
+ // a fatal TextDecoder throws exactly TypeError on bytes that are no UTF-8: the scan below names them
2664
+ try {
2665
+ return fatalUtf8.decode(bytes);
2666
+ }
2667
+ catch (err) {
2668
+ if (!(err instanceof TypeError))
2669
+ throw err;
2670
+ }
2671
+ }
2672
+ // find the first character Postgres refuses: a NUL, or a character no UTF-8 decoder reads alone
2673
+ for (let at = 0; at < bytes.length;) {
2674
+ const b = bytes[at];
2675
+ const length = b < 0x80 ? 1 : (b & 0xe0) === 0xc0 ? 2 : (b & 0xf0) === 0xe0 ? 3 : (b & 0xf8) === 0xf0 ? 4 : 1;
2676
+ const char = bytes.subarray(at, at + length);
2677
+ let legal = b !== 0 && char.length === length;
2678
+ if (legal && b >= 0x80) {
2679
+ try {
2680
+ fatalUtf8.decode(char);
2681
+ }
2682
+ catch (err) {
2683
+ if (!(err instanceof TypeError))
2684
+ throw err;
2685
+ legal = false;
2686
+ }
2687
+ }
2688
+ if (!legal) {
2689
+ const shown = Array.from(char, (c) => `0x${c.toString(16).padStart(2, "0")}`).join(" ");
2690
+ throw new PgError({ code: "22021", message: `invalid byte sequence for encoding "UTF8": ${shown}` });
2691
+ }
2692
+ at += length;
2693
+ }
2694
+ throw new PgError({ code: "XX000", message: "a text[] element failed to decode as UTF-8 at no character: report it" });
2695
+ }
2309
2696
  /** A binary-format parameter, as text Postgres's input function reads (DuckOptions.parameters). */
2310
2697
  function decodeBinaryParam(type, bytes, i) {
2311
2698
  const need = (n) => {
@@ -2395,7 +2782,7 @@ class Server {
2395
2782
  if (c.typeOid === undefined || c.spelled === undefined)
2396
2783
  continue;
2397
2784
  // the type's name as pg_type holds it (`other.level` is `level`, `"My Mood"` is `My Mood`)
2398
- const name = typeNameParts(c.spelled.replace(/\[\]$/, "")).name;
2785
+ const name = typeNameParts(madeTypeName(c.spelled)).name;
2399
2786
  // the element type and its array type, as pg_type names them (`citext`, `_citext`); an array column's own OID is
2400
2787
  // its array type's (pg_attribute.atttypid), and it has none of its own (an array type's typarray is 0)
2401
2788
  if (c.spelled.endsWith("[]"))
@@ -2417,7 +2804,7 @@ class Server {
2417
2804
  * analysis starts the instance with the new DDL. A plan whose schema or generation moved while it was made is
2418
2805
  * returned to its caller and not kept.
2419
2806
  */
2420
- async duckPlan(text, paramOids, searchPath, timeZone, wireBound, role, make) {
2807
+ async duckPlan(text, paramOids, under, wireBound, role, make) {
2421
2808
  const schema = this.schema();
2422
2809
  const roles = this.rolesNow();
2423
2810
  const generation = this.options.duck?.generation?.() ?? 0;
@@ -2430,7 +2817,8 @@ class Server {
2430
2817
  // the zone too: a plan holds the values Postgres parsed its literals to, under the session's zone
2431
2818
  // and the bound: an Execute's row count plans a LEFT lateral of a list otherwise (Session.boundOnWire), kept beside
2432
2819
  // and the role: a plan is held to it as it is made (src/duck/grants.ts), so a role's plans are its own
2433
- const key = `${searchPath}\u0000${timeZone}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${role}\u0000${text}`;
2820
+ // and the DateStyle: Postgres reads a literal date in its order (`'01/02/2026'::date`), at analysis
2821
+ const key = `${under.searchPath}\u0000${under.timeZone}\u0000${under.dateStyle}\u0000${paramOids.join(",")}\u0000${wireBound ? "wire" : ""}\u0000${role}\u0000${text}`;
2434
2822
  const hit = this.duckPlans.get(key);
2435
2823
  if (hit !== undefined)
2436
2824
  return hit;
@@ -2452,7 +2840,8 @@ class Server {
2452
2840
  constructor(options) {
2453
2841
  this.options = options;
2454
2842
  this.door = { tls: options.tls === null ? null : tls.createSecureContext({ cert: options.tls.cert, key: options.tls.key }), authenticate: options.authenticate,
2455
- recheck: options.recheck, recheckMs: options.recheckMs ?? DEFAULT_RECHECK_MS, who: "a mason user", loginHint: USER_LOGIN_HINT, public: false, logins: null };
2843
+ recheck: options.recheck, recheckMs: options.recheckMs ?? DEFAULT_RECHECK_MS, who: "a mason user", loginHint: USER_LOGIN_HINT, public: false, logins: null,
2844
+ proxyFrom: options.proxyFrom ?? null };
2456
2845
  this.lastSchema = options.schema();
2457
2846
  this.lastRoles = options.roles?.() ?? NO_ROLES;
2458
2847
  const version = versionText(options.product ?? "Mason", options.masonVersion);
@@ -2464,7 +2853,10 @@ class Server {
2464
2853
  `CREATE OR REPLACE FUNCTION public.version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '${version.replaceAll("'", "''")}' $$`,
2465
2854
  // `current_setting(name)` answers the session's settings, carried in by each of its requests (catalog.ts asSession)
2466
2855
  ...currentSettingSql(),
2467
- "SET search_path = public, pg_catalog",
2856
+ // latest(x ORDER BY …), Mason's aggregate, analysed as Postgres's own (catalog.ts LATEST_SQL)
2857
+ ...LATEST_SQL,
2858
+ // the catalog's base state, which every request of a session leaves it in (catalog.ts CATALOG_BASE)
2859
+ CATALOG_BASE_SQL,
2468
2860
  ...databaseNameSql(options.database),
2469
2861
  ],
2470
2862
  ...(options.catalogStatementMs !== undefined ? { statementMs: options.catalogStatementMs } : {}),
@@ -2489,8 +2881,8 @@ class Server {
2489
2881
  this.baseSettings.catch(() => { this.baseSettings = null; });
2490
2882
  return this.baseSettings;
2491
2883
  }
2492
- /** the clock "now" is read from */
2493
- now() { return (this.options.clock ?? Date.now)(); }
2884
+ /** the clock "now" is read from: microseconds since the epoch (src/lib/instant.ts) */
2885
+ now() { return (this.options.clock ?? nowMicros)(); }
2494
2886
  log(line) { (this.options.log ?? ((l) => console.log(l)))(`[postgres] ${line}`); }
2495
2887
  /** the schema as of now; the catalog Postgres is replaced when it changed (or the roles did: `rolesNow`) */
2496
2888
  schema() {
@@ -2503,6 +2895,17 @@ class Server {
2503
2895
  }
2504
2896
  return s;
2505
2897
  }
2898
+ /** src/duck/generated-plan.ts `Planner`: null `grants` so the plan names what it reads, under no role */
2899
+ planner() {
2900
+ const duck = this.options.duck;
2901
+ if (duck === undefined)
2902
+ throw new Error("a generated table needs the listener's engine (Mason bug: report it)");
2903
+ const catalog = (b) => this.catalog.run(b);
2904
+ return {
2905
+ plan: (text, stmt, relations) => { this.schema(); return duck.plan(catalog, text, stmt, 0, [], GENERATED_SESSION, this.userTypes(), false, null, relations, true); },
2906
+ rows: (sql) => { this.schema(); return catalogRows(catalog, sql); },
2907
+ };
2908
+ }
2506
2909
  /** the roles as of now (src/duck/grants.ts RolesLive); the catalog is replaced when they changed, as for the schema */
2507
2910
  rolesNow() {
2508
2911
  this.schema();
@@ -2552,7 +2955,7 @@ class Server {
2552
2955
  if (stmt === undefined)
2553
2956
  throw new Error(`a filter's read does not parse: ${probe}`);
2554
2957
  try {
2555
- await this.options.duck.plan((b) => this.catalog.run(b), probe, stmt, 0, [], "public", "UTC", this.userTypes(), false, undefined);
2958
+ await this.options.duck.plan((b) => this.catalog.run(b), probe, stmt, 0, [], GENERATED_SESSION, this.userTypes(), false, undefined);
2556
2959
  }
2557
2960
  catch (e) {
2558
2961
  if (!(e instanceof StatementError))
@@ -2697,7 +3100,9 @@ export async function startPostgresApi(options) {
2697
3100
  port,
2698
3101
  async openShare(o) {
2699
3102
  const door = { tls: tls.createSecureContext({ cert: o.tls.cert, key: o.tls.key }), authenticate: o.authenticate, recheck: o.recheck, recheckMs: 0, who: "a share",
2700
- loginHint: o.loginHint, public: true, logins: new LoginQueue(o.pendingLimit ?? DEFAULT_PENDING_LIMIT, o.pendingMs ?? DEFAULT_PENDING_MS, o.dropped), refused: o.refused };
3103
+ loginHint: o.loginHint, public: true, logins: new LoginQueue(o.pendingLimit ?? DEFAULT_PENDING_LIMIT, o.pendingMs ?? DEFAULT_PENDING_MS, o.dropped), refused: o.refused,
3104
+ // a tunnel's client comes from loopback: no header is believed on a share's socket
3105
+ proxyFrom: null };
2701
3106
  // loopback whatever the run's own listener is bound to: a tunnel's client is on this machine
2702
3107
  const share = await listenOn(server, door, 0, "127.0.0.1");
2703
3108
  shares.add(share.listener);
@@ -2722,6 +3127,7 @@ export async function startPostgresApi(options) {
2722
3127
  get sessions() { return server.sessions.size; },
2723
3128
  roles: () => server.rolesChecked(),
2724
3129
  checkRoles: () => server.checkRoles(),
3130
+ planner: () => server.planner(),
2725
3131
  openSessions: () => server.openSessions(),
2726
3132
  running: () => server.running(),
2727
3133
  cancel: (session, statement) => server.cancelSession(session, statement),