@mason-data/cli 0.6.2 → 0.8.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 (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -6,16 +6,18 @@
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 { gapRefusal, StatementError } from "../api/postgres/server.js";
10
10
  import { parseSql } from "../api/postgres/parser.js";
11
11
  import { analyze, catalogRows, readParameters } from "./analyze.js";
12
12
  import { describeKeys, nameUserKeys, resolvedKeys } from "./resolved.js";
13
- import { gapMark } from "./gaps.js";
14
13
  import { expandViews, MAX_VIEW_DEPTH, viewRefused } from "./views.js";
15
14
  import { selectListAfterLimit } from "./late.js";
16
15
  import { pgTyplen } from "./types.js";
17
16
  import { datumText, masonCalls, rewrite, treeDifference, Unrewritable } from "./rewrite.js";
18
17
  import { exposes, hideKept, readKey, searchSchemas } from "./kept-route.js";
18
+ import { distinctCountTables, distinctKeyCounts, uniqueColumnsOf, uniqueColumnsSql } from "./distinct-key.js";
19
+ import { enforceGrants } from "./grants.js";
20
+ import { attributeRefused, expandRowFilters, wallStatistics, wallSwitch } from "./row-filter.js";
19
21
  const JSON_OID = 114, JSONB_OID = 3802;
20
22
  /** what the rewrite asks of the stored JSON paths (src/duck/kept-route.ts KeptLookup): the copied table and column of
21
23
  * each json or jsonb column the statement reads from a table (Typed.relation), by the catalog's OIDs; null when it
@@ -33,6 +35,15 @@ async function keptLookup(catalog, types, kept) {
33
35
  const at = new Map(rows.map(([relid, attno, schema, table, column]) => [`${relid}.${attno}`, { source: `${schema}.${table}`, column: column }]));
34
36
  return { at: (relid, attno) => at.get(`${relid}.${attno}`) ?? null, ready: (read) => kept.ready(read) };
35
37
  }
38
+ /** the count(DISTINCT x) of the statement that are count(x) (src/duck/distinct-key.ts), by their offsets into the
39
+ * analysed text: x unique by the CATALOG's own pg_index — the source's keys as the catalog declares them — read only
40
+ * for a statement that counts DISTINCT values at all */
41
+ async function distinctKeys(catalog, tree) {
42
+ const relids = distinctCountTables(tree);
43
+ if (relids.length === 0)
44
+ return new Set();
45
+ return distinctKeyCounts(tree, uniqueColumnsOf(await catalogRows(catalog, uniqueColumnsSql(relids))));
46
+ }
36
47
  /**
37
48
  * DuckDB's keywords that Postgres's are not: a column named `at` (DuckDB's AT TIME ZONE) or `qualify` is an identifier
38
49
  * the deparser writes bare, as Postgres reads it, and DuckDB refuses as a syntax error. The deparser's one quoting
@@ -96,11 +107,11 @@ export function missingRelationHint(relation, copies) {
96
107
  const matches = copies.filter((c) => c.source === relation || (!relation.includes(".") && c.source.endsWith(`.${relation}`)))
97
108
  .sort((a, b) => Number(b.source.startsWith("public.")) - Number(a.source.startsWith("public.")));
98
109
  const c = matches[0];
99
- const served = "it is served once its copy commits; mason wait waits for it (mason status shows how far it is)";
110
+ const served = "it is served once its first sync commits; mason wait waits for it (mason status shows how far it is)";
100
111
  if (c === undefined)
101
- return `a table is in Mason once it is added: mason add ${relation} copies it (mason tables lists the database's)`;
112
+ return `a table is in Mason once it is added: mason add ${relation} syncs it (mason tables lists the database's)`;
102
113
  switch (c.phase) {
103
- case "waiting": return `${c.source} is added and waits its turn to be copied: ${served}`;
114
+ case "waiting": return `${c.source} is added and waits its turn to be synced: ${served}`;
104
115
  case "copying": {
105
116
  // the share is of the heap's PAGES read (`CopyProgressView.share`: exact, so it never moves backwards), the count
106
117
  // the rows the committed chunks and the ones reading have in; a copy can outrun the source's estimate (Postgres's
@@ -113,13 +124,55 @@ export function missingRelationHint(relation, copies) {
113
124
  : of === null || of <= 0 ? `${share}about ${so} rows so far`
114
125
  : so > of ? `${share}${so} rows so far, past the ~${of} estimated`
115
126
  : `${share}about ${so} of ~${of} rows so far${p === undefined ? `, ${Math.min(99, Math.floor((so / of) * 100))}%` : ""}`;
116
- return `${c.source} is added and being copied (${how}): ${served}`;
127
+ return `${c.source} is added and being synced (${how}): ${served}`;
117
128
  }
118
- case "failed": return `${c.source} is added, but its copy failed: ${c.error ?? "no reason recorded"}; mason logs says more`;
119
- 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)`;
129
+ case "failed": return `${c.source} is added, but its first sync failed: ${c.error ?? "no reason recorded"}; mason logs says more`;
130
+ 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)`;
120
131
  }
121
132
  }
122
- export async function planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, collation, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null) {
133
+ /**
134
+ * THE TREE AS TEXT, DuckDB's `COLUMNS(…)` (decidedByKey's star, the statistics wall) unquoted: it is an expression only so,
135
+ * and the deparser quotes the keyword. Marked in the tree, never found in the printed text, where a literal or a name may
136
+ * hold the same characters: each call printed under a name the text holds nowhere else (the count says so), then that name
137
+ * replaced. No statement Postgres accepted calls a function "columns" (Postgres has none), and the read-back checks the text.
138
+ */
139
+ function printed(tree, statement) {
140
+ const calls = [];
141
+ const collect = (v) => {
142
+ if (Array.isArray(v)) {
143
+ v.forEach(collect);
144
+ return;
145
+ }
146
+ if (typeof v !== "object" || v === null)
147
+ return;
148
+ const name = v.FuncCall?.funcname;
149
+ if (name?.length === 1 && name[0]?.String?.sval === "columns")
150
+ calls.push(name[0]);
151
+ Object.values(v).forEach(collect);
152
+ };
153
+ collect(tree);
154
+ if (calls.length === 0)
155
+ return deparseSync(tree, { pretty: false });
156
+ // a text holds a mark at most once per few characters; a count that never agrees is a call the deparser does not print
157
+ const bound = deparseSync(tree, { pretty: false }).length;
158
+ for (let i = 0; i <= bound; i++) {
159
+ const mark = `__mason_columns_${i}`;
160
+ for (const c of calls)
161
+ c.String.sval = mark;
162
+ const text = deparseSync(tree, { pretty: false });
163
+ for (const c of calls)
164
+ c.String.sval = "columns";
165
+ if (text.split(`${mark}(`).length - 1 === calls.length)
166
+ return text.replaceAll(`${mark}(`, "COLUMNS(");
167
+ }
168
+ throw new StatementError({ code: "0A000", message: "Mason cannot print this statement for the engine (a Mason gap: report it, with the statement)" }, statement);
169
+ }
170
+ /** A statement's plan. `wireBound`: its rows are bounded by a row count an Execute sets on the wire, which its SQL
171
+ * does not say (src/duck/rewrite.ts markBounds; the listener asks for it where PlannedSql.unboundedLateral says so).
172
+ * `grants`: the session's role (src/duck/grants.ts), held after the analysis; null for a session with no role where
173
+ * roles exist (the plan still names its reads, for an Execute under a role given later); undefined with no roles at all.
174
+ * `walls`: `text` is the statement with its filtered reads in place (expandRowFilters), each of which the wall ends */
175
+ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, collation, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null, wireBound = false, grants = undefined, walls = null) {
123
176
  const started = performance.now();
124
177
  const analysis = await analyze(catalog, text, paramOids, pathSql(searchPath), timeZone);
125
178
  if ("refused" in analysis) {
@@ -138,13 +191,51 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
138
191
  if (depth >= MAX_VIEW_DEPTH)
139
192
  throw viewRefused(`Mason cannot yet answer views read through more than ${MAX_VIEW_DEPTH} other views`, "read the tables the views read");
140
193
  try {
141
- return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept);
194
+ return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept, wireBound, grants, walls);
142
195
  }
143
196
  catch (e) {
144
197
  if (!(e instanceof StatementError))
145
198
  throw e;
146
199
  const { position: _p, ...fields } = e.fields;
147
- throw new StatementError(fields, text);
200
+ throw new StatementError(fields, text, e.gap);
201
+ }
202
+ }
203
+ // THE ROLE (src/duck/grants.ts): the tables and columns Postgres's analysis says the statement reads, held to the
204
+ // session's role — after Postgres's own refusal and the views' expansion (the check sees the tables a view reads),
205
+ // before the rewrite (a role's refusal before a Mason gap's). The reads stay on the plan: an Execute under a role
206
+ // given or taken since the Parse is held to them again (src/api/postgres/server.ts)
207
+ const reads = grants === undefined ? undefined : await enforceGrants(catalog, analysis.tree, grants);
208
+ // THE ROLE'S ROWS (src/duck/row-filter.ts): every read of a table the role filters becomes its filtered read, and that
209
+ // statement is planned from the start — analysed by Postgres, rewritten and verified as any other (one grammar), held
210
+ // to nothing more (the grants were held above, on the statement as written: a filter may read a column the role
211
+ // may not, as a policy may on Postgres). Its errors are the expanded text's, so they carry no position into this one
212
+ if (grants?.rows !== undefined && grants.rows.size > 0) {
213
+ const filtered = await expandRowFilters(catalog, stmt, analysis, pathSql(searchPath), grants.rows);
214
+ if (filtered !== null) {
215
+ let inner;
216
+ try {
217
+ inner = await planDuck(catalog, filtered.text, filtered.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept, wireBound, undefined, filtered.walls);
218
+ }
219
+ catch (e) {
220
+ if (!(e instanceof StatementError))
221
+ throw e;
222
+ // a data exception may be a bound value's, not the statement's: `--set tenant=abc` on an integer
223
+ const bad = e.fields.code.startsWith("22") && e.gap === null ? await attributeRefused(catalog, grants.rows, filtered.tables) : null;
224
+ if (bad !== null) {
225
+ const user = JSON.stringify(grants.rowsUser ?? "");
226
+ 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`,
227
+ detail: `${bad.at} reads :${bad.name} as ${bad.type} (${bad.where}): ${bad.problem}`,
228
+ 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);
229
+ }
230
+ // the statement as written passed this check above, so what fails it is the table being a subquery (carryKeyDependency)
231
+ if (e.fields.code === "42803" || /could not identify an equality operator/.test(e.fields.message)) {
232
+ 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);
233
+ }
234
+ const { position: _p, ...fields } = e.fields;
235
+ throw new StatementError(fields, text, e.gap);
236
+ }
237
+ const rowFilters = filtered.tables.map((t) => ({ table: t, words: `filtered by the role ${grants.name}: ${grants.rows.get(t).words}` }));
238
+ return { ...inner, ...(reads !== undefined ? { reads } : {}), rowFilters };
148
239
  }
149
240
  }
150
241
  // the catalog's own OIDs for the types an extension made (citext): the rewrite knows a few by name, and the wire sends
@@ -183,31 +274,31 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
183
274
  f.oid = source;
184
275
  }
185
276
  }
186
- let tree, dropLast, exact, clock, jsonReads;
277
+ let tree, dropLast, exact, clock, jsonReads, unboundedLateral;
187
278
  // a table's row the source has otherwise (copied in part: no source OID for it) is not the source's row
188
279
  const sourceRows = new Map([...rowTypes].filter(([oid]) => userTypes.has(catalogTypes.get(oid) ?? "")));
189
280
  // the stored JSON paths the statement's JSON reads may be answered from (src/duck/kept-route.ts)
190
281
  const lookup = kept === null ? null : await keptLookup(catalog, analysis.types, kept);
282
+ const keyCounts = await distinctKeys(catalog, analysis.tree);
191
283
  const analysed = performance.now();
192
- const rewritten = (l = lookup) => rewrite(stmt, base, analysis, collation, catalogTypes, sourceRows, timeZone, enums, l);
284
+ const rewritten = (l = lookup) => rewrite(stmt, base, analysis, collation, catalogTypes, sourceRows, timeZone, enums, l, wireBound, keyCounts);
193
285
  try {
194
- ({ tree, dropLast, exact, clock, jsonReads } = rewritten());
286
+ ({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten());
195
287
  // the oracle's (`eager`): every read stored before the plan is made, and the plan made again over them
196
288
  if (kept?.keepNow != null && lookup !== null && jsonReads.some((r) => kept.ready(r) === null)) {
197
289
  await kept.keepNow(jsonReads);
198
- ({ tree, dropLast, exact, clock, jsonReads } = rewritten());
290
+ ({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten());
199
291
  }
200
292
  // a statement that lists a copy's columns without naming them reads none from a stored column (hideKept): made
201
293
  // again with every read its call, the reads still recorded
202
294
  if (lookup !== null && exposes(tree))
203
- ({ tree, dropLast, exact, clock, jsonReads } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
295
+ ({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
204
296
  }
205
297
  catch (e) {
206
298
  // a gap says its id (docs/SQL-SUPPORT.md); Postgres's own refusal is its SQLSTATE, and no gap
207
299
  if (e instanceof Unrewritable) {
208
- throw new StatementError(typeof e.gap === "string"
209
- ? { code: "0A000", message: e.message, hint: e.hint, detail: `${gapMark(e.gap)} (docs/SQL-SUPPORT.md)` }
210
- : { code: e.gap.postgres, message: e.message, hint: e.hint }, text);
300
+ throw typeof e.gap === "string" ? gapRefusal(e.gap, e.message, e.hint, text)
301
+ : new StatementError({ code: e.gap.postgres, message: e.message, hint: e.hint }, text);
211
302
  }
212
303
  throw e;
213
304
  }
@@ -224,23 +315,22 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
224
315
  if (unverified.length > 0) {
225
316
  const names = await describeKeys((sql) => catalogRows(catalog, sql), unverified);
226
317
  const listed = unverified.map((k) => names.get(k) ?? k);
227
- throw new StatementError({ code: "0A000",
228
- 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`,
229
- 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",
230
- detail: `${gapMark("unverified")}: ${unverified.join(", ")}` }, text);
318
+ 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(", ")}`);
231
319
  }
232
- // DuckDB's COLUMNS(t.*) (decidedByKey's star) is an expression only unquoted; the deparser quotes the keyword. No
233
- // statement Postgres accepted calls a function "columns" (Postgres has none), and the read-back below checks the text
234
- const sql = deparseSync(tree, { pretty: false }).replaceAll('any_value("columns"(', "any_value(COLUMNS(");
320
+ // only a statement with its filtered reads in place: no other statement's text changes, whatever it names its subqueries
321
+ if (walls !== null && wallSwitch.statistics)
322
+ wallStatistics(tree, walls);
323
+ const sql = printed(tree, text);
235
324
  // THE PRINTED TEXT MUST READ BACK AS THE TREE: a printing gap (the deparser drops or respells something) would run
236
325
  // another statement than the one Postgres analysed — refused instead, by name
237
326
  const back = (await parseSql(sql)).stmts?.[0]?.stmt;
238
327
  const difference = back === undefined ? "nothing" : treeDifference(back, tree);
239
328
  if (difference !== null) {
240
- 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)",
241
- detail: `read back: ${difference}; DuckDB SQL: ${sql}` }, text);
329
+ 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)",
330
+ detail: `read back: ${difference}; the rewritten SQL: ${sql}` }, text);
242
331
  }
243
- const planned = { text: sql, functions: masonCalls(tree) };
332
+ const planned = { text: sql, functions: masonCalls(tree), ...(unboundedLateral ? { unboundedLateral: true } : {}) };
244
333
  return { sql: planned, params: analysis.params, fields: fields?.map((f) => ({ ...f, size: pgTyplen(f.oid) })) ?? null, dropLast, ...(exact.length > 0 ? { exact } : {}), resolved: keys,
245
- ...(Object.keys(clock).length > 0 ? { clock } : {}), ...(jsonReads.length > 0 ? { jsonReads: jsonReads.map(readKey) } : {}), timing: { analysisMs: analysed - started, rewriteMs: performance.now() - analysed } };
334
+ ...(Object.keys(clock).length > 0 ? { clock } : {}), ...(jsonReads.length > 0 ? { jsonReads: jsonReads.map(readKey) } : {}), ...(reads !== undefined ? { reads } : {}),
335
+ timing: { analysisMs: analysed - started, rewriteMs: performance.now() - analysed } };
246
336
  }
@@ -1,8 +1,8 @@
1
1
  const requiredOf = (t) => t.columns.filter((c) => !c.generated).map((c) => c.name);
2
2
  /** what follows every refusal: Mason waits for the owner, and when it looks again */
3
3
  const NEVER_ALTERED = "Mason reads a publication it is given as it is, never altering it: once its owner has run that, the next mason start " +
4
- "(or mason add) checks it again. Or mason start --replication without --publication, where Mason makes its own (not on a read " +
5
- "replica), or mason start --poll, which needs no publication.";
4
+ "(or mason add) checks it again. Or give the table's entry sync: poll <interval> in the sources file, which needs no publication; " +
5
+ "or write publication: null in it, where Mason makes its own, carrying the replicated tables alone (not on a read replica).";
6
6
  /**
7
7
  * The verdict over a described publication (pure: `described` is what `readNamedPublication` read). `identities` are
8
8
  * the tables' `relreplident`: a table whose identity is not its key is expected WHOLE (a column list missing an
@@ -56,13 +56,13 @@ function checkPublication(required, described, whole) {
56
56
  continue;
57
57
  if (d.published === null) {
58
58
  problems.push(`${table} is not published`);
59
- missingLines.push(` missing: ${table} (read by Mason's copy)`);
59
+ missingLines.push(` missing: ${table} (read by Mason's sync)`);
60
60
  continue;
61
61
  }
62
62
  const lacking = inCatalogOrder(d, cols).filter((c) => !d.published.includes(c));
63
63
  if (lacking.length) {
64
64
  problems.push(`${table} columns ${lacking.join(", ")} are not published`);
65
- missingLines.push(` missing: ${table} columns ${lacking.join(", ")} (read by Mason's copy)`);
65
+ missingLines.push(` missing: ${table} columns ${lacking.join(", ")} (read by Mason's sync)`);
66
66
  }
67
67
  if (d.rowFilter !== null) {
68
68
  problems.push(`${table} is published with a row filter`);
@@ -103,7 +103,7 @@ function checkPublication(required, described, whole) {
103
103
  (found.size ? tableLines(found, columnLists).join(",\n") : " (no tables)")]
104
104
  : [`Found: no publication named ${pub.name}. Publications on this database: ${pub.others.length ? pub.others.join(", ") : "none"}.`];
105
105
  return [
106
- `publication ${pub.name} does not carry what Mason's copy of the table holds.`,
106
+ `publication ${pub.name} does not carry what Mason's synced table holds.`,
107
107
  "",
108
108
  ...(missingLines.length ? [...missingLines, ""] : []),
109
109
  ...(publishes.length && pub.exists ? [` it publishes ${pub.publish.join(", ") || "nothing"}; the change reader needs insert, update and delete`, ""] : []),
@@ -185,7 +185,7 @@ export function describedOf(answer, publication) {
185
185
  }
186
186
  /** The named publication and the copied tables as the live catalog describes them: read-only, two statements.
187
187
  * (`_project`, the project's name, is unused: the tables are found by their own names.) */
188
- export async function readNamedPublication(pg, publication, tables, _project) {
188
+ export async function readNamedPublication(pg, publication, tables) {
189
189
  const one = async (sql) => {
190
190
  const v = (await pg.query(sql)).rows[0]?.[0];
191
191
  if (typeof v !== "string")