@mason-data/cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +19 -8
  2. package/dist/src/api/error.js +4 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +166 -32
  5. package/dist/src/api/postgres/listener.js +11 -3
  6. package/dist/src/api/postgres/proxy.js +129 -0
  7. package/dist/src/api/postgres/schema.js +6 -0
  8. package/dist/src/api/postgres/server.js +527 -103
  9. package/dist/src/api/postgres/translate.js +47 -2
  10. package/dist/src/cli/background.js +18 -19
  11. package/dist/src/cli/bridge-stdin.js +101 -0
  12. package/dist/src/cli/command.js +337 -15
  13. package/dist/src/cli/commands.js +458 -169
  14. package/dist/src/cli/credentials.js +182 -0
  15. package/dist/src/cli/demo.js +2 -2
  16. package/dist/src/cli/deploy.js +680 -0
  17. package/dist/src/cli/dev.js +148 -27
  18. package/dist/src/cli/doctor.js +122 -36
  19. package/dist/src/cli/generated-files.js +143 -0
  20. package/dist/src/cli/guides.js +116 -16
  21. package/dist/src/cli/hosted.js +850 -0
  22. package/dist/src/cli/init.js +154 -33
  23. package/dist/src/cli/login.js +292 -0
  24. package/dist/src/cli/machine.js +125 -40
  25. package/dist/src/cli/main.js +59 -10
  26. package/dist/src/cli/mason.cjs +17 -0
  27. package/dist/src/cli/mcp.js +17 -14
  28. package/dist/src/cli/output-gate.js +176 -0
  29. package/dist/src/cli/page-share.js +96 -0
  30. package/dist/src/cli/pg-tls.js +1 -1
  31. package/dist/src/cli/pin.js +6 -2
  32. package/dist/src/cli/pool-size.cjs +85 -0
  33. package/dist/src/cli/ports.js +2 -0
  34. package/dist/src/cli/preflight.js +130 -39
  35. package/dist/src/cli/project.js +100 -1
  36. package/dist/src/cli/readiness.js +42 -6
  37. package/dist/src/cli/registry.js +89 -36
  38. package/dist/src/cli/replica-hosted.js +1324 -0
  39. package/dist/src/cli/replica.js +114 -0
  40. package/dist/src/cli/replication-mode.js +5 -0
  41. package/dist/src/cli/request-filter.js +16 -6
  42. package/dist/src/cli/roles.js +2 -1
  43. package/dist/src/cli/run-server.js +179 -9
  44. package/dist/src/cli/secret-input.js +193 -0
  45. package/dist/src/cli/security.js +57 -10
  46. package/dist/src/cli/serve.js +65 -9
  47. package/dist/src/cli/share-run.js +51 -8
  48. package/dist/src/cli/share.js +23 -18
  49. package/dist/src/cli/source-edit.js +10 -4
  50. package/dist/src/cli/source-role.js +343 -0
  51. package/dist/src/cli/sources.js +50 -23
  52. package/dist/src/cli/style.js +16 -1
  53. package/dist/src/cli/supervise.js +15 -23
  54. package/dist/src/cli/sync.js +17 -8
  55. package/dist/src/cli/token-hosted.js +343 -0
  56. package/dist/src/cli/tunnel.js +1 -1
  57. package/dist/src/cli/ui-hosted.js +163 -0
  58. package/dist/src/cli/ui-ssh.js +460 -0
  59. package/dist/src/cli/ui.js +162 -17
  60. package/dist/src/cli/usage.js +19 -15
  61. package/dist/src/cli/user-hosted.js +364 -0
  62. package/dist/src/cli/user.js +275 -105
  63. package/dist/src/demo/seed.js +26 -0
  64. package/dist/src/duck/analyze.js +36 -20
  65. package/dist/src/duck/collation.js +266 -0
  66. package/dist/src/duck/constant-key.js +94 -0
  67. package/dist/src/duck/distinct-key.js +283 -26
  68. package/dist/src/duck/events.js +39 -8
  69. package/dist/src/duck/executor-worker.js +2 -2
  70. package/dist/src/duck/executor.js +6 -5
  71. package/dist/src/duck/explain.js +4 -0
  72. package/dist/src/duck/functions.js +448 -49
  73. package/dist/src/duck/gaps.js +34 -16
  74. package/dist/src/duck/generated-plan.js +1070 -0
  75. package/dist/src/duck/generated.js +906 -0
  76. package/dist/src/duck/grants.js +1 -1
  77. package/dist/src/duck/held-types.js +64 -0
  78. package/dist/src/duck/history.js +4 -0
  79. package/dist/src/duck/jsonb-order.js +9 -12
  80. package/dist/src/duck/kept-route.js +6 -5
  81. package/dist/src/duck/kept.js +9 -7
  82. package/dist/src/duck/late.js +1469 -40
  83. package/dist/src/duck/libc-bound.js +341 -0
  84. package/dist/src/duck/libc-order.js +892 -126
  85. package/dist/src/duck/pg-eval-worker.js +7 -1
  86. package/dist/src/duck/pg-eval.js +6 -4
  87. package/dist/src/duck/pgtree.js +774 -23
  88. package/dist/src/duck/plan.js +275 -30
  89. package/dist/src/duck/publication.js +14 -0
  90. package/dist/src/duck/requests.js +9 -5
  91. package/dist/src/duck/resolved.js +117 -2
  92. package/dist/src/duck/rewrite.js +4567 -583
  93. package/dist/src/duck/row-filter.js +12 -6
  94. package/dist/src/duck/statement.js +19 -0
  95. package/dist/src/duck/store.js +294 -68
  96. package/dist/src/duck/stream.js +9 -5
  97. package/dist/src/duck/sync.js +602 -126
  98. package/dist/src/duck/token.js +4 -1
  99. package/dist/src/duck/types.js +63 -7
  100. package/dist/src/duck/usage.js +6 -4
  101. package/dist/src/duck/users.js +110 -9
  102. package/dist/src/duck/verified.js +159 -2
  103. package/dist/src/duck/views.js +4 -3
  104. package/dist/src/lib/cidr.js +177 -0
  105. package/dist/src/lib/database-url.js +55 -10
  106. package/dist/src/lib/instant.js +50 -0
  107. package/dist/src/lib/log-buffer.js +13 -4
  108. package/dist/src/lib/log-read.js +196 -0
  109. package/dist/src/lib/pg-numeric.js +387 -49
  110. package/dist/src/lib/secret-mask.js +142 -0
  111. package/dist/src/lib/table-sync.js +84 -19
  112. package/dist/src/web/app.js +9 -4
  113. package/dist/src/web/pages/status.js +178 -13
  114. package/dist/src/web/pages/syncs.js +1 -1
  115. package/dist/src/web/pages/tables.js +8 -3
  116. package/dist/src/web/pages/users.js +4 -3
  117. package/dist/src/web/shared.js +19 -3
  118. package/package.json +8 -7
package/README.md CHANGED
@@ -106,7 +106,7 @@ again and remove `mason/models/`.
106
106
  | `mason wait [--timeout <s>]` | Waits until every added table is synced and served, showing every added table on a line of its own: 100% for the finished ones, in the order they finished, and a bar for each still syncing (rows, %, rate, time left) at the bottom. Exit 0 ready, 1 a first sync failed (printed, with what to do), 124 timed out (default 600 s). |
107
107
  | `mason user add\|list\|remove <name>` | The users of the Postgres API: a name and a password that does not expire, as a BI tool's datasource form asks for them or an app's `DATABASE_URL` carries them. `add` prints the password once with the URL; `remove` refuses the next connection at once and ends the sessions it holds at their next statement, without a restart. |
108
108
  | `mason mcp [--url <url>]` | An MCP server on stdio for Claude Code, Claude Desktop and other agents (below): the tables Mason serves with each one's rows and how current it is, a table's columns, a statement's rows with their Postgres types or Mason's refusal with its way out, and EXPLAIN — connected as a `mason user` (`MASON_URL`, else `--url`), beside `mason start` or a `mason serve` elsewhere. |
109
- | `mason ui` | Opens the running Mason's web page in your browser: the Overview (what `mason status` says), the Tables (add, remove or sync one), the Users of `mason user` (add one, its password shown once; disable, enable or remove one) and Observability (the statements, usage over time, the sync's events), refreshed while the tab is open. It prints the URL with the run's token, which the page trades for a cookie on the first visit; `--no-open` prints it only, `--json` is `{ url, port }`. `mason start`'s alone: `mason serve` serves no page. |
109
+ | `mason ui` | Opens the running Mason's web page in your browser: the Overview (what `mason status` says), the Tables (add, remove or sync one), the Users of `mason user` (add one, its password shown once; disable, enable or remove one) and Observability (the statements, usage over time, the sync's events), refreshed while the tab is open. It prints the URL with the run's token, which the page trades for a cookie on the first visit, and opens it in a browser on a terminal; `--no-open` (or output that is not a terminal) prints it only, `--json` is `{ url, port }`. `mason start`'s alone: `mason serve` serves no page. |
110
110
  | `mason status` | What Mason is doing: the database and how its changes are read, the project folder, the synced data's file, the Postgres API and how it listens, each synced table, the statements of the last day and week (each outcome, the completed ones' average, p50 and p95 time, the rows and bytes sent), the ones answered slowly in the last day and the ones refused or failed, each with its reason. In replication mode, where the stream is. |
111
111
  | `mason sync <table>…` | Brings these synced tables up to date now, by one poll each: a table whose `sync:` is `off` is kept current only so (from cron, before a report), a polled one is polled now as well as at its interval. In replication mode the stream keeps every table current, and it says where the stream is. |
112
112
  | `mason logs [-n <lines>] [-f] [--start] [--requests [<filters>]] [--events [--failed]] [--running]` | Mason's log: every sync, poll and statement, with warnings and errors and their reasons; `-f` follows it, `--start` shows what `mason start` printed instead. `--requests` is the request log: every statement a tool sent, when, the outcome (ok, refused, failed, cancelled) and SQLSTATE, how long, the rows and bytes sent, the tool and the user; `--failed` only the ones not answered, `--outcome`, `--since` / `--until` (`2h`, `7d`, an ISO time), `--user` and `--client` narrow it, and `--before` reads the page before. `--events` is the sync's events: each first sync, poll, catch-up or stream that failed, the database out of reach, a table current again, each sync asked by hand (mason sync, the page's Sync now), and each restart of the run (`--failed` only the failures). `--running` lists the statements running now, by session. |
@@ -114,10 +114,15 @@ again and remove `mason/models/`.
114
114
  | `mason usage [--since 7d] [--step 1h]` | What Mason did over time, from a sample a minute kept `MASON_HISTORY_DAYS` days: the statements and their p95, the rows and bytes sent, the synced data's size and rows, the stalest table, the stream's lag, the memory; then each synced table's own size. |
115
115
  | `mason stop` | Stops Mason for this project. The synced data and the state stay: the next `mason start` resumes in seconds. |
116
116
  | `mason destroy` | Removes this project's synced data (its file) and Mason's state in `.mason/`, stopping Mason first if it runs — and, when a table was replicated, its slot (and its publication, when Mason made it) on the database. Your files, `.mason/config.json` and the users stay: the next `mason start` syncs the tables again. |
117
- | `mason serve` | Mason for a server or a container: `mason start --attach` configured by the environment alone, its state in one folder (a volume), its log on stdout, `GET /healthz`, `GET /metrics` (Prometheus, numbers only), a clean stop on SIGTERM. `mason serve --help` lists every setting with its default. |
117
+ | `mason serve` | Mason for a server or a container: `mason start --attach` configured by the environment alone, its state in one folder (a volume), its log on stdout, `GET /healthz`, `GET /metrics` (Prometheus, numbers only), a clean stop on SIGTERM. `mason serve --help` lists every setting with its default; `MASON_PROJECT_READONLY=1`, for a project folder deployed as committed, refuses every edit of its files there (`mason add`, `mason remove`, the page's Add and Remove), with "edit the files and deploy them". |
118
+ | `mason login` | Signs this machine in to a hosted Mason service (`MASON_CLOUD_URL`, else `https://app.masondata.ai`): it prints a page and a code (on stderr, at once, so a coding agent can pass them on), opens the page in a browser on a terminal, waits until the code is approved, then says who is signed in and their organisations. The login is kept in `~/.config/mason/credentials.json` (0600), never in a project; refused while `MASON_TOKEN` is set, which every command uses first. [`docs/HOSTED-API.md`](../docs/HOSTED-API.md) is the API. |
119
+ | `mason logout` | Revokes this machine's login to that service and removes it. When the service cannot be reached, the login is still removed, and it says it was NOT revoked and how to revoke it. |
120
+ | `mason replica create\|list\|status` | A hosted Mason service's replicas: `create <org>/<name> --region <region> --size <gb>` makes one for this project's database (its URL read by the variable the sources file names, never from the command line) and waits until it is ready; `list [<org>]` lists them; `status <org>/<name>` says how one is. [`docs/HOSTED-API.md`](../docs/HOSTED-API.md#replicas-mason-replica) is the API. |
121
+ | `mason token create\|list\|revoke` | A hosted Mason service's CI tokens: `create --replica <org>/<name>` makes one for a replica, printed once, for a deploy from CI (`MASON_TOKEN`); `list [<org>]` lists them; `revoke <id>` revokes one. |
122
+ | `mason deploy [--replica <org>/<name>] [--dry-run] [--timeout 10m]` | Sends this project's files (the YAML under `mason/`: never `.env` or `.mason/`) to a replica on the hosted service (`--replica`, else `MASON_REPLICA`), after checking the project as `mason start` does, without connecting to any database, then waits until the replica runs it (exit 0), Mason there refuses it (its failure printed as it came, exit 1), or the timeout (exit 124: the deploy goes on, its id said). `--dry-run` prints each file, its size and its sha256, and sends nothing. The login is `MASON_TOKEN`, else `mason login`'s. |
118
123
 
119
124
  Every command documents itself: `mason --help` lists them, and `mason <command> --help` (or `mason help <command>`) says
120
- how to use one, with examples. Every command but `help` and `mcp` (whose stdout is the MCP protocol's) takes `--json`. A command means the project of the folder it
125
+ how to use one, with examples; `mason <command> --help --json` is its spec as data, and `mason help --json` every command's. Every command but `mcp` (whose stdout is the MCP protocol's) takes `--json`. A command means the project of the folder it
121
126
  runs in, or the nearest one above it.
122
127
 
123
128
  ## What `mason start` does
@@ -156,7 +161,7 @@ there (`mason status` lists them); `MASON_STORE_JSON_PATHS=off` turns that off.
156
161
  | Option | What it does |
157
162
  |---|---|
158
163
  | `--self-signed-tls` | The Postgres API requires TLS, from a certificate made for this project in `.mason/` (served again by every later start with the flag); clients connect with `sslmode=require`. |
159
- | `--port <n>` | The port the `mason` commands reach it on (default 4039, or the next free one). |
164
+ | `--ui-port <n>` | The port of the web page and the `mason` commands, on 127.0.0.1 (default 4039, or the next free one); `MASON_UI_PORT` on a server. `--port` is gone: it answers with the line to run and exit 2. |
160
165
  | `--attach` | Stay in the foreground: the terminal shows Mason's progress, and Ctrl-C stops it. |
161
166
 
162
167
  `--replication`, `--poll` and `--publication` are gone: each answers with the line to write at the top of the sources
@@ -178,7 +183,7 @@ what changed is written. A different database is refused: `mason destroy`, then
178
183
  `mason help agent` is the page an agent reads first, offline: the commands in the order a project takes them, every
179
184
  answer's shape, the exit codes and the failure codes. In short:
180
185
 
181
- - Every command but `help` and `mcp` takes `--json` and answers with **one JSON object on stdout**, always with
186
+ - Every command but `mcp` takes `--json` and answers with **one JSON object on stdout**, always with
182
187
  `next: [{ command, what }]` — the commands to run next, the same a person reads under `Next:`. The other keys are the
183
188
  command's own; `mason help <command>` names them on its `--json` line. Lists are wrapped (`{ tables: […], next }`).
184
189
  - A failure is `{ "error": { "code", "message", "why", "fix" }, "next": […] }` on stdout, and the **exit code** says it:
@@ -263,9 +268,15 @@ seeds it again.
263
268
  `mason doctor` checks all of this and says what is missing.
264
269
 
265
270
  - Node 22.13 or later. The SQL parser (`libpg-query`), the catalog (`PGlite`) and the engine (DuckDB) ship as
266
- WebAssembly or prebuilt binaries: no native build, no Python or compiler needed.
267
- - Memory: the engine may use half the container's memory limit by default, else half the machine's RAM
268
- (`MASON_DUCKDB_MEMORY=4GB` sets it; `mason doctor` says it). Sizing a server:
271
+ WebAssembly or prebuilt binaries: no native build, no Python or compiler needed. Mason's CI and the `mason-data`
272
+ image run Node 26.
273
+ - On Node 24 Mason runs, but a bug in Node 24's V8 (nodejs/node#66366, fixed in Node 26) can now and then abort it
274
+ with `Check failed: jit_page_->allocations_.erase(addr) == 1`, as a process exits or when the catalog's worker stops
275
+ after idling. `mason start` starts a run that died after it was ready again (not under `--attach`), and `mason status`
276
+ shows the restart. To avoid it, use Node 26, or a 22.x release from 22.13.
277
+ - Memory: by default the engine may use half the container's memory limit, else of the machine's RAM, or what leaves
278
+ 2.5 GiB beside it when that is less, never under 128 MiB (`MASON_DUCKDB_MEMORY=4GB` sets it; `mason doctor` says
279
+ it). Under 3 GiB, with it unset, Mason starts and warns that the system may stop it. Sizing a server:
269
280
  [deploy/mason-data/README.md#memory](https://github.com/embeddable-hq/mason-data/blob/main/deploy/mason-data/README.md#memory).
270
281
  - Postgres 13 or later. Each table needs a primary key, or a `key:` in the sources file (polling only).
271
282
 
@@ -19,3 +19,7 @@ export class ApiError extends Error {
19
19
  /** The code of a DISABLED `mason user`'s refused login or session (src/duck/users.ts): the listener answers it 28000, as
20
20
  * Postgres answers a role without LOGIN, never 28P01 — its password was right. */
21
21
  export const USER_DISABLED = "user_disabled";
22
+ /** The code of a `mason user`'s login or session refused for its ADDRESS (src/duck/users.ts `from`): the listener answers
23
+ * it 28000 before any password is checked, in the same words whether the user exists or not, as Postgres answers a
24
+ * connection no pg_hba.conf line takes. */
25
+ export const ADDRESS_NOT_ALLOWED = "address_not_allowed";
@@ -7,18 +7,65 @@
7
7
  *
8
8
  * Messages in: `{ id, op: "init", statements, optional? }` (start PGlite and run the DDL; the reply names the refused
9
9
  * statement, or the optional ones refused),
10
- * `{ id, op: "raw", bytes }` (frontend protocol bytes in, backend bytes out), `{ id, op: "query", sql }` (rows as
10
+ * `{ id, op: "raw", bytes, at? }` (frontend protocol bytes in, backend bytes out; `at`: the instant the FIRST message
11
+ * runs at, in microseconds since the epoch, `pinnedClock`), `{ id, op: "query", sql }` (rows as
11
12
  * objects, for the server's own lookups and the tests), `{ id, op: "close" }`. Every reply carries the id:
12
13
  * `{ id, ok: true, bytes | rows }` or `{ id, ok: false, message }`.
13
14
  */
14
15
  import { parentPort } from "node:worker_threads";
15
16
  import { PGlite } from "@electric-sql/pglite";
16
17
  import { citext } from "@electric-sql/pglite/contrib/citext";
18
+ import { instantWallMs, nowMicros } from "../../lib/instant.js";
17
19
  const port = parentPort;
18
20
  if (port === null)
19
21
  throw new Error("catalog-worker.ts runs on a worker thread only");
20
22
  const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
21
23
  let db = null;
24
+ /**
25
+ * THE CATALOG'S CLOCK IS THE SESSION'S TRANSACTION'S (GitHub issue 253, review r3 of PR 333): `bytes`' first message
26
+ * (`asSession`'s, which begins the request's transaction) runs with the wall clock PGlite reads held at `at`, the
27
+ * session's instant, and the rest with the real one. Postgres's transaction start is the instant its first message
28
+ * arrived (`xactStartTimestamp`), and every reader of it — now(), CURRENT_TIMESTAMP and the other keywords, a
29
+ * relative word (`'now'`, `'today'`) however it is spelt, computed or bound — reads that one value, so the catalog's
30
+ * transaction instant IS the session's and no statement is rewritten for it. `statement_timestamp()` and
31
+ * `clock_timestamp()` read the real clock, as on Postgres. PGlite's wall clock is `Date.now()`
32
+ * (emscripten's `clock_time_get(CLOCK_REALTIME)`), on this thread alone; the requests are one at a time.
33
+ * The instant is MICROSECONDS, as Postgres's (src/lib/instant.ts, GitHub issues 372, 363): `at` is handed to PGlite
34
+ * as the value it reads back as exactly that microsecond (`instantWallMs`), and the real clock after it is this
35
+ * thread's microsecond clock (`nowMicros`), floored at `at`: the two threads' clocks may disagree inside a
36
+ * millisecond, and `statement_timestamp()` must never read earlier than the transaction's start, as on Postgres.
37
+ */
38
+ async function pinnedClock(instance, bytes, at) {
39
+ // a frame: its type byte, then its length (its own four bytes included)
40
+ const first = 1 + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1);
41
+ const wall = Date.now;
42
+ const pinned = instantWallMs(at);
43
+ let head, tail;
44
+ try {
45
+ Date.now = () => pinned;
46
+ head = new Uint8Array(await instance.execProtocolRaw(bytes.subarray(0, first))); // a copy: PGlite may reuse its buffer
47
+ Date.now = () => instantWallMs(Math.max(at, nowMicros(wall)));
48
+ tail = await instance.execProtocolRaw(bytes.subarray(first));
49
+ }
50
+ finally {
51
+ Date.now = wall;
52
+ }
53
+ const out = new Uint8Array(head.length + tail.length);
54
+ out.set(head, 0);
55
+ out.set(tail, head.length);
56
+ return out;
57
+ }
58
+ /** a request run at no instant of a session's (the DDL, a parameter's input function): the real clock, in microseconds */
59
+ async function microsClock(instance, bytes) {
60
+ const wall = Date.now;
61
+ try {
62
+ Date.now = () => instantWallMs(nowMicros(wall));
63
+ return await instance.execProtocolRaw(bytes);
64
+ }
65
+ finally {
66
+ Date.now = wall;
67
+ }
68
+ }
22
69
  port.on("message", (m) => {
23
70
  void (async () => {
24
71
  try {
@@ -63,7 +110,7 @@ port.on("message", (m) => {
63
110
  case "raw": {
64
111
  if (db === null)
65
112
  throw new Error("the catalog is not started");
66
- const bytes = await db.execProtocolRaw(m.bytes);
113
+ const bytes = m.at === undefined ? await microsClock(db, m.bytes) : await pinnedClock(db, m.bytes, m.at);
67
114
  const copy = new Uint8Array(bytes); // PGlite may reuse its buffer: a copy of our own is transferred
68
115
  port.postMessage({ id: m.id, ok: true, bytes: copy }, [copy.buffer]);
69
116
  return;
@@ -38,7 +38,7 @@ import { runnable } from "../../lib/runnable.js";
38
38
  import { NO_ROLE_CATALOG } from "../../duck/grants.js";
39
39
  import { renderWhere, tableSql } from "../../duck/row-filter.js";
40
40
  import { cstr, frame, splitFrames } from "./protocol.js";
41
- import { PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
41
+ import { HELD_ROW_INPUT, HELD_TYPE_INPUT, madeTypeName, PG_API_TYPE_SPELLING, typeNameParts } from "./schema.js";
42
42
  /** Ten minutes: long enough to outlive a tool's startup burst and a person's pause between two queries. */
43
43
  const DEFAULT_IDLE_MS = 10 * 60_000;
44
44
  /** A catalog statement's deadline (`MASON_PG_CATALOG_TIMEOUT_MS`): a tool's introspection answers in milliseconds; a
@@ -117,12 +117,19 @@ export function catalogDdl(schema) {
117
117
  for (const name of [...new Set(schema.tables.map((t) => t.schema).filter((x) => x !== undefined && x !== "public"))]) {
118
118
  statements.push(`CREATE SCHEMA IF NOT EXISTS ${ident(name)};`);
119
119
  }
120
+ // THE SOURCE'S COLLATIONS (src/duck/collation.ts), before the tables: each the catalog lacks, declared under its name with
121
+ // the source's provider, locale and determinism (a locale PGlite cannot open declared on its root locale: what it
122
+ // MEANS is the source's row of its name, read by the resolver, never this one's), so a column's COLLATE and a
123
+ // statement's resolve as on the source. In pg_catalog, public, a table's schema, or a column's collation's schema
124
+ statements.push(...collationDdl(schema));
120
125
  // a source enum a column has: the type, its labels in their declared order, before the tables —
121
126
  // spelled as the source's format_type spells it (`mood`, `other.level`, `"Odd"`), its schema made first
122
127
  const enums = new Map();
128
+ // one declaration a type, the FIRST column's: the synced tables come first, and src/duck/sync.ts hands a generated
129
+ // table's column its synced type's labels (one source of an enum's labels), so no later column's can replace them
123
130
  for (const t of schema.tables)
124
131
  for (const c of t.columns)
125
- if (c.enumLabels !== undefined && c.spelled !== undefined)
132
+ if (c.enumLabels !== undefined && c.spelled !== undefined && !enums.has(c.spelled.replace(/\[\]$/, "")))
126
133
  enums.set(c.spelled.replace(/\[\]$/, ""), c.enumLabels);
127
134
  for (const [type, labels] of enums) {
128
135
  const qualifier = typeNameParts(type).schema;
@@ -130,10 +137,22 @@ export function catalogDdl(schema) {
130
137
  statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
131
138
  statements.push(`CREATE TYPE ${type} AS ENUM (${labels.map(literal).join(", ")});`);
132
139
  }
140
+ // a type a database made, held as its text: its name, the text's input and output, and nothing over it
141
+ const made = new Map(schema.tables.flatMap((t) => t.columns.filter((c) => c.made !== undefined).map((c) => [madeTypeName(c.spelled), c.made])));
142
+ [...made].forEach(([type, kind], i) => {
143
+ const input = kind === "row" ? HELD_ROW_INPUT : HELD_TYPE_INPUT;
144
+ const qualifier = typeNameParts(type).schema;
145
+ if (qualifier !== null && qualifier !== '"public"' && qualifier !== "public")
146
+ statements.push(`CREATE SCHEMA IF NOT EXISTS ${qualifier};`);
147
+ statements.push(`CREATE TYPE ${type};`, `CREATE FUNCTION pg_catalog.${input}${i}(cstring) RETURNS ${type} LANGUAGE internal IMMUTABLE STRICT AS 'textin';`, `CREATE FUNCTION pg_catalog.mason_held_out${i}(${type}) RETURNS cstring LANGUAGE internal IMMUTABLE STRICT AS 'textout';`, `CREATE TYPE ${type} (INPUT = pg_catalog.${input}${i}, OUTPUT = pg_catalog.mason_held_out${i}, LIKE = text);`);
148
+ });
133
149
  for (const table of schema.tables) {
134
150
  const lines = [];
135
- for (const c of table.columns)
136
- lines.push(`${ident(c.name)} ${c.spelled ?? PG_API_TYPE_SPELLING[c.type]}${c.notNull === true ? " NOT NULL" : ""}`);
151
+ for (const c of table.columns) {
152
+ // a column's own collation: the source's, so Postgres's analysis orders it as the source does
153
+ const collate = c.collation !== undefined ? ` COLLATE ${ident(c.collation.schema)}.${ident(c.collation.name)}` : "";
154
+ lines.push(`${ident(c.name)} ${c.made !== undefined ? `${madeTypeName(c.spelled)}${c.spelled.endsWith("[]") ? "[]" : ""}` : c.spelled ?? PG_API_TYPE_SPELLING[c.type]}${collate}${c.notNull === true ? " NOT NULL" : ""}`);
155
+ }
137
156
  if (table.primaryKey.length > 0)
138
157
  lines.push(`PRIMARY KEY (${table.primaryKey.map(ident).join(", ")})`);
139
158
  statements.push(`CREATE TABLE ${qualified(table)} (\n ${lines.join(",\n ")}\n);`);
@@ -170,6 +189,38 @@ export function catalogDdl(schema) {
170
189
  }
171
190
  return [...statements, ...constraints, ...sourceTypeNames(schema)];
172
191
  }
192
+ /**
193
+ * THE SOURCE'S COLLATIONS THE CATALOG LACKS, declared (one DO block: ~800 ICU collations in tens of milliseconds): each
194
+ * under its schema and name, by the source's provider, locale and determinism, else — a locale PGlite's ICU or libc
195
+ * cannot open — the same name on the root locale (ICU), C (libc) or C (builtin), which its analysis treats alike: the
196
+ * resolver reads what a collation means from the SOURCE's row of its name (src/duck/collation.ts), never this one. Only
197
+ * where a name can be met: pg_catalog, public, a table's schema, and a synced column's collation's own schema.
198
+ */
199
+ export function collationDdl(schema) {
200
+ const used = schema.tables.flatMap((t) => t.columns.flatMap((c) => c.collation !== undefined ? [c.collation] : []));
201
+ const schemas = new Set(["pg_catalog", "public", ...schema.tables.map((t) => t.schema ?? "public"), ...used.map((c) => c.schema)]);
202
+ const declared = (schema.collations ?? []).filter((c) => c.provider !== "d" && schemas.has(c.schema));
203
+ if (declared.length === 0)
204
+ return [];
205
+ const out = [];
206
+ for (const s of new Set(declared.map((c) => c.schema)))
207
+ if (s !== "pg_catalog" && s !== "public")
208
+ out.push(`CREATE SCHEMA IF NOT EXISTS ${ident(s)};`);
209
+ // ONE INSERT of the rows, never a CREATE COLLATION each: a source lists about 1,600 (every ICU locale and every libc
210
+ // one the OS has), and a CREATE each took the catalog's start past its deadline (about 18 ms each, measured; the
211
+ // INSERT, 260 ms for 1,600). The catalog only ANALYSES under them: what a statement orders by is the synced data's, so
212
+ // a locale PGlite has no data for is never opened. A name the catalog has already (`C`, `und-x-icu`) is the source's
213
+ // too; the OIDs are past any the catalog makes, so none collides
214
+ const rows = declared.map((c) => ({ schema: c.schema, name: c.name, provider: c.provider, collate: c.collate, locale: c.locale, deterministic: c.deterministic }));
215
+ out.push(`INSERT INTO pg_catalog.pg_collation (oid, collname, collnamespace, collowner, collprovider, collisdeterministic, collencoding, collcollate, collctype, colllocale)
216
+ SELECT (greatest((SELECT max(oid)::bigint FROM pg_catalog.pg_collation), 3000000000) + row_number() OVER ())::oid, c.name, n.oid, 10, c.provider, c.deterministic,
217
+ CASE WHEN c.provider = 'c' THEN 6 ELSE -1 END, CASE WHEN c.provider = 'c' THEN c."collate" END, CASE WHEN c.provider = 'c' THEN c."collate" END,
218
+ CASE WHEN c.provider <> 'c' THEN coalesce(c.locale, c."collate") END
219
+ FROM json_to_recordset(${literal(JSON.stringify(rows))}) AS c(schema text, name text, provider "char", "collate" text, locale text, deterministic boolean)
220
+ JOIN pg_catalog.pg_namespace n ON n.nspname = c.schema
221
+ WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_collation x WHERE x.collnamespace = n.oid AND x.collname = c.name AND x.collencoding IN (-1, 6));`);
222
+ return out;
223
+ }
173
224
  /**
174
225
  * THE NAME OF A TYPE BY THE OID THE WIRE SENDS FOR IT. A column of a type the source made (an enum, a citext) is
175
226
  * sent with the SOURCE's OID (src/duck/plan.ts `userTypes`: what the source's own clients cached), which in this
@@ -190,14 +241,19 @@ export function sourceTypeNames(schema) {
190
241
  if (c.typeOid === undefined || c.spelled === undefined)
191
242
  continue;
192
243
  // an array column's own OID is its array type's; an element's array type is the element's spelling with []
193
- names.set(c.typeOid, c.spelled);
194
- if (c.typeArrayOid !== undefined && !c.spelled.endsWith("[]"))
195
- names.set(c.typeArrayOid, `${c.spelled}[]`);
244
+ const spelled = c.made !== undefined ? `${madeTypeName(c.spelled)}${c.spelled.endsWith("[]") ? "[]" : ""}` : c.spelled;
245
+ names.set(c.typeOid, spelled);
246
+ if (c.typeArrayOid !== undefined && !spelled.endsWith("[]"))
247
+ names.set(c.typeArrayOid, `${spelled}[]`);
196
248
  }
197
249
  }
198
250
  // with no type the source made, every OID the wire sends is Postgres's: the wire's name is format_type's
199
- if (names.size === 0)
200
- return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
251
+ // (format_type is declared all the same: a catalog statement's every unqualified call of it is spliced to this one,
252
+ // server.ts userSpliced, since the session's search_path puts pg_catalog first)
253
+ if (names.size === 0) {
254
+ return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`,
255
+ `CREATE OR REPLACE FUNCTION public.format_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$ SELECT pg_catalog.format_type($1, $2) $$;`];
256
+ }
201
257
  const values = [...names].map(([oid, spelled]) => `(${oid}::oid, ${literal(spelled)})`).join(", ");
202
258
  return [`CREATE OR REPLACE FUNCTION public.mason_wire_type(oid, integer) RETURNS text LANGUAGE sql STABLE AS $$
203
259
  SELECT coalesce((SELECT pg_catalog.format_type(pg_catalog.to_regtype(v.spelled), $2) FROM (VALUES ${values}) AS v(source, spelled) WHERE v.source = $1),
@@ -374,11 +430,77 @@ export function unwrapRole(frames) {
374
430
  const end = n >= 2 && inner[n - 1].type === "Z" && inner[n - 2].type === "C" ? n - 2 : n;
375
431
  return { frames: inner.slice(0, end), setRoleRefused: null };
376
432
  }
377
- /** the placeholder parameter a session's settings ride in, for one request of its (`asSession`): named anew by each
378
- * process, so no name a tool writes is it — `current_setting('mason.session')` answered the carrier's JSON where
379
- * Postgres (and `SHOW`, and the same beside a copied table) answers 42704 (a review finding of #46). A placeholder
380
- * stays defined once SET, so the name, not a RESET, is what keeps it out of reach. */
381
- export const SESSION_SETTINGS = `mason_${randomBytes(8).toString("hex")}.session`;
433
+ /** the prefix of the placeholders a session's request rides in (`asSession`): named anew by each process, so no name a
434
+ * tool writes is one of them — `current_setting('mason.session')` answered the carrier's JSON where Postgres (and
435
+ * `SHOW`, and the same beside a copied table) answers 42704 (a review finding of #46). A placeholder stays defined once
436
+ * SET, so the name, not a RESET, is what keeps it out of reach. */
437
+ const CARRIER = `mason_${randomBytes(8).toString("hex")}`;
438
+ /** the placeholder a session's settings ride in, for one request of its (`asSession`) */
439
+ export const SESSION_SETTINGS = `${CARRIER}.session`;
440
+ /** the catalog's own state, which its start sets and every request of a session leaves it in: UTC, `public` first (Mason's
441
+ * own reads that name no schema find its overrides), ISO with month-day-year */
442
+ export const CATALOG_BASE = { timeZone: "UTC", searchPath: "public, pg_catalog", dateStyle: "ISO, MDY" };
443
+ /**
444
+ * A search_path as SET writes it, read as Postgres's `SplitIdentifierString` reads the parameter's text: a list split at
445
+ * the commas outside double quotes, an unquoted name folded to lower case, a quoted one as written (`"$user"` kept); an
446
+ * empty list is `''`. Every name is written quoted, so no text of a session's reaches the SQL but as an identifier.
447
+ */
448
+ export function searchPathSql(path) {
449
+ const names = [];
450
+ let i = 0;
451
+ while (i < path.length) {
452
+ while (i < path.length && /[\s,]/.test(path[i]))
453
+ i++;
454
+ if (i >= path.length)
455
+ break;
456
+ let name = "";
457
+ if (path[i] === '"') {
458
+ i++;
459
+ while (i < path.length) {
460
+ if (path[i] === '"') {
461
+ if (path[i + 1] === '"') {
462
+ name += '"';
463
+ i += 2;
464
+ continue;
465
+ }
466
+ i++;
467
+ break;
468
+ }
469
+ name += path[i++];
470
+ }
471
+ }
472
+ else {
473
+ while (i < path.length && path[i] !== ",")
474
+ name += path[i++];
475
+ name = name.trim().toLowerCase();
476
+ }
477
+ while (i < path.length && path[i] !== ",")
478
+ i++;
479
+ names.push(name);
480
+ }
481
+ return names.length === 0 ? "''" : names.map((n) => `"${n.replaceAll('"', '""')}"`).join(", ");
482
+ }
483
+ /** THE CATALOG'S ONE APPLIER of a session's state: its SETs, three statements (`local`: for the transaction they run
484
+ * in, as a lookup of Mason's sets them inside one batch) */
485
+ export function sessionSetSql(state, local = false) {
486
+ const set = local ? "SET LOCAL" : "SET";
487
+ return `${set} search_path = ${searchPathSql(state.searchPath)}; ${set} TimeZone = ${quoteLiteral(state.timeZone)}; ${set} DateStyle = ${quoteLiteral(state.dateStyle)}`;
488
+ }
489
+ /** the catalog's base set back (`CATALOG_BASE`), after a request that applied a session's state */
490
+ export const CATALOG_BASE_SQL = sessionSetSql(CATALOG_BASE);
491
+ /** how many CommandCompletes `sessionSetSql` answers: one a SET */
492
+ const SESSION_SETS = 3;
493
+ /**
494
+ * LATEST(x ORDER BY k…), THE AGGREGATE MASON ADDS: the x of the row its ORDER BY sorts last, by Postgres's sort rules.
495
+ * Declared in the catalog so that Postgres's analysis types and names it (an AGGREF of its argument's type), and by the
496
+ * oracle's real Postgres from this ONE definition, so the two compare like with like. Its transition keeps the latest
497
+ * row's value, a NULL included (not STRICT); over no rows it is NULL. In pg_catalog, so no search_path hides it. The
498
+ * engine never runs this: src/duck/rewrite.ts `latest` rewrites it, and refuses it without an ORDER BY (`latest-order`).
499
+ */
500
+ export const LATEST_SQL = [
501
+ "CREATE OR REPLACE FUNCTION pg_catalog.mason_latest_step(anyelement, anyelement) RETURNS anyelement LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT $2 $$",
502
+ "CREATE OR REPLACE AGGREGATE pg_catalog.latest(anyelement) (SFUNC = pg_catalog.mason_latest_step, STYPE = anyelement)",
503
+ ];
382
504
  /**
383
505
  * THE CATALOG'S `current_setting(name[, missing_ok])` IS THE SESSION'S (GitHub issue 40: after `SET TimeZone` it answered
384
506
  * the catalog's UTC, where `SHOW TimeZone` answered the session's): the value the session holds under that name, from
@@ -397,30 +519,40 @@ export function currentSettingSql() {
397
519
  ];
398
520
  }
399
521
  /**
400
- * A SESSION'S CATALOG REQUEST WITH ITS SETTINGS: `SET <SESSION_SETTINGS> = '<its settings as JSON>'` before the request's
401
- * frames and `RESET` after, in ONE message, as `asRole` wraps a role (around it, when the session has one) — the catalog
402
- * runs one request at a time and a message whole, so no other session's request reads these settings, and none of this
403
- * session's runs without them. A Sync is added before the RESET where the request has none (ignore_till_sync: a
404
- * setting left set would be every session's). `settings` are the listener's `sessionSetting`, never read here. The
405
- * catalog's search_path is pinned in the same SET (`public, pg_catalog`): the overrides of `current_setting` and
406
- * `version()` win only while public comes first, and the request must never depend on what the catalog was left with.
522
+ * A SESSION'S CATALOG REQUEST UNDER ITS STATE AND WITH ITS SETTINGS, IN A TRANSACTION OF ITS OWN: `BEGIN`, the session's
523
+ * TimeZone, search_path and DateStyle (`sessionSetSql`) and `SET <SESSION_SETTINGS> = '<its settings as JSON>'` before
524
+ * the request's frames; `ROLLBACK` and the catalog's base (`CATALOG_BASE`) set back after, in ONE message, as `asRole`
525
+ * wraps a role (around it, when the session has one) — the catalog runs one request at a time and a message whole, so
526
+ * no other session's request reads these, and none of this session's runs without them. A Sync is added before the
527
+ * restore where the request has none (ignore_till_sync: a setting left set would be every session's). The `BEGIN`'s
528
+ * message is run at the session's transaction instant (`PgCatalog.run`'s `at`, catalog-worker.ts `pinnedClock`), so
529
+ * the transaction it begins starts at that instant and every reader of the transaction's start in the request —
530
+ * now(), the clock keywords, a relative word however it reaches a date or time — reads the session's (GitHub issue
531
+ * 253, review r3 of PR 333). The ROLLBACK undoes anything the request set, whether it succeeded or failed.
532
+ * `state` and `settings` are the listener's (`sessionState`, `sessionSetting`), never read here. The search_path is the
533
+ * session's as written, pg_catalog first unless it lists it, as on Postgres: the overrides of `version()`,
534
+ * `current_setting` and `format_type` are reached by the splice of every call of them (server.ts userSpliced), never by
535
+ * the order of the path.
407
536
  */
408
- export function asSession(message, settings) {
537
+ export function asSession(message, state, settings) {
409
538
  const own = Buffer.from(message.buffer, message.byteOffset, message.byteLength);
410
539
  const frames = splitFrames(own);
411
540
  const synced = frames.some((f) => f.type === "S") || frames.at(-1)?.type === "Q";
412
541
  const json = JSON.stringify(Object.fromEntries(settings));
413
- return Buffer.concat([frame("Q", cstr(`SET search_path = public, pg_catalog; SET ${SESSION_SETTINGS} = ${quoteLiteral(json)}`)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr(`RESET ${SESSION_SETTINGS}`))]);
542
+ const before = `BEGIN; ${sessionSetSql(state)}; SET ${SESSION_SETTINGS} = ${quoteLiteral(json)}`;
543
+ const after = `ROLLBACK; RESET ${SESSION_SETTINGS}; ${CATALOG_BASE_SQL}`;
544
+ return Buffer.concat([frame("Q", cstr(before)), own, ...(synced ? [] : [frame("S")]), frame("Q", cstr(after))]);
414
545
  }
415
- /** The reply to an `asSession` message, less the SETs' and the RESET's own replies (two CommandCompletes and a
416
- * ReadyForQuery first, a CommandComplete and a ReadyForQuery last). A SET the catalog refused is a Mason bug, said. */
546
+ /** The reply to an `asSession` message, less the applier's and the restore's own replies (a CommandComplete a SET and a
547
+ * ReadyForQuery, first and last). A SET the catalog refused is a Mason bug, said. */
417
548
  export function unwrapSession(frames) {
418
- const ok = frames[0]?.type === "C" && frames[1]?.type === "C" && frames[2]?.type === "Z";
549
+ const sets = SESSION_SETS + 2;
550
+ const run = (from) => frames.slice(from, from + sets).every((f) => f.type === "C") && frames[from + sets]?.type === "Z";
419
551
  const n = frames.length;
420
- if (!ok || n < 5 || frames[n - 1].type !== "Z" || frames[n - 2].type !== "C") {
552
+ if (n < 2 * (sets + 1) || !run(0) || !run(n - sets - 1)) {
421
553
  throw new Error(`the catalog Postgres did not take the session's settings around a request (${frames.map((f) => f.type).join(", ")}): report it`);
422
554
  }
423
- return frames.slice(3, n - 2);
555
+ return frames.slice(sets + 1, n - sets - 1);
424
556
  }
425
557
  /**
426
558
  * WHAT A LEFT-OUT DEFAULT OR VIEW IS SAID AS, in one line (the test server's log printed every left-out source
@@ -529,12 +661,14 @@ export class PgCatalog {
529
661
  return this.#db !== null && this.#db.alive;
530
662
  }
531
663
  /** Runs raw frontend protocol bytes — one message or several (Parse, Bind, Describe, Execute, Sync in one buffer
532
- * works) — and returns the backend's raw reply, starting the instance when needed. */
533
- run(message) {
664
+ * works) — and returns the backend's raw reply, starting the instance when needed. `at`: the instant the first
665
+ * message runs at, the start of the transaction it begins (`asSession`; catalog-worker.ts `pinnedClock`), in
666
+ * MICROSECONDS since the epoch as Postgres's (src/lib/instant.ts), which the catalog reads back exactly. */
667
+ run(message, at) {
534
668
  return this.#enqueue(async () => {
535
669
  const db = await this.#ensureStarted();
536
670
  const copy = new Uint8Array(message);
537
- const reply = await this.#ask(db, { op: "raw", bytes: copy }, "a statement", [copy.buffer]);
671
+ const reply = await this.#ask(db, { op: "raw", bytes: copy, ...(at !== undefined ? { at } : {}) }, "a statement", [copy.buffer]);
538
672
  this.#touch();
539
673
  return reply.bytes ?? new Uint8Array(0);
540
674
  });
@@ -613,7 +747,7 @@ export class PgCatalog {
613
747
  const t0 = Date.now();
614
748
  const db = new CatalogWorker();
615
749
  try {
616
- const started = await this.#ask(db, { op: "init", statements: ["SET TimeZone = 'UTC'", ...catalogDdl(schema), ...catalogRoles(schema, roles), ...this.#setup],
750
+ const started = await this.#ask(db, { op: "init", statements: [CATALOG_BASE_SQL, ...catalogDdl(schema), ...catalogRoles(schema, roles), ...this.#setup],
617
751
  optional: [...catalogDefaults(schema), ...catalogViews(schema, roles), ...this.#policies] }, "its start");
618
752
  // each left-out default or view: one line, once per schema (the instance starts again after every idle stop)
619
753
  for (const r of started.refused ?? []) {
@@ -15,6 +15,7 @@
15
15
  import * as fs from "node:fs";
16
16
  import { parsePort } from "../../lib/port.js";
17
17
  import { plainListener } from "./plain.js";
18
+ import { parseProxyFrom } from "./proxy.js";
18
19
  export const DEFAULT_PG_PORT = 5433;
19
20
  /** The listener's address from `env`, or the problem with its port (`parsePort`'s words). */
20
21
  export function pgListenerAddress(env, apiHost) {
@@ -24,8 +25,8 @@ export function pgListenerAddress(env, apiHost) {
24
25
  return { host: env.MASON_PG_HOST || apiHost, port: port.port };
25
26
  }
26
27
  /** Where and how the listener listens, from `env` (`MASON_PG_HOST`, `MASON_PG_PORT`, `MASON_PG_TLS_CERT`,
27
- * `MASON_PG_TLS_KEY`, `MASON_PG_PLAIN`), or why it cannot: a bad port, half a certificate, plain off loopback. Reads no
28
- * file, so a start can be refused before anything is made. */
28
+ * `MASON_PG_TLS_KEY`, `MASON_PG_PLAIN`, `MASON_PG_PROXY_FROM`), or why it cannot: a bad port, half a certificate, plain
29
+ * off loopback, a proxy's address that is no address. Reads no file, so a start can be refused before anything is made. */
29
30
  export function pgListenerSettings(env, apiHost) {
30
31
  const address = pgListenerAddress(env, apiHost);
31
32
  if ("problem" in address)
@@ -36,7 +37,14 @@ export function pgListenerSettings(env, apiHost) {
36
37
  const plain = plainListener(address.host, env.MASON_PG_PLAIN, Boolean(cert && key));
37
38
  if (plain !== null)
38
39
  return plain;
39
- return { ...address, tls: cert && key ? { cert, key } : null };
40
+ const proxyFrom = parseProxyFrom(env.MASON_PG_PROXY_FROM);
41
+ if (proxyFrom !== null && "problem" in proxyFrom) {
42
+ return { problem: proxyFrom.problem, kind: "proxy_from",
43
+ why: "a PROXY header names the client every statement is recorded as, so it is believed only from the addresses this names",
44
+ fix: "Name the addresses of your TCP proxy or load balancer alone, comma-separated (MASON_PG_PROXY_FROM=10.0.0.0/16,10.1.0.0/16), " +
45
+ "or unset it to read no PROXY header — docs/POSTGRES-API.md \"Behind a TCP proxy\"" };
46
+ }
47
+ return { ...address, tls: cert && key ? { cert, key } : null, ...(proxyFrom !== null ? { proxyFrom } : {}) };
40
48
  }
41
49
  /** The certificate and key the settings name, read; or why not. */
42
50
  export function readPgTls(files) {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * THE POSTGRES API BEHIND A TCP PROXY (`MASON_PG_PROXY_FROM`; docs/POSTGRES-API.md "Behind a TCP proxy"). Behind a TCP
3
+ * load balancer or proxy (an AWS NLB, HAProxy, an edge of one's own) every connection's peer is the proxy, so the
4
+ * request log could not say who sent what. A proxy that speaks the PROXY protocol v2 (haproxy's proxy-protocol.txt,
5
+ * section 2.2) begins each connection with a binary header naming the client it carries. A header is a CLAIM about
6
+ * where a connection came from, so it is believed only from an address the operator named:
7
+ * - a connection from a named address MUST begin with a valid v2 header, or it is refused;
8
+ * - a connection from any other address is read as before, and a PROXY header from it is refused, never believed;
9
+ * - unset, nothing is named and nothing changes.
10
+ * The header is read whole before anything else on the connection is looked at (an SSLRequest, a direct TLS start, a
11
+ * startup packet, a CancelRequest), and bounded in size (`MAX_PROXY_BODY`) and in time (`PROXY_HEADER_MS`).
12
+ *
13
+ * THE CLIENT'S ADDRESS HAS ONE READER (`clientAddress`): the source a named proxy's header carries, else the socket's
14
+ * peer, a v4-mapped IPv6 address shown as IPv4 (`shownAddress`). The request log's `client_addr` and the session's log
15
+ * line read it, never the socket.
16
+ */
17
+ import { ALL_END, cidrOf, covers, inRanges, ipv6Text, rangeOf, shownAddress, V4_END, V4_START } from "../../lib/cidr.js";
18
+ export { shownAddress };
19
+ /** the v2 header's first 12 bytes (section 2.2) */
20
+ export const PROXY_V2_SIGNATURE = Buffer.from([0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a]);
21
+ /** how a v1 (text) header begins */
22
+ const PROXY_V1_START = Buffer.from("PROXY ", "latin1");
23
+ /** The most a header may carry past its 16 fixed bytes: the largest address block of the spec (two UNIX paths, 216
24
+ * bytes), which holds an IPv6 pair (36) and 180 bytes of TLVs. A header that says more is refused. */
25
+ export const MAX_PROXY_BODY = 216;
26
+ /** How long a named proxy has to send its whole header once it has connected. */
27
+ export const PROXY_HEADER_MS = 5_000;
28
+ /** the setting, as every message names it */
29
+ export const PROXY_SETTING = "MASON_PG_PROXY_FROM";
30
+ /**
31
+ * The PROXY v2 header at the start of `b`, read as section 2.2 says: the signature, version 2, the LOCAL or PROXY
32
+ * command, TCP over IPv4 (0x11) or IPv6 (0x21) for a PROXY command, its length at most MAX_PROXY_BODY, and its TLVs
33
+ * skipped, each held inside the block. Asked again as bytes arrive: a header split across reads is `more` until it is
34
+ * whole, and a wrong byte is refused as soon as it arrives. A v1 (text) header is refused by name.
35
+ */
36
+ export function readProxyHeader(b) {
37
+ for (let i = 0; i < Math.min(b.length, PROXY_V2_SIGNATURE.length); i++) {
38
+ if (b[i] === PROXY_V2_SIGNATURE[i])
39
+ continue;
40
+ const v1 = Math.min(b.length, PROXY_V1_START.length);
41
+ if (b.subarray(0, v1).every((c, j) => c === PROXY_V1_START[j])) {
42
+ return v1 < PROXY_V1_START.length ? { more: true } : { refused: "a PROXY protocol v1 (text) header: Mason reads version 2 alone (HAProxy's send-proxy-v2)" };
43
+ }
44
+ return { refused: "no PROXY protocol v2 header: its first 12 bytes are not the signature" };
45
+ }
46
+ if (b.length < 16)
47
+ return { more: true };
48
+ const version = b[12] >> 4, command = b[12] & 0x0f;
49
+ if (version !== 2)
50
+ return { refused: `a PROXY header of version ${version}: Mason reads version 2` };
51
+ if (command > 1)
52
+ return { refused: `a PROXY header with the command 0x${command.toString(16)}: only LOCAL (0x0) and PROXY (0x1) are defined` };
53
+ const family = b[13], length = (b[14] << 8) | b[15];
54
+ if (length > MAX_PROXY_BODY)
55
+ return { refused: `a PROXY header of ${length} bytes past its first 16: Mason reads at most ${MAX_PROXY_BODY}` };
56
+ if (b.length < 16 + length)
57
+ return { more: true };
58
+ // LOCAL: the proxy's own connection (a health check); its address block is discarded, as the spec says
59
+ if (command === 0)
60
+ return { header: { command: "LOCAL", source: null }, length: 16 + length };
61
+ const body = b.subarray(16, 16 + length);
62
+ const addresses = family === 0x11 ? 12 : family === 0x21 ? 36 : null;
63
+ if (addresses === null) {
64
+ return { refused: `a PROXY header for family and transport 0x${family.toString(16).padStart(2, "0")}: Mason reads TCP over IPv4 (0x11) or IPv6 (0x21), and an UNSPEC one with LOCAL alone` };
65
+ }
66
+ if (length < addresses)
67
+ return { refused: `a PROXY header of ${length} bytes past its first 16, too few for its addresses (${addresses})` };
68
+ const source = family === 0x11
69
+ ? { address: Array.from(body.subarray(0, 4)).join("."), port: (body[8] << 8) | body[9] }
70
+ : { address: ipv6Text(body.subarray(0, 16)), port: (body[32] << 8) | body[33] };
71
+ // the TLVs (type, a 2-byte length, the value): skipped, each held inside the block
72
+ for (let at = addresses; at < length;) {
73
+ if (length - at < 3)
74
+ return { refused: "a PROXY header whose last TLV is cut short" };
75
+ at += 3 + ((body[at + 1] << 8) | body[at + 2]);
76
+ if (at > length)
77
+ return { refused: "a PROXY header with a TLV past its end" };
78
+ }
79
+ return { header: { command: "PROXY", source }, length: 16 + length };
80
+ }
81
+ /** Whether a connection's first byte is a PROXY header's, v2 or v1: never a startup packet's (its length's first byte is
82
+ * 0) nor a TLS ClientHello's (0x16). */
83
+ export const startsProxyHeader = (first) => first === PROXY_V2_SIGNATURE[0] || first === PROXY_V1_START[0];
84
+ /**
85
+ * THE CLIENT'S ADDRESS, THE ONE READER OF IT: the source the header of a named proxy carries, else the socket's peer
86
+ * (a LOCAL header's connection is the proxy's own), shown as Mason shows an address (`shownAddress`); null when the
87
+ * peer cannot be read.
88
+ */
89
+ export function clientAddress(peer, header) {
90
+ return header?.source?.address ?? shownAddress(peer);
91
+ }
92
+ /** Whether `address` (as `shownAddress` shows one, or in any spelling of the same address) is among those `from` names. */
93
+ export const proxyNames = (from, address) => inRanges(from.ranges, address);
94
+ /** what a prefix of 0 is refused with: a header believed from every address lets any client claim to be any other */
95
+ const EVERY = "so any client could claim to be any other: name the proxy's own addresses";
96
+ /**
97
+ * `MASON_PG_PROXY_FROM` read: null when it is unset or empty (no header is believed from anyone, as before), the
98
+ * addresses it names, or why not: every entry that is not an address or a CIDR, each said with what is wrong, or a list
99
+ * whose entries TOGETHER name every IPv4 or every IPv6 address (read as places, never spellings), under which any
100
+ * client could claim to be any other.
101
+ */
102
+ export function parseProxyFrom(value) {
103
+ if (value === undefined || value.trim() === "")
104
+ return null;
105
+ const entries = value.split(",").map((e) => e.trim());
106
+ const ranges = [], wrong = [];
107
+ for (const e of entries) {
108
+ if (e === "") {
109
+ wrong.push("an empty entry between two commas");
110
+ continue;
111
+ }
112
+ const c = cidrOf(e, EVERY);
113
+ if ("problem" in c)
114
+ wrong.push(c.problem);
115
+ else
116
+ ranges.push(rangeOf(c));
117
+ }
118
+ const setting = `${PROXY_SETTING}=${JSON.stringify(value)}`;
119
+ if (wrong.length)
120
+ return { problem: `${setting}: ${wrong.join("; ")}` };
121
+ const every = [
122
+ ...(covers(ranges, V4_START, V4_END) ? ["every IPv4 address (0.0.0.0/0)"] : []),
123
+ // IPv6's every address but the v4-mapped ones, which are IPv4's and judged above
124
+ ...(covers(ranges, 0n, V4_START - 1n) && covers(ranges, V4_END + 1n, ALL_END) ? ["every IPv6 address (::/0)"] : []),
125
+ ];
126
+ if (every.length)
127
+ return { problem: `${setting}: its entries together name ${every.join(" and ")}, so any client could claim to be any other: name the proxy's own addresses` };
128
+ return { entries, ranges };
129
+ }