@mason-data/cli 0.7.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 (155) hide show
  1. package/README.md +101 -73
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog-worker.js +49 -2
  4. package/dist/src/api/postgres/catalog.js +368 -25
  5. package/dist/src/api/postgres/encode.js +3 -1
  6. package/dist/src/api/postgres/explain.js +3 -2
  7. package/dist/src/api/postgres/listener.js +11 -3
  8. package/dist/src/api/postgres/protocol.js +39 -23
  9. package/dist/src/api/postgres/proxy.js +283 -0
  10. package/dist/src/api/postgres/schema.js +6 -0
  11. package/dist/src/api/postgres/server.js +1617 -245
  12. package/dist/src/api/postgres/translate.js +127 -10
  13. package/dist/src/api/request-log.js +18 -5
  14. package/dist/src/cdc/poll.js +5 -5
  15. package/dist/src/cli/background.js +47 -25
  16. package/dist/src/cli/bridge-stdin.js +101 -0
  17. package/dist/src/cli/cancel.js +75 -0
  18. package/dist/src/cli/command.js +170 -11
  19. package/dist/src/cli/commands.js +804 -209
  20. package/dist/src/cli/control.js +59 -0
  21. package/dist/src/cli/credentials.js +182 -0
  22. package/dist/src/cli/demo.js +20 -11
  23. package/dist/src/cli/deploy.js +574 -0
  24. package/dist/src/cli/dev.js +491 -130
  25. package/dist/src/cli/doctor.js +131 -38
  26. package/dist/src/cli/generated-files.js +143 -0
  27. package/dist/src/cli/guides.js +175 -36
  28. package/dist/src/cli/hosted.js +850 -0
  29. package/dist/src/cli/init.js +162 -65
  30. package/dist/src/cli/login.js +289 -0
  31. package/dist/src/cli/machine.js +13 -12
  32. package/dist/src/cli/main.js +11 -5
  33. package/dist/src/cli/mason.cjs +17 -0
  34. package/dist/src/cli/mcp.js +31 -22
  35. package/dist/src/cli/metrics.js +79 -0
  36. package/dist/src/cli/output-gate.js +176 -0
  37. package/dist/src/cli/page-share.js +96 -0
  38. package/dist/src/cli/pg-tls.js +14 -4
  39. package/dist/src/cli/pg-url.js +4 -0
  40. package/dist/src/cli/pin.js +19 -14
  41. package/dist/src/cli/pool-size.cjs +85 -0
  42. package/dist/src/cli/ports.js +2 -0
  43. package/dist/src/cli/preflight.js +138 -46
  44. package/dist/src/cli/project.js +142 -4
  45. package/dist/src/cli/readiness.js +76 -6
  46. package/dist/src/cli/registry.js +65 -38
  47. package/dist/src/cli/replica-hosted.js +665 -0
  48. package/dist/src/cli/replica.js +76 -0
  49. package/dist/src/cli/replication-checks.js +7 -5
  50. package/dist/src/cli/replication-mode.js +105 -26
  51. package/dist/src/cli/request-filter.js +134 -0
  52. package/dist/src/cli/roles.js +585 -0
  53. package/dist/src/cli/run-server.js +637 -48
  54. package/dist/src/cli/runs.js +1 -1
  55. package/dist/src/cli/security.js +67 -18
  56. package/dist/src/cli/serve.js +204 -46
  57. package/dist/src/cli/share-run.js +329 -0
  58. package/dist/src/cli/share.js +286 -0
  59. package/dist/src/cli/source-edit.js +75 -17
  60. package/dist/src/cli/source-role.js +344 -0
  61. package/dist/src/cli/sources.js +306 -47
  62. package/dist/src/cli/style.js +2 -1
  63. package/dist/src/cli/supervise.js +27 -24
  64. package/dist/src/cli/sync.js +55 -25
  65. package/dist/src/cli/token-hosted.js +337 -0
  66. package/dist/src/cli/tunnel.js +200 -0
  67. package/dist/src/cli/ui-ssh.js +460 -0
  68. package/dist/src/cli/ui.js +124 -17
  69. package/dist/src/cli/usage.js +128 -0
  70. package/dist/src/cli/user-hosted.js +299 -0
  71. package/dist/src/cli/user.js +573 -65
  72. package/dist/src/cli/version.js +9 -7
  73. package/dist/src/demo/seed.js +29 -0
  74. package/dist/src/duck/analyze.js +36 -20
  75. package/dist/src/duck/cancel.js +10 -1
  76. package/dist/src/duck/collation.js +266 -0
  77. package/dist/src/duck/constant-key.js +94 -0
  78. package/dist/src/duck/distinct-key.js +283 -26
  79. package/dist/src/duck/events.js +165 -0
  80. package/dist/src/duck/executor-worker.js +20 -11
  81. package/dist/src/duck/executor.js +47 -33
  82. package/dist/src/duck/explain.js +60 -46
  83. package/dist/src/duck/extensions.js +160 -0
  84. package/dist/src/duck/functions.js +461 -63
  85. package/dist/src/duck/gaps.js +48 -21
  86. package/dist/src/duck/generated-plan.js +1070 -0
  87. package/dist/src/duck/generated.js +906 -0
  88. package/dist/src/duck/grants.js +176 -0
  89. package/dist/src/duck/held-types.js +64 -0
  90. package/dist/src/duck/history.js +127 -0
  91. package/dist/src/duck/jsonb-order.js +9 -12
  92. package/dist/src/duck/kept-route.js +6 -5
  93. package/dist/src/duck/kept.js +15 -13
  94. package/dist/src/duck/late.js +1469 -40
  95. package/dist/src/duck/libc-bound.js +341 -0
  96. package/dist/src/duck/libc-order.js +892 -126
  97. package/dist/src/duck/pg-eval-worker.js +7 -1
  98. package/dist/src/duck/pg-eval.js +6 -4
  99. package/dist/src/duck/pgtree.js +774 -23
  100. package/dist/src/duck/plan.js +369 -47
  101. package/dist/src/duck/publication.js +20 -6
  102. package/dist/src/duck/requests.js +269 -93
  103. package/dist/src/duck/resolved.js +117 -2
  104. package/dist/src/duck/rewrite.js +4584 -580
  105. package/dist/src/duck/row-filter.js +655 -0
  106. package/dist/src/duck/statement.js +19 -0
  107. package/dist/src/duck/store.js +439 -177
  108. package/dist/src/duck/stream.js +19 -10
  109. package/dist/src/duck/sync.js +1411 -368
  110. package/dist/src/duck/token.js +4 -1
  111. package/dist/src/duck/types.js +69 -12
  112. package/dist/src/duck/usage.js +326 -0
  113. package/dist/src/duck/users.js +69 -15
  114. package/dist/src/duck/verified.js +161 -1
  115. package/dist/src/duck/views.js +5 -5
  116. package/dist/src/lib/database-name.js +62 -0
  117. package/dist/src/lib/database-url.js +55 -10
  118. package/dist/src/lib/dotenv.js +17 -4
  119. package/dist/src/lib/instant.js +50 -0
  120. package/dist/src/lib/log-buffer.js +77 -0
  121. package/dist/src/lib/log-read.js +196 -0
  122. package/dist/src/lib/pg-numeric.js +390 -49
  123. package/dist/src/lib/project-version.js +23 -23
  124. package/dist/src/lib/secret-mask.js +142 -0
  125. package/dist/src/lib/table-sync.js +282 -0
  126. package/dist/src/web/app.css +706 -66
  127. package/dist/src/web/app.js +331 -409
  128. package/dist/src/web/charts.js +229 -0
  129. package/dist/src/web/index.html +82 -18
  130. package/dist/src/web/pages/queries.js +192 -0
  131. package/dist/src/web/pages/status.js +611 -0
  132. package/dist/src/web/pages/syncs.js +155 -0
  133. package/dist/src/web/pages/tables.js +439 -0
  134. package/dist/src/web/pages/usage.js +397 -0
  135. package/dist/src/web/pages/users.js +219 -0
  136. package/dist/src/web/shared.js +584 -0
  137. package/dist/src/web/vendor/README.md +25 -0
  138. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  139. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  140. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  141. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  142. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  143. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  144. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  145. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  146. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  147. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  148. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  149. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  150. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  151. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  152. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  153. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  154. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  155. package/package.json +12 -4
@@ -6,17 +6,22 @@
6
6
  * per text until the schema changes.
7
7
  */
8
8
  import { deparseSync, QuoteUtils } from "pgsql-deparser";
9
- import { StatementError } from "../api/postgres/server.js";
9
+ import { HELD_ROW_INPUT, HELD_TYPE_INPUT } from "../api/postgres/schema.js";
10
+ import { heldName, heldType, isHeldArray } from "./held-types.js";
11
+ import { collationOids, functionsCalled, outputExpressions, textOrdering } from "./pgtree.js";
12
+ import { gapRefusal, StatementError } from "./statement.js";
10
13
  import { parseSql } from "../api/postgres/parser.js";
11
14
  import { analyze, catalogRows, readParameters } from "./analyze.js";
12
- import { describeKeys, nameUserKeys, resolvedKeys } from "./resolved.js";
13
- import { gapMark } from "./gaps.js";
15
+ import { argumentTypes, describeKeys, nameUserKeys, heldUses, nullTested, resolvedKeys } from "./resolved.js";
14
16
  import { expandViews, MAX_VIEW_DEPTH, viewRefused } from "./views.js";
15
17
  import { selectListAfterLimit } from "./late.js";
18
+ import { libcBounded } from "./libc-bound.js";
16
19
  import { pgTyplen } from "./types.js";
17
- import { datumText, masonCalls, rewrite, treeDifference, Unrewritable } from "./rewrite.js";
20
+ import { giveTurns, datumText, masonCalls, rewrite, treeDifference, Unrewritable } from "./rewrite.js";
18
21
  import { exposes, hideKept, readKey, searchSchemas } from "./kept-route.js";
19
- import { distinctCountTables, distinctKeyCounts, uniqueColumnsOf, uniqueColumnsSql } from "./distinct-key.js";
22
+ import { distinctCountTables, distinctKeyCounts, fanOutTables, groupedLimitTables, primaryKeysOf, uniqueColumnsOf, uniqueColumnsSql } from "./distinct-key.js";
23
+ import { enforceGrants } from "./grants.js";
24
+ import { attributeRefused, expandRowFilters, wallStatistics, wallSwitch } from "./row-filter.js";
20
25
  const JSON_OID = 114, JSONB_OID = 3802;
21
26
  /** what the rewrite asks of the stored JSON paths (src/duck/kept-route.ts KeptLookup): the copied table and column of
22
27
  * each json or jsonb column the statement reads from a table (Typed.relation), by the catalog's OIDs; null when it
@@ -43,6 +48,20 @@ async function distinctKeys(catalog, tree) {
43
48
  return new Set();
44
49
  return distinctKeyCounts(tree, uniqueColumnsOf(await catalogRows(catalog, uniqueColumnsSql(relids))));
45
50
  }
51
+ /** the primary keys that may prove a LIMIT with no ORDER BY's joins bounded, or a grouped LIMIT grouped by a whole key
52
+ * (src/duck/late.ts), read only for a statement that has one */
53
+ async function joinKeys(catalog, tree) {
54
+ const relids = [...new Set([...fanOutTables(tree), ...groupedLimitTables(tree)])].sort((a, b) => a - b);
55
+ return relids.length === 0 ? new Map() : primaryKeysOf(await catalogRows(catalog, uniqueColumnsSql(relids)));
56
+ }
57
+ /** which of `functions` the catalog says are volatile: asked only of a statement with a derived read the list after the
58
+ * limit may read twice (src/duck/late.ts) */
59
+ async function volatileFunctions(catalog, functions) {
60
+ if (functions.length === 0)
61
+ return new Set();
62
+ const rows = await catalogRows(catalog, `SELECT oid::text FROM pg_proc WHERE provolatile = 'v' AND oid IN (${functions.map((f) => String(Math.trunc(f))).join(", ")})`);
63
+ return new Set(rows.map(([oid]) => Number(oid)));
64
+ }
46
65
  /**
47
66
  * DuckDB's keywords that Postgres's are not: a column named `at` (DuckDB's AT TIME ZONE) or `qualify` is an identifier
48
67
  * the deparser writes bare, as Postgres reads it, and DuckDB refuses as a syntax error. The deparser's one quoting
@@ -59,9 +78,17 @@ export function quoteDuckKeywords(words) {
59
78
  return q === ident && duckKeywords.has(ident) ? `"${ident}"` : q;
60
79
  };
61
80
  }
62
- /** a search_path as SET writes it: each schema quoted (`"$user"` kept) */
63
- const pathSql = (searchPath) => searchPath.split(",").map((s) => s.trim()).filter((s) => s !== "")
64
- .map((s) => s.startsWith('"') ? s : `"${s.replaceAll('"', '""')}"`).join(", ") || "public";
81
+ /**
82
+ * EVERY TYPE THAT HOLDS A CITEXT, to any depth, by the catalog's structure: citext itself; an array by its element
83
+ * (`typelem`, a nested array is the same type); a domain by its base (`typbasetype`); a composite by its attributes; a
84
+ * range by its subtype and a multirange by its range (PR 674 shrink 2: an array of citext compared by bytes in a
85
+ * quantified subquery). One recursive term, since Postgres takes the recursive reference once.
86
+ */
87
+ export const CITEXT_HELD = "WITH RECURSIVE held(oid) AS (SELECT oid FROM pg_type WHERE typname = 'citext' UNION " +
88
+ "SELECT t.oid FROM pg_type t, held h WHERE t.typelem = h.oid OR t.typbasetype = h.oid " +
89
+ "OR (t.typrelid <> 0 AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped AND a.atttypid = h.oid)) " +
90
+ "OR EXISTS (SELECT 1 FROM pg_range r WHERE (r.rngtypid = t.oid AND r.rngsubtype = h.oid) OR (r.rngmultitypid = t.oid AND r.rngtypid = h.oid))) " +
91
+ "SELECT oid::text FROM held";
65
92
  /**
66
93
  * `verified`: what the oracle has held to Postgres (src/duck/verified.ts). A statement using anything else is REFUSED,
67
94
  * by name — never answered by a rewrite nobody checked. null only for the oracle itself, which records what it verifies.
@@ -71,12 +98,18 @@ const pathSql = (searchPath) => searchPath.split(",").map((s) => s.trim()).filte
71
98
  * src/duck/analyze.ts), written back in a form DuckDB reads to the same value. A value Postgres refuses is refused with
72
99
  * its error (`'1.5'` for a smallint: 22P02).
73
100
  */
74
- export async function duckParameters(catalog, oids, values, timeZone) {
75
- const read = await readParameters(catalog, oids, values, timeZone);
101
+ export async function duckParameters(catalog, oids, values, under) {
102
+ const read = await readParameters(catalog, oids, values, under);
76
103
  if ("refused" in read) {
77
104
  const f = read.refused;
78
105
  throw new StatementError({ code: f.C ?? "22P02", message: f.M ?? "invalid parameter value", ...(f.D ? { detail: f.D } : {}), ...(f.H ? { hint: f.H } : {}) }, "");
79
106
  }
107
+ // a text[] value DuckDB's list cannot hold as Postgres read it: refused by name, as its literal is
108
+ if ("gap" in read) {
109
+ throw read.gap === "multidim-array"
110
+ ? gapRefusal("multidim-array", `a multi-dimensional array as parameter $${read.parameter} is not supported by Mason`, "bind a one-dimensional array")
111
+ : gapRefusal("array-lower-bound", `an array whose subscripts do not start at 1 as parameter $${read.parameter} is not supported by Mason`, "bind the array without its bounds ('{a,b}', not '[2:3]={a,b}')");
112
+ }
80
113
  return values.map((v, i) => {
81
114
  const r = read.values[i];
82
115
  if (v === null || r === undefined || r === null)
@@ -106,11 +139,11 @@ export function missingRelationHint(relation, copies) {
106
139
  const matches = copies.filter((c) => c.source === relation || (!relation.includes(".") && c.source.endsWith(`.${relation}`)))
107
140
  .sort((a, b) => Number(b.source.startsWith("public.")) - Number(a.source.startsWith("public.")));
108
141
  const c = matches[0];
109
- const served = "it is served once its copy commits; mason wait waits for it (mason status shows how far it is)";
142
+ const served = "it is served once its first sync commits; mason wait waits for it (mason status shows how far it is)";
110
143
  if (c === undefined)
111
- return `a table is in Mason once it is added: mason add ${relation} copies it (mason tables lists the database's)`;
144
+ return `a table is in Mason once it is added: mason add ${relation} syncs it (mason tables lists the database's)`;
112
145
  switch (c.phase) {
113
- case "waiting": return `${c.source} is added and waits its turn to be copied: ${served}`;
146
+ case "waiting": return `${c.source} is added and waits its turn to be synced: ${served}`;
114
147
  case "copying": {
115
148
  // the share is of the heap's PAGES read (`CopyProgressView.share`: exact, so it never moves backwards), the count
116
149
  // the rows the committed chunks and the ones reading have in; a copy can outrun the source's estimate (Postgres's
@@ -123,46 +156,312 @@ export function missingRelationHint(relation, copies) {
123
156
  : of === null || of <= 0 ? `${share}about ${so} rows so far`
124
157
  : so > of ? `${share}${so} rows so far, past the ~${of} estimated`
125
158
  : `${share}about ${so} of ~${of} rows so far${p === undefined ? `, ${Math.min(99, Math.floor((so / of) * 100))}%` : ""}`;
126
- return `${c.source} is added and being copied (${how}): ${served}`;
159
+ return `${c.source} is added and being synced (${how}): ${served}`;
160
+ }
161
+ case "failed": return `${c.source} is added, but its first sync failed: ${c.error ?? "no reason recorded"}; mason logs says more`;
162
+ case "copied": return `${c.source} was just synced and joins the catalog as it is rebuilt: run the statement again in a moment (if it stays missing, mason logs says why)`;
163
+ }
164
+ }
165
+ /**
166
+ * THE TREE AS TEXT, DuckDB's `COLUMNS(…)` (decidedByKey's star, the statistics wall) unquoted: it is an expression only so,
167
+ * and the deparser quotes the keyword. Marked in the tree, never found in the printed text, where a literal or a name may
168
+ * hold the same characters: each call printed under a name the text holds nowhere else (the count says so), then that name
169
+ * replaced. No statement Postgres accepted calls a function "columns" (Postgres has none), and the read-back checks the text.
170
+ */
171
+ function printed(tree, statement) {
172
+ const calls = [];
173
+ const collect = (v) => {
174
+ if (Array.isArray(v)) {
175
+ v.forEach(collect);
176
+ return;
177
+ }
178
+ if (typeof v !== "object" || v === null)
179
+ return;
180
+ const name = v.FuncCall?.funcname;
181
+ if (name?.length === 1 && name[0]?.String?.sval === "columns")
182
+ calls.push(name[0]);
183
+ Object.values(v).forEach(collect);
184
+ };
185
+ collect(tree);
186
+ if (calls.length === 0)
187
+ return deparseSync(tree, { pretty: false });
188
+ // a text holds a mark at most once per few characters; a count that never agrees is a call the deparser does not print
189
+ const bound = deparseSync(tree, { pretty: false }).length;
190
+ for (let i = 0; i <= bound; i++) {
191
+ const mark = `__mason_columns_${i}`;
192
+ for (const c of calls)
193
+ c.String.sval = mark;
194
+ const text = deparseSync(tree, { pretty: false });
195
+ for (const c of calls)
196
+ c.String.sval = "columns";
197
+ if (text.split(`${mark}(`).length - 1 === calls.length)
198
+ return text.replaceAll(`${mark}(`, "COLUMNS(");
199
+ }
200
+ throw new StatementError({ code: "0A000", message: "Mason cannot print this statement for the engine (a Mason gap: report it, with the statement)" }, statement);
201
+ }
202
+ /**
203
+ * A CHAIN'S CHILD READS ITS PARENT'S SIDE TABLE (src/duck/generated.ts, issue 124 stage 2): on the finished tree, each
204
+ * read of a table `relations` names is swapped for the table it maps to, in the same schema, under the name the statement
205
+ * read it by (its alias, else the table's own), so every column the rewrite qualified still binds. A bare name is the
206
+ * search path's first schema's (a generated table is planned under `public`), and a bare name a WITH in scope holds is a
207
+ * CTE's, never swapped. Every table named must be met: a read left on the served table would read the old parent, so
208
+ * one not met throws. The read-back below holds the swapped text to the tree, as any other.
209
+ */
210
+ function swapRelations(tree, relations, schemas) {
211
+ const met = new Set();
212
+ const walk = (v, ctes) => {
213
+ if (Array.isArray(v)) {
214
+ for (const x of v)
215
+ walk(x, ctes);
216
+ return;
217
+ }
218
+ if (typeof v !== "object" || v === null)
219
+ return;
220
+ const o = v;
221
+ const r = o.RangeVar;
222
+ if (r !== undefined) {
223
+ if (r.schemaname === undefined && ctes.has(r.relname))
224
+ return;
225
+ const key = `${r.schemaname ?? schemas[0] ?? "public"}.${r.relname}`;
226
+ const to = relations.get(key);
227
+ if (to !== undefined) {
228
+ r.alias ??= { aliasname: r.relname };
229
+ r.relname = to;
230
+ met.add(key);
231
+ }
232
+ return;
127
233
  }
128
- case "failed": return `${c.source} is added, but its copy failed: ${c.error ?? "no reason recorded"}; mason logs says more`;
129
- case "copied": return `${c.source} was just copied and joins the catalog as it is rebuilt: run the statement again in a moment (if it stays missing, mason logs says why)`;
234
+ // a column by the table's schema-qualified name (`public.counts.tenant_id`, valid Postgres): the swapped table is
235
+ // read under its own name as an alias, which binds no schema, so the column is named by that alias alone
236
+ const fields = o.ColumnRef?.fields;
237
+ if (fields !== undefined && fields.length >= 3) {
238
+ const sval = (x) => x?.String?.sval;
239
+ const schema = sval(fields[fields.length - 3]), table = sval(fields[fields.length - 2]);
240
+ if (schema !== undefined && table !== undefined && relations.has(`${schema}.${table}`))
241
+ o.ColumnRef.fields = fields.slice(-2);
242
+ return;
243
+ }
244
+ const list = o.withClause?.ctes;
245
+ const scope = list === undefined ? ctes : new Set([...ctes, ...list.map((c) => c.CommonTableExpr?.ctename ?? "")]);
246
+ for (const x of Object.values(o))
247
+ walk(x, scope);
248
+ };
249
+ walk(tree, new Set());
250
+ const missed = [...relations.keys()].filter((k) => !met.has(k));
251
+ if (missed.length > 0)
252
+ throw new Error(`a generated table's plan does not read ${missed.join(", ")} by a name it can swap (Mason bug: report it)`);
253
+ }
254
+ /** The catalog declares a type the source made with nothing over it (`PgColumn.made`), so what its analysis says is not
255
+ * there (an `=`, an order, a field of a composite) the source may have: Mason's gap, never Postgres's refusal. */
256
+ async function madeTypeRefusal(catalog, code, message, text) {
257
+ if (message === undefined || (code !== "42883" && code !== "42809" && code !== "42804" && code !== "42846"))
258
+ return null;
259
+ const types = (await catalogRows(catalog, `SELECT t.typname FROM pg_type t WHERE t.typinput::text LIKE '%${HELD_TYPE_INPUT}%'`)).map((r) => r[0]);
260
+ const name = types.find((t) => new RegExp(`\\b${t.replace(/[^\w]/g, ".")}\\b`).test(message));
261
+ if (name === undefined || /^function /.test(message) && !/\(.*\)/.test(message))
262
+ return null;
263
+ return gapRefusal("held-type-compare", `Mason cannot answer this over a column of the type ${name}: it holds the value's text, and has no operator of the type`, "read the value, or its text (x::text); compare, sort or take a field on the source's own columns", text);
264
+ }
265
+ /**
266
+ * A STATEMENT OF NO TABLE THAT ORDERS TEXT UNDER A COLLATION THAT IS NOT BYTE ORDER (issue 122): the catalog's database
267
+ * is C, so `SELECT 'a' < 'B'` answered there is byte order where the source's libc or ICU says otherwise. Its analysis
268
+ * (under the session's state) says where it orders a collatable value and under which collation (`textOrdering`); an
269
+ * operator or a function counts as ordering by the catalog's own name of it (`<`, `max` …, never `=` or `count`), and
270
+ * the resolver says whether that collation is byte order. "order": the statement is planned for the synced data, made
271
+ * and refused like any other. A statement the analysis refuses stays the catalog's, which raises Postgres's own error.
272
+ * "case" (issue 406): it calls upper, lower or initcap of text, under any collation (the catalog maps case by its own
273
+ * C.UTF-8's rules, never the source's ctype): the synced data's where its RUN answers the statement, which the caller
274
+ * decides (src/api/postgres/server.ts `answersCase`), since what the catalog answered before is kept where the synced
275
+ * data's plan or run fails.
276
+ */
277
+ export async function ordersText(o) {
278
+ const analysis = await analyze(o.catalog, o.text, o.paramOids, o.under);
279
+ if ("refused" in analysis)
280
+ return null;
281
+ // the analysis as the session runs the statement: a simple Query binds no parameter, so one naming `$1` is Postgres's
282
+ // 42P02, which the catalog answers (the analysis above is a Parse's, which would infer its type)
283
+ if (!o.bindable && analysis.params.length > 0)
284
+ return null;
285
+ const oids = collationOids(analysis.tree);
286
+ if (oids.size === 0)
287
+ return null;
288
+ let resolved;
289
+ // (a collation the source lacks, or one Mason cannot follow: the synced data's plan refuses it by name, never the
290
+ // catalog's byte order; a nondeterministic one wherever it is named, equality and GROUP BY included)
291
+ try {
292
+ resolved = await o.collations.prepare((sql) => catalogRows(o.catalog, sql), oids);
130
293
  }
294
+ catch (e) {
295
+ if (e instanceof Unrewritable)
296
+ return "order";
297
+ throw e;
298
+ }
299
+ // a statement of no table that holds a numeric (a literal such as 1.5 included) stays the catalog's, which computes it exactly, its text in byte
300
+ // order and its case as before (Mason gap fromless-numeric-text-order): the synced data holds a VALUES list's numeric
301
+ // otherwise than Postgres (issue 342's class), and the catalog answered it right
302
+ if ([...analysis.types.values()].some((ts) => ts.some((t) => t.type === NUMERIC || t.type === NUMERIC_ARRAY)))
303
+ return null;
304
+ const ordering = (coll) => {
305
+ try {
306
+ const c = resolved.resolve(coll);
307
+ return c !== null && c.kind !== "bytes";
308
+ }
309
+ catch (e) {
310
+ if (e instanceof Unrewritable)
311
+ return true;
312
+ throw e;
313
+ }
314
+ };
315
+ const { orders, operators, functions, calls } = textOrdering(analysis.tree);
316
+ if ([...orders].some(ordering))
317
+ return "order";
318
+ const ops = [...operators].filter(([, cs]) => [...cs].some(ordering)).map(([oid]) => oid);
319
+ const fns = [...functions].filter(([, cs]) => [...cs].some(ordering)).map(([oid]) => oid);
320
+ const cased = [...calls.keys()];
321
+ if (ops.length + fns.length + cased.length === 0)
322
+ return null;
323
+ const named = (await catalogRows(o.catalog, [
324
+ ...(ops.length > 0 ? [`SELECT 'order' FROM pg_catalog.pg_operator WHERE oid IN (${ops.join(", ")}) AND oprname IN ('<', '>', '<=', '>=')`] : []),
325
+ ...(fns.length > 0 ? [`SELECT 'order' FROM pg_catalog.pg_proc WHERE oid IN (${fns.join(", ")}) AND proname IN ('min', 'max')`] : []),
326
+ ...(cased.length > 0 ? [`SELECT 'case' FROM pg_catalog.pg_proc WHERE oid IN (${cased.join(", ")}) AND proname IN ('upper', 'lower', 'initcap') AND pronamespace = 'pg_catalog'::regnamespace`] : [])
327
+ ].join(" UNION ALL "))).map((r) => r[0]);
328
+ if (named.includes("order"))
329
+ return "order";
330
+ // a case mapping alone moves only a statement of text (and the booleans and integers text answers: a comparison, a
331
+ // length), which the synced data computes as the catalog does but for the case; one that also holds a date, a time,
332
+ // a json or a session's value stays the catalog's, as before, which answers what the synced data may refuse
333
+ // (`lower('now')::timestamptz`, a relative time word; Mason gap fromless-numeric-text-order)
334
+ if (!named.includes("case"))
335
+ return null;
336
+ if (![...analysis.types.values()].every((ts) => ts.every((t) => CASE_ROUTED_TYPES.has(t.type))))
337
+ return null;
338
+ // and only one whose every value is its own arguments' (review r2 of PR 568, fromless-case-route-run-success-read-as-
339
+ // sameness): a value of the server or the session (`version()`, `format_type(…)`, `current_database()`,
340
+ // `current_user`) the synced data answers with ITS OWN, so a run that succeeds says nothing of it. Postgres's analysis
341
+ // says which: a session keyword or sequence (`functionsCalled`), and a function it does not call immutable
342
+ // (pg_proc.provolatile: pg_catalog.version() and format_type() are stable); such a statement stays the
343
+ // catalog's, which answers it as this session's, as before (Mason gap fromless-numeric-text-order)
344
+ const called = functionsCalled(analysis.tree);
345
+ if (called.session || called.oids.has(0))
346
+ return null;
347
+ const unpure = await catalogRows(o.catalog, `SELECT 1 FROM pg_catalog.pg_proc WHERE oid IN (${[...called.oids].join(", ")}) AND provolatile <> 'i' LIMIT 1`);
348
+ return unpure.length === 0 ? "case" : null;
349
+ }
350
+ const NUMERIC = 1700, NUMERIC_ARRAY = 1231;
351
+ /** the types a statement of no table that maps case may hold and still be the synced data's: text's, an unknown
352
+ * literal's, a name's, a boolean's, an integer's and a text array's */
353
+ const CASE_ROUTED_TYPES = new Set([25, 1043, 1042, 705, 19, 16, 21, 23, 20, 1009, 1015]);
354
+ /** a rewrite's refusal as the statement's: a gap says its id (docs/SQL-SUPPORT.md); Postgres's own refusal is its
355
+ * SQLSTATE, and no gap */
356
+ function unrewritableError(e, text) {
357
+ return typeof e.gap === "string" ? gapRefusal(e.gap, e.message, e.hint, text)
358
+ : new StatementError({ code: e.gap.postgres, message: e.message, ...(e.hint !== "" ? { hint: e.hint } : {}) }, text);
359
+ }
360
+ /** the functions that read a held value's position, not its value: its type, a count, an array's shape and rows */
361
+ const HELD_READERS = new Set(["1619", "2147", "2803", "3179", "2176", "2091", "2092", "2331", "2334", "730", "2922"]);
362
+ /** What a statement does with a value the copy holds as its text that the text cannot answer as the type would: an
363
+ * operator but `=` and `<>` of a type whose equal values have one text, a function, a cast to another type. */
364
+ async function heldRefusal(catalog, tree, text) {
365
+ const uses = heldUses(tree).filter((u) => !(u.kind === "function" && HELD_READERS.has(u.oid)));
366
+ if (uses.length === 0)
367
+ return null;
368
+ const ops = uses.filter((u) => u.kind === "operator");
369
+ const equality = new Set(ops.length === 0 ? [] : (await catalogRows(catalog, `SELECT oid::text FROM pg_operator WHERE oid IN (${ops.map((u) => Number(u.oid)).join(", ")}) AND oprname IN ('=', '<>')`)).map((r) => r[0]));
370
+ const bad = uses.find((u) => !(u.kind === "operator" && equality.has(u.oid) && heldType(u.type)?.textEqual === true && heldType(u.type)?.literal === true && !isHeldArray(u.type)));
371
+ if (bad === undefined)
372
+ return null;
373
+ return gapRefusal("held-type-compare", `Mason cannot answer this over a value of the type ${heldName(bad.type)}: it holds the value's text, and this needs the type's own value`, "read the value, or its text (x::text); = and GROUP BY are answered where equal values have one text", text);
131
374
  }
132
375
  /** A statement's plan. `wireBound`: its rows are bounded by a row count an Execute sets on the wire, which its SQL
133
- * does not say (src/duck/rewrite.ts markBounds; the listener asks for it where PlannedSql.unboundedLateral says so) */
134
- export async function planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, collation, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null, wireBound = false) {
376
+ * does not say (src/duck/rewrite.ts markBounds; the listener asks for it where PlannedSql.unboundedLateral says so).
377
+ * `grants`: the session's role (src/duck/grants.ts), held after the analysis; null for a session with no role where
378
+ * roles exist (the plan still names its reads, for an Execute under a role given later); undefined with no roles at all.
379
+ * `walls`: `text` is the statement with its filtered reads in place (expandRowFilters), each of which the wall ends */
380
+ export async function planDuck(options) {
381
+ const { catalog, text, stmt, base, paramOids, searchPath, timeZone, dateStyle = "ISO, MDY", collations: resolver, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null, wireBound = false, grants, walls = null, relations, outputs = false } = options;
135
382
  const started = performance.now();
136
- const analysis = await analyze(catalog, text, paramOids, pathSql(searchPath), timeZone);
383
+ const under = { searchPath, timeZone, dateStyle };
384
+ const analysis = await analyze(catalog, text, paramOids, under);
137
385
  if ("refused" in analysis) {
138
386
  const f = analysis.refused;
139
387
  // a table the catalog does not hold: where its copy is, or the `mason add` away
140
388
  const missing = f.C === "42P01" ? /^relation "([^"]+)" does not exist$/.exec(f.M ?? "")?.[1] : undefined;
389
+ // a generated table not built yet (src/duck/generated.ts): by name, and what it is doing
390
+ const unbuilt = missing === undefined ? undefined : copies().find((c) => c.kept?.by === "generated" && c.phase !== "copied" && (c.source === missing || c.source === `public.${missing}`));
391
+ if (unbuilt?.kept?.by === "generated")
392
+ throw gapRefusal("generated-not-built", `the generated table ${unbuilt.source} is not built yet: ${unbuilt.kept.text}`, "mason status says how far it is", text);
141
393
  const hint = missing !== undefined ? missingRelationHint(missing, copies()) : f.H;
394
+ const made = await madeTypeRefusal(catalog, f.C, f.M, text);
395
+ if (made !== null)
396
+ throw made;
142
397
  throw new StatementError({ code: f.C ?? "XX000", message: f.M ?? "Postgres refused the statement", ...(f.D ? { detail: f.D } : {}), ...(hint ? { hint } : {}),
143
398
  ...(f.P ? { position: Number(f.P) } : {}) }, text);
144
399
  }
145
400
  // A VIEW is answered as Postgres answers it, by its definition in its place (src/duck/views.ts): the statement so
146
401
  // written is planned from the start, its parameters typed as this one's were. Its errors are the expanded text's, so
147
402
  // they carry no position into this one
148
- const expanded = await expandViews(catalog, stmt, analysis, pathSql(searchPath));
403
+ const expanded = await expandViews(catalog, stmt, analysis, under);
149
404
  if (expanded !== null) {
150
405
  if (depth >= MAX_VIEW_DEPTH)
151
406
  throw viewRefused(`Mason cannot yet answer views read through more than ${MAX_VIEW_DEPTH} other views`, "read the tables the views read");
152
407
  try {
153
- return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept, wireBound);
408
+ return await planDuck({ ...options, text: expanded.text, stmt: expanded.stmt, base: 0, paramOids: analysis.params, depth: depth + 1 });
154
409
  }
155
410
  catch (e) {
156
411
  if (!(e instanceof StatementError))
157
412
  throw e;
158
413
  const { position: _p, ...fields } = e.fields;
159
- throw new StatementError(fields, text);
414
+ throw new StatementError(fields, text, e.gap);
415
+ }
416
+ }
417
+ const held = await heldRefusal(catalog, analysis.tree, text);
418
+ if (held !== null)
419
+ throw held;
420
+ // THE ROLE (src/duck/grants.ts): the tables and columns Postgres's analysis says the statement reads, held to the
421
+ // session's role — after Postgres's own refusal and the views' expansion (the check sees the tables a view reads),
422
+ // before the rewrite (a role's refusal before a Mason gap's). The reads stay on the plan: an Execute under a role
423
+ // given or taken since the Parse is held to them again (src/api/postgres/server.ts)
424
+ const reads = grants === undefined ? undefined : await enforceGrants(catalog, analysis.tree, grants);
425
+ // THE ROLE'S ROWS (src/duck/row-filter.ts): every read of a table the role filters becomes its filtered read, and that
426
+ // statement is planned from the start — analysed by Postgres, rewritten and verified as any other (one grammar), held
427
+ // to nothing more (the grants were held above, on the statement as written: a filter may read a column the role
428
+ // may not, as a policy may on Postgres). Its errors are the expanded text's, so they carry no position into this one
429
+ if (grants?.rows !== undefined && grants.rows.size > 0) {
430
+ const filtered = await expandRowFilters(catalog, stmt, analysis, under, grants.rows);
431
+ if (filtered !== null) {
432
+ let inner;
433
+ try {
434
+ inner = await planDuck({ ...options, text: filtered.text, stmt: filtered.stmt, base: 0, paramOids: analysis.params, depth: depth + 1, grants: undefined, walls: filtered.walls });
435
+ }
436
+ catch (e) {
437
+ if (!(e instanceof StatementError))
438
+ throw e;
439
+ // a data exception may be a bound value's, not the statement's: `--set tenant=abc` on an integer
440
+ const bad = e.fields.code.startsWith("22") && e.gap === null ? await attributeRefused(catalog, grants.rows, filtered.tables) : null;
441
+ if (bad !== null) {
442
+ const user = JSON.stringify(grants.rowsUser ?? "");
443
+ throw new StatementError({ code: "42501", message: `permission denied: the user ${user} has a value for the attribute ${bad.name} that is not of type ${bad.type}, which the role ${grants.name} filters rows by`,
444
+ detail: `${bad.at} reads :${bad.name} as ${bad.type} (${bad.where}): ${bad.problem}`,
445
+ hint: `mason user remove ${grants.rowsUser ?? "<user>"} && mason user add ${grants.rowsUser ?? "<user>"} --role ${grants.name} --set ${bad.name}=<a value of type ${bad.type}>` }, text);
446
+ }
447
+ // the statement as written passed this check above, so what fails it is the table being a subquery (carryKeyDependency)
448
+ if (e.fields.code === "42803" || /could not identify an equality operator/.test(e.fields.message)) {
449
+ throw gapRefusal("roles", `Mason cannot yet answer a GROUP BY of a key of a table its role filters here: ${e.fields.message}`, "name each column it reads in the GROUP BY, or read it in an aggregate", text);
450
+ }
451
+ const { position: _p, ...fields } = e.fields;
452
+ throw new StatementError(fields, text, e.gap);
453
+ }
454
+ const rowFilters = filtered.tables.map((t) => ({ table: t, words: `filtered by the role ${grants.name}: ${grants.rows.get(t).words}` }));
455
+ return { ...inner, ...(reads !== undefined ? { reads } : {}), rowFilters };
160
456
  }
161
457
  }
162
458
  // the catalog's own OIDs for the types an extension made (citext): the rewrite knows a few by name, and the wire sends
163
459
  // the SOURCE's OID for them (`userTypes`, from the copy), never the catalog's
164
460
  let resolved = resolvedKeys(analysis.tree);
165
461
  const catalogTypes = new Map();
462
+ // every type that holds a citext anywhere in its structure (`CITEXT_HELD`): the rewrite refuses one where it would
463
+ // compare it by bytes (rewrite.ts `holdsCitext`)
464
+ const citextHeld = new Set();
166
465
  const enumLabels = new Map(), enumArrays = new Map();
167
466
  const enums = { labels: enumLabels, arrays: enumArrays };
168
467
  const fields = analysis.fields === null ? null : analysis.fields.map((f) => ({ ...f }));
@@ -173,6 +472,8 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
173
472
  if ([...resolved].some((k) => /\bu\d+/.test(k)) || fields?.some((f) => f.oid >= 16384) || used.size > 0) {
174
473
  for (const [oid, name] of await catalogRows(catalog, "SELECT oid::text, typname FROM pg_type WHERE oid >= 16384 AND typtype IN ('b', 'd', 'e')"))
175
474
  catalogTypes.set(Number(oid), name);
475
+ for (const [oid] of await catalogRows(catalog, CITEXT_HELD))
476
+ citextHeld.add(Number(oid));
176
477
  // an enum's labels in their declared order, by its OID and its array type's: DuckDB is handed each value as its ENUM
177
478
  for (const [oid, array, label] of await catalogRows(catalog, "SELECT t.oid::text, t.typarray::text, e.enumlabel FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid ORDER BY t.oid, e.enumsortorder")) {
178
479
  const labels = enumLabels.get(Number(oid)) ?? [];
@@ -185,8 +486,8 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
185
486
  catalogTypes.set(Number(oid), name);
186
487
  if (!used.has(Number(oid)))
187
488
  continue;
188
- const columns = await catalogRows(catalog, `SELECT a.attname, a.atttypid::text, a.atttypmod::text FROM pg_attribute a JOIN pg_type t ON t.typrelid = a.attrelid WHERE t.oid = ${Number(oid)} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`);
189
- rowTypes.set(Number(oid), columns.map(([n, type, typmod]) => ({ name: n, type: Number(type), typmod: Number(typmod) })));
489
+ const columns = await catalogRows(catalog, `SELECT a.attname, a.atttypid::text, a.atttypmod::text, a.attcollation::text FROM pg_attribute a JOIN pg_type t ON t.typrelid = a.attrelid WHERE t.oid = ${Number(oid)} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`);
490
+ rowTypes.set(Number(oid), columns.map(([n, type, typmod, collation]) => ({ name: n, type: Number(type), typmod: Number(typmod), collation: Number(collation ?? 0) })));
190
491
  }
191
492
  resolved = await nameUserKeys((sql) => catalogRows(catalog, sql), resolved);
192
493
  for (const f of fields ?? []) {
@@ -195,33 +496,45 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
195
496
  f.oid = source;
196
497
  }
197
498
  }
198
- let tree, dropLast, exact, clock, jsonReads, unboundedLateral;
499
+ let tree, withTies, exact, clock, jsonReads, unboundedLateral;
199
500
  // a table's row the source has otherwise (copied in part: no source OID for it) is not the source's row
200
501
  const sourceRows = new Map([...rowTypes].filter(([oid]) => userTypes.has(catalogTypes.get(oid) ?? "")));
502
+ // IS [NOT] NULL of a composite reads its fields (a row of NULLs IS NULL), which the copy's text does not
503
+ if (used.size > 0) {
504
+ const rows = await catalogRows(catalog, `SELECT oid::text FROM pg_type WHERE typinput::text LIKE '${HELD_ROW_INPUT}%'`);
505
+ if (rows.length > 0 && nullTested(analysis.tree, new Set(rows.map((r) => Number(r[0]))))) {
506
+ throw gapRefusal("held-type-compare", "IS [NOT] NULL of a composite column is not supported by Mason: it holds the value's text, and Postgres reads each of its fields", "read x::text, or the source's own columns", text);
507
+ }
508
+ }
201
509
  // the stored JSON paths the statement's JSON reads may be answered from (src/duck/kept-route.ts)
202
510
  const lookup = kept === null ? null : await keptLookup(catalog, analysis.types, kept);
203
511
  const keyCounts = await distinctKeys(catalog, analysis.tree);
512
+ // WHAT EACH COLLATION OF THE ANALYSIS MEANS (src/duck/collation.ts), resolved before the rewrite reads any: a libc
513
+ // order not read yet is read here, before the plan is made; a collation the source lacks or Mason cannot follow refused
514
+ let collations;
515
+ try {
516
+ collations = await resolver.prepare((sql) => catalogRows(catalog, sql), collationOids(analysis.tree));
517
+ }
518
+ catch (e) {
519
+ throw e instanceof Unrewritable ? unrewritableError(e, text) : e;
520
+ }
204
521
  const analysed = performance.now();
205
- const rewritten = (l = lookup) => rewrite(stmt, base, analysis, collation, catalogTypes, sourceRows, timeZone, enums, l, wireBound, keyCounts);
522
+ const rewritten = (l = lookup) => rewrite({ stmt: stmt, base, analysis, collations, userTypes: catalogTypes, citextHeld, rowTypes: sourceRows, timeZone, dateStyle, enums, kept: l, wireBound, keyCounts });
206
523
  try {
207
- ({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten());
524
+ ({ tree, withTies, exact, clock, jsonReads, unboundedLateral } = rewritten());
208
525
  // the oracle's (`eager`): every read stored before the plan is made, and the plan made again over them
209
526
  if (kept?.keepNow != null && lookup !== null && jsonReads.some((r) => kept.ready(r) === null)) {
210
527
  await kept.keepNow(jsonReads);
211
- ({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten());
528
+ ({ tree, withTies, exact, clock, jsonReads, unboundedLateral } = rewritten());
212
529
  }
213
530
  // a statement that lists a copy's columns without naming them reads none from a stored column (hideKept): made
214
531
  // again with every read its call, the reads still recorded
215
532
  if (lookup !== null && exposes(tree))
216
- ({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
533
+ ({ tree, withTies, exact, clock, jsonReads, unboundedLateral } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
217
534
  }
218
535
  catch (e) {
219
- // a gap says its id (docs/SQL-SUPPORT.md); Postgres's own refusal is its SQLSTATE, and no gap
220
- if (e instanceof Unrewritable) {
221
- throw new StatementError(typeof e.gap === "string"
222
- ? { code: "0A000", message: e.message, hint: e.hint, detail: `${gapMark(e.gap)} (docs/SQL-SUPPORT.md)` }
223
- : { code: e.gap.postgres, message: e.message, hint: e.hint }, text);
224
- }
536
+ if (e instanceof Unrewritable)
537
+ throw unrewritableError(e, text);
225
538
  throw e;
226
539
  }
227
540
  // a statement that lists a copy's columns without naming them reads its visible ones (src/duck/kept-route.ts); before
@@ -229,7 +542,13 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
229
542
  if (kept !== null)
230
543
  hideKept(tree, { visible: kept.visible(), searchPath: searchSchemas(searchPath) });
231
544
  // a sorted, limited select list is computed over the rows the limit keeps (src/duck/late.ts)
232
- tree = selectListAfterLimit(tree, analysis.tree) ?? tree;
545
+ // (a role's walled read it fetches through itself is one more wall: counted, so `wallStatistics` still walls every one)
546
+ const lateWall = walls !== null ? { alias: walls.alias, added: 0 } : null;
547
+ tree = await selectListAfterLimit(tree, analysis.tree, () => joinKeys(catalog, analysis.tree), { volatile: (functions) => volatileFunctions(catalog, functions), wall: lateWall }) ?? tree;
548
+ // a top N and a min or max under a libc order keyed for their candidates alone (src/duck/libc-bound.ts): after the
549
+ // late select list, whose inner query holds the sort and the limit; a walled read it reads again is one more wall
550
+ tree = await libcBounded(tree, analysis.tree, { order: (collate) => resolver.libcOrder(collate),
551
+ volatile: (functions) => volatileFunctions(catalog, functions), wall: lateWall }) ?? tree;
233
552
  // ONLY WHAT THE ORACLE VERIFIED: after the rewrite, so a construct the rewrite refuses by name (with its own way out)
234
553
  // says so first
235
554
  const keys = [...resolved].sort();
@@ -237,23 +556,26 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
237
556
  if (unverified.length > 0) {
238
557
  const names = await describeKeys((sql) => catalogRows(catalog, sql), unverified);
239
558
  const listed = unverified.map((k) => names.get(k) ?? k);
240
- throw new StatementError({ code: "0A000",
241
- message: `Mason cannot yet answer this statement on DuckDB exactly as Postgres would: it uses ${listed.slice(0, 5).join("; ")}${listed.length > 5 ? ` and ${listed.length - 5} more` : ""}, which Mason has not verified`,
242
- hint: "write the statement without it (docs/SQL-SUPPORT.md lists what is verified), or report it: a statement Mason has not verified is refused rather than answered differently",
243
- detail: `${gapMark("unverified")}: ${unverified.join(", ")}` }, text);
559
+ throw gapRefusal("unverified", `Mason cannot yet answer this statement exactly as Postgres would: it uses ${listed.slice(0, 5).join("; ")}${listed.length > 5 ? ` and ${listed.length - 5} more` : ""}, which Mason has not verified`, "write the statement without it (docs/SQL-SUPPORT.md lists what is verified), or report it: a statement Mason has not verified is refused rather than answered differently", text, `: ${unverified.join(", ")}`);
244
560
  }
245
- // DuckDB's COLUMNS(t.*) (decidedByKey's star) is an expression only unquoted; the deparser quotes the keyword. No
246
- // statement Postgres accepted calls a function "columns" (Postgres has none), and the read-back below checks the text
247
- const sql = deparseSync(tree, { pretty: false }).replaceAll('any_value("columns"(', "any_value(COLUMNS(");
561
+ // only a statement with its filtered reads in place: no other statement's text changes, whatever it names its subqueries
562
+ if (walls !== null && wallSwitch.statistics)
563
+ wallStatistics(tree, { alias: walls.alias, reads: walls.reads + (lateWall?.added ?? 0) });
564
+ // on the finished tree, so no rule above meets it; `masonCalls` below does not count it as a function called
565
+ giveTurns(tree);
566
+ if (relations !== undefined && relations.size > 0)
567
+ swapRelations(tree, relations, searchSchemas(searchPath));
568
+ const sql = printed(tree, text);
248
569
  // THE PRINTED TEXT MUST READ BACK AS THE TREE: a printing gap (the deparser drops or respells something) would run
249
570
  // another statement than the one Postgres analysed — refused instead, by name
250
571
  const back = (await parseSql(sql)).stmts?.[0]?.stmt;
251
572
  const difference = back === undefined ? "nothing" : treeDifference(back, tree);
252
573
  if (difference !== null) {
253
- throw new StatementError({ code: "0A000", message: "Mason cannot run this statement on DuckDB exactly: its rewritten form does not read back as itself (a Mason gap: report it, with the statement)",
254
- detail: `read back: ${difference}; DuckDB SQL: ${sql}` }, text);
574
+ throw new StatementError({ code: "0A000", message: "Mason cannot run this statement exactly: its rewritten form does not read back as itself (a Mason gap: report it, with the statement)",
575
+ detail: `read back: ${difference}; the rewritten SQL: ${sql}` }, text);
255
576
  }
256
577
  const planned = { text: sql, functions: masonCalls(tree), ...(unboundedLateral ? { unboundedLateral: true } : {}) };
257
- return { sql: planned, params: analysis.params, fields: fields?.map((f) => ({ ...f, size: pgTyplen(f.oid) })) ?? null, dropLast, ...(exact.length > 0 ? { exact } : {}), resolved: keys,
258
- ...(Object.keys(clock).length > 0 ? { clock } : {}), ...(jsonReads.length > 0 ? { jsonReads: jsonReads.map(readKey) } : {}), timing: { analysisMs: analysed - started, rewriteMs: performance.now() - analysed } };
578
+ return { sql: planned, params: analysis.params, fields: fields?.map((f) => ({ ...f, size: pgTyplen(f.oid) })) ?? null, withTies, ...(exact.length > 0 ? { exact } : {}), resolved: keys, argumentTypes: argumentTypes(analysis.tree), ...(outputs ? { outputs: outputExpressions(analysis.tree) ?? [] } : {}),
579
+ ...(Object.keys(clock).length > 0 ? { clock } : {}), ...(jsonReads.length > 0 ? { jsonReads: jsonReads.map(readKey) } : {}), ...(reads !== undefined ? { reads } : {}),
580
+ timing: { analysisMs: analysed - started, rewriteMs: performance.now() - analysed } };
259
581
  }