@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.
- package/README.md +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
package/dist/src/duck/plan.js
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
|
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
|
|
127
|
+
return `${c.source} is added and being synced (${how}): ${served}`;
|
|
117
128
|
}
|
|
118
|
-
case "failed": return `${c.source} is added, but its
|
|
119
|
-
case "copied": return `${c.source} was just
|
|
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
|
-
|
|
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
|
|
209
|
-
|
|
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
|
|
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
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
241
|
-
detail: `read back: ${difference};
|
|
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) } : {}),
|
|
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
|
|
5
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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")
|