@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
package/README.md CHANGED
@@ -114,7 +114,10 @@ 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 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
121
 
119
122
  Every command documents itself: `mason --help` lists them, and `mason <command> --help` (or `mason help <command>`) says
120
123
  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
@@ -263,9 +266,15 @@ seeds it again.
263
266
  `mason doctor` checks all of this and says what is missing.
264
267
 
265
268
  - 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:
269
+ WebAssembly or prebuilt binaries: no native build, no Python or compiler needed. Mason's CI and the `mason-data`
270
+ image run Node 26.
271
+ - 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
272
+ with `Check failed: jit_page_->allocations_.erase(addr) == 1`, as a process exits or when the catalog's worker stops
273
+ after idling. `mason start` starts a run that died after it was ready again (not under `--attach`), and `mason status`
274
+ shows the restart. To avoid it, use Node 26, or a 22.x release from 22.13.
275
+ - Memory: by default the engine may use half the container's memory limit, else of the machine's RAM, or what leaves
276
+ 2.5 GiB beside it when that is less, never under 128 MiB (`MASON_DUCKDB_MEMORY=4GB` sets it; `mason doctor` says
277
+ it). Under 3 GiB, with it unset, Mason starts and warns that the system may stop it. Sizing a server:
269
278
  [deploy/mason-data/README.md#memory](https://github.com/embeddable-hq/mason-data/blob/main/deploy/mason-data/README.md#memory).
270
279
  - Postgres 13 or later. Each table needs a primary key, or a `key:` in the sources file (polling only).
271
280
 
@@ -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) {