@mason-data/cli 0.7.0 → 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 +90 -71
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +222 -13
- 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 +1173 -207
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +29 -6
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +1 -0
- package/dist/src/cli/commands.js +622 -183
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +19 -10
- package/dist/src/cli/dev.js +351 -111
- package/dist/src/cli/doctor.js +20 -10
- package/dist/src/cli/guides.js +79 -31
- package/dist/src/cli/init.js +119 -58
- package/dist/src/cli/machine.js +12 -11
- package/dist/src/cli/main.js +3 -3
- package/dist/src/cli/mcp.js +29 -20
- 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 +8 -7
- package/dist/src/cli/project.js +43 -4
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +53 -34
- 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 +483 -41
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +144 -42
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +65 -13
- package/dist/src/cli/sources.js +287 -38
- 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 +3 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +18 -9
- package/dist/src/duck/executor.js +43 -30
- package/dist/src/duck/explain.js +56 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +13 -14
- package/dist/src/duck/gaps.js +17 -8
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/kept.js +6 -6
- package/dist/src/duck/plan.js +102 -25
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +25 -5
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +156 -120
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +835 -268
- 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 +3 -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/web/app.css +706 -66
- package/dist/src/web/app.js +327 -410
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- 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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROLES: WHAT A USER MAY READ, HELD ON THE ANALYSED TREE (GitHub issue 30, stage 1: table and column grants per role).
|
|
3
|
+
* A role is an entry of the project's roles file (`mason/roles.yml`, src/cli/roles.ts): for each copied table, every
|
|
4
|
+
* column (`{}`), `columns:` or `exclude:`, the sources file's own words — or `tables: all`, every copied table and column.
|
|
5
|
+
* A `mason user` carries a role (src/duck/users.ts `MasonUser.role`); a user with none is held to the file's
|
|
6
|
+
* `default:` role, and reads nothing when the file names none (stage 1b: the FILE decides, never the code); with no
|
|
7
|
+
* roles file at all, every user reads every copied table. A token (no user) reads every copied table.
|
|
8
|
+
*
|
|
9
|
+
* ENFORCED ON THE TREE POSTGRES ANALYSED, NEVER ON SQL TEXT: Postgres puts one `RTEPERMISSIONINFO` per relation in the
|
|
10
|
+
* query tree it analyses (src/duck/analyze.ts), with `selectedCols` as its own bitmap of the columns the statement
|
|
11
|
+
* reads — a column read inside a CTE, a subquery, a join's USING, a window, a WHERE or a CASE is in it; `SELECT *` lists
|
|
12
|
+
* every column; `count(*)` reads none; a whole-row reference (`SELECT t FROM t`) is the bit for attnum 0, which means
|
|
13
|
+
* every column. This is the information Postgres's own executor checks privileges with (ExecCheckPermissions), so an
|
|
14
|
+
* alias, a subquery or a view cannot get around it: a view is expanded by Mason itself before the check
|
|
15
|
+
* (src/duck/views.ts), so the check sees the tables it reads — stricter than Postgres, where a grant on the view
|
|
16
|
+
* suffices, and said so in docs/POSTGRES-API.md.
|
|
17
|
+
*
|
|
18
|
+
* THE REFUSAL IS POSTGRES'S: 42501 `permission denied for table orders`, the words Postgres 18 answers whether the
|
|
19
|
+
* table or one of its columns is missing from the grants (its executor names the table only), so a tool handles it as
|
|
20
|
+
* it handles Postgres's. The DETAIL names the role and the first hidden column, the HINT what the role may read. The
|
|
21
|
+
* rules are Postgres's too: `count(*)` over a table is answered when the role has any column of it; `SELECT *` over a
|
|
22
|
+
* table the role has only part of is refused, never narrowed (a narrowed `*` would change the result's columns); a
|
|
23
|
+
* system column (xmin, ctid) needs the whole table.
|
|
24
|
+
*/
|
|
25
|
+
import { StatementError } from "../api/postgres/server.js";
|
|
26
|
+
import { catalogRows } from "./analyze.js";
|
|
27
|
+
/** no roles file: every session reads every copied table, as before; one object, so the listener sees no change */
|
|
28
|
+
export const NO_ROLES = { file: null, grants: new Map(), problem: null, default: null };
|
|
29
|
+
/**
|
|
30
|
+
* THE CATALOG'S ROLE OF A USER THE FILE HOLDS TO NOTHING (no role of its own, no `default:`): a Postgres role with no
|
|
31
|
+
* grant, so its catalog requests answer as Postgres answers a login with none — information_schema lists none of the
|
|
32
|
+
* copied tables or views, every `has_*_privilege` is false, `set_config` is revoked (while any role exists; a session's
|
|
33
|
+
* own `set_config` never reaches the catalog, src/api/postgres/translate.ts setConfig).
|
|
34
|
+
* Named outside the file's namespace: `ROLE_NAME_RE` (src/cli/roles.ts) forbids a leading underscore, so no role of the
|
|
35
|
+
* file can ever be it.
|
|
36
|
+
*/
|
|
37
|
+
export const NO_ROLE_CATALOG = "__mason_no_role";
|
|
38
|
+
/** The roles the catalog Postgres holds (src/api/postgres/catalog.ts catalogRoles): the file's, and — whenever a roles
|
|
39
|
+
* file exists — `NO_ROLE_CATALOG`, a role with no table, for a user the file holds to nothing. */
|
|
40
|
+
export function catalogRolesOf(live) {
|
|
41
|
+
if (live.file === null)
|
|
42
|
+
return live.grants;
|
|
43
|
+
return new Map([...live.grants, [NO_ROLE_CATALOG, { name: NO_ROLE_CATALOG, file: live.file, tables: new Map(), all: false }]]);
|
|
44
|
+
}
|
|
45
|
+
/** `FirstLowInvalidHeapAttributeNumber` is -7: a column's bit in `selectedCols` is its attnum + 7 */
|
|
46
|
+
const ATTNUM_OFFSET = 7;
|
|
47
|
+
/** ACL_SELECT in `requiredPerms` */
|
|
48
|
+
const ACL_SELECT = 2;
|
|
49
|
+
const isNode = (v) => v !== null && v !== undefined && typeof v === "object" && !Array.isArray(v);
|
|
50
|
+
/** Every RTEPERMISSIONINFO of the tree that asks SELECT, with its bitmap read as attnums. */
|
|
51
|
+
export function tableReads(tree) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const visit = (v) => {
|
|
54
|
+
if (Array.isArray(v)) {
|
|
55
|
+
for (const x of v)
|
|
56
|
+
visit(x);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!isNode(v))
|
|
60
|
+
return;
|
|
61
|
+
if (v._node === "RTEPERMISSIONINFO") {
|
|
62
|
+
const perms = Number(v.requiredPerms ?? 0);
|
|
63
|
+
const relid = Number(v.relid ?? 0);
|
|
64
|
+
if ((perms & ACL_SELECT) !== 0 && relid > 0) {
|
|
65
|
+
// a bitmapset prints as `(b 8 9)`: the `b` first, then each member
|
|
66
|
+
const bits = Array.isArray(v.selectedCols) ? v.selectedCols.filter((x) => typeof x === "string" && /^\d+$/.test(x)).map(Number) : [];
|
|
67
|
+
const read = { relid, attnums: [], wholeRow: false, system: false };
|
|
68
|
+
for (const b of bits) {
|
|
69
|
+
const attnum = b - ATTNUM_OFFSET;
|
|
70
|
+
if (attnum === 0)
|
|
71
|
+
read.wholeRow = true;
|
|
72
|
+
else if (attnum < 0)
|
|
73
|
+
read.system = true;
|
|
74
|
+
else
|
|
75
|
+
read.attnums.push(attnum);
|
|
76
|
+
}
|
|
77
|
+
out.push(read);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const [k, x] of Object.entries(v))
|
|
81
|
+
if (k !== "_node")
|
|
82
|
+
visit(x);
|
|
83
|
+
};
|
|
84
|
+
visit(tree);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The reads named through the catalog Postgres's own pg_class and pg_attribute (dropped columns left out, as Postgres
|
|
89
|
+
* leaves them): the copied tables only (relkind r) — a view is never here, since Mason expands it before the plan is
|
|
90
|
+
* made (src/duck/views.ts), and a catalog relation is not a copy.
|
|
91
|
+
*/
|
|
92
|
+
export async function resolveReads(catalog, reads) {
|
|
93
|
+
if (reads.length === 0)
|
|
94
|
+
return [];
|
|
95
|
+
const relids = [...new Set(reads.map((r) => Math.trunc(r.relid)))];
|
|
96
|
+
const rows = await catalogRows(catalog, `SELECT c.oid::text, n.nspname, c.relname, a.attnum::text, a.attname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace ` +
|
|
97
|
+
`LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped WHERE c.oid IN (${relids.join(", ")}) AND c.relkind = 'r' ` +
|
|
98
|
+
// the copied tables alone: pg_class and the rest of pg_catalog are relkind 'r' too, and PUBLIC's on Postgres (a review finding of #35)
|
|
99
|
+
`AND n.nspname NOT IN ('pg_catalog', 'information_schema') ORDER BY c.oid, a.attnum`);
|
|
100
|
+
const tables = new Map();
|
|
101
|
+
for (const [oid, schema, name, attnum, attname] of rows) {
|
|
102
|
+
const t = tables.get(Number(oid)) ?? { table: `${schema}.${name}`, columns: new Map() };
|
|
103
|
+
if (attnum !== null && attnum !== undefined && attname !== null && attname !== undefined)
|
|
104
|
+
t.columns.set(Number(attnum), attname);
|
|
105
|
+
tables.set(Number(oid), t);
|
|
106
|
+
}
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const r of reads) {
|
|
109
|
+
const t = tables.get(r.relid);
|
|
110
|
+
if (t === undefined)
|
|
111
|
+
continue;
|
|
112
|
+
const all = [...t.columns.entries()].sort((a, b) => a[0] - b[0]).map(([, n]) => n);
|
|
113
|
+
const named = r.wholeRow ? all : [...r.attnums].sort((a, b) => a - b).map((n) => t.columns.get(n)).filter((n) => n !== undefined);
|
|
114
|
+
out.push({ table: t.table, columns: named, all, system: r.system });
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/** a table as a tool writes it: its name alone in public, else schema.table (the sources file's own spelling) */
|
|
119
|
+
const shown = (table) => table.startsWith("public.") ? table.slice("public.".length) : table;
|
|
120
|
+
/** The 42501 of a read the role does not cover, Postgres's words first; null when the role covers every read. */
|
|
121
|
+
export function grantRefusal(reads, role) {
|
|
122
|
+
if (role.noRole !== undefined)
|
|
123
|
+
return reads.length === 0 ? null : noRoleRefusal(reads[0].table, role.file, role.noRole);
|
|
124
|
+
const tablesOf = () => {
|
|
125
|
+
if (role.all)
|
|
126
|
+
return "every synced table (tables: all)";
|
|
127
|
+
const names = [...role.tables.keys()].map(shown);
|
|
128
|
+
return names.length ? names.join(", ") : "none";
|
|
129
|
+
};
|
|
130
|
+
for (const r of reads) {
|
|
131
|
+
const g = role.tables.get(r.table);
|
|
132
|
+
const denied = (detail, hint) => new StatementError({ code: "42501", message: `permission denied for table ${r.table.replace(/^[^.]+\./, "")}`, detail, hint });
|
|
133
|
+
if (g === undefined) {
|
|
134
|
+
return denied(`the role ${role.name} has no grant on ${shown(r.table)}`, `the role ${role.name} reads: ${tablesOf()} (${role.file} names what each role reads; Mason gap roles)`);
|
|
135
|
+
}
|
|
136
|
+
if (g.all)
|
|
137
|
+
continue;
|
|
138
|
+
const may = `the role ${role.name} reads these columns of ${shown(r.table)}: ${g.columns.join(", ")} (${role.file} names what each role reads; Mason gap roles)`;
|
|
139
|
+
if (r.system)
|
|
140
|
+
return denied(`the role ${role.name} may read columns of ${shown(r.table)}, and a system column needs the whole table`, may);
|
|
141
|
+
const hidden = r.columns.find((c) => !g.columns.includes(c));
|
|
142
|
+
if (hidden !== undefined)
|
|
143
|
+
return denied(`the role ${role.name} may not read column ${hidden} of ${shown(r.table)}`, may);
|
|
144
|
+
// no column read (count(*)): any column of the table is enough, as Postgres rules it, and a grant names at least one
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* THE 42501 OF A USER WITH NO ROLE UNDER A FILE THAT NAMES NO DEFAULT (stage 1b): Postgres's message for the first
|
|
150
|
+
* table read, the reason in the DETAIL, and both ways out as the exact lines in the HINT — the file's `default:`, or
|
|
151
|
+
* the user made again with a role. `file`: the roles file as messages name it.
|
|
152
|
+
*/
|
|
153
|
+
export function noRoleRefusal(table, file, who) {
|
|
154
|
+
return new StatementError({ code: "42501", message: `permission denied for table ${table.replace(/^[^.]+\./, "")}`,
|
|
155
|
+
detail: `the user "${who.user}" has no role and ${file} names no default`, hint: noRoleWayOut(who.user, file, who.example) });
|
|
156
|
+
}
|
|
157
|
+
/** The two ways out for a user with no role under a file with no default, as exact lines: ONE wording, for the
|
|
158
|
+
* refusal's HINT, `mason start`, `mason user list` and `mason user add`. */
|
|
159
|
+
export const noRoleWayOut = (user, file, example) => `write default: ${example} at the top of ${file}, or mason user remove ${user} and mason user add ${user} --role ${example}`;
|
|
160
|
+
/** A role of the file to name in a way out: one of `tables: all` where there is one (the role today's access is), else
|
|
161
|
+
* the first, or a placeholder where it names none. */
|
|
162
|
+
export const exampleRole = (live) => [...live.grants.values()].find((g) => g.all)?.name ?? [...live.grants.keys()][0] ?? "<a role>";
|
|
163
|
+
/**
|
|
164
|
+
* The statement's table reads held to the role: the tree's reads named through the catalog, then refused where the role
|
|
165
|
+
* does not cover one. Called by the plan (src/duck/plan.ts) after Postgres's analysis and the views' expansion, before
|
|
166
|
+
* the rewrite — so Postgres's own refusal (an unknown column) comes first, and a role's before a Mason gap.
|
|
167
|
+
*/
|
|
168
|
+
export async function enforceGrants(catalog, tree, role) {
|
|
169
|
+
const reads = await resolveReads(catalog, tableReads(tree));
|
|
170
|
+
if (role !== null) {
|
|
171
|
+
const refused = grantRefusal(reads, role);
|
|
172
|
+
if (refused !== null)
|
|
173
|
+
throw refused;
|
|
174
|
+
}
|
|
175
|
+
return reads;
|
|
176
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { MASON_SCHEMA } from "./store.js";
|
|
2
|
+
/** How many days every history table keeps (the request log, the sync's events, the usage samples, the copies'
|
|
3
|
+
* sizes) unless MASON_HISTORY_DAYS says. */
|
|
4
|
+
export const DEFAULT_HISTORY_DAYS = 7;
|
|
5
|
+
const KEPT = "the request log, the sync's events, the usage samples and the synced tables' sizes";
|
|
6
|
+
/** The days MASON_HISTORY_DAYS keeps, or what is wrong with the text. Unset or empty: the default. */
|
|
7
|
+
export function historyDays(text) {
|
|
8
|
+
if (text === undefined || text.trim() === "")
|
|
9
|
+
return DEFAULT_HISTORY_DAYS;
|
|
10
|
+
const days = Number(text);
|
|
11
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
12
|
+
return { message: `MASON_HISTORY_DAYS=${JSON.stringify(text)} is not a number of days`,
|
|
13
|
+
why: `it is how many days Mason keeps its history (${KEPT}): a whole number, 1 or more`,
|
|
14
|
+
fix: `write MASON_HISTORY_DAYS=${DEFAULT_HISTORY_DAYS} (the default, as when it is unset) or another whole number of days` };
|
|
15
|
+
}
|
|
16
|
+
return days;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* THE HISTORY'S DAYS, AS THE ENVIRONMENT SETS THEM — read by this one function at every entrance (`mason serve`'s
|
|
20
|
+
* settings report, `mason start` before it spawns, and the run before anything opens), so a wrong setting is refused
|
|
21
|
+
* before the copy is touched, in the Failure shape. `MASON_REQUEST_LOG_DAYS` SET IS REFUSED, never run on the default
|
|
22
|
+
* (CLAUDE.md: a renamed setting refuses the start, naming the line to write): it kept the request log alone once, and
|
|
23
|
+
* the days kept are every history table's now, so it is MASON_HISTORY_DAYS. Then MASON_HISTORY_DAYS must be a number
|
|
24
|
+
* of days (`historyDays`).
|
|
25
|
+
*/
|
|
26
|
+
export function historyDaysSetting(env) {
|
|
27
|
+
const v = env.MASON_REQUEST_LOG_DAYS;
|
|
28
|
+
if (v !== undefined && v !== "") {
|
|
29
|
+
return { message: `MASON_REQUEST_LOG_DAYS=${v} is no longer read`,
|
|
30
|
+
why: `it was renamed MASON_HISTORY_DAYS, since the days it keeps are every history's (${KEPT})`,
|
|
31
|
+
fix: `write MASON_HISTORY_DAYS=${v.trim()} instead, then unset MASON_REQUEST_LOG_DAYS` };
|
|
32
|
+
}
|
|
33
|
+
return historyDays(env.MASON_HISTORY_DAYS);
|
|
34
|
+
}
|
|
35
|
+
/** what is wrong, as one line of `mason serve`'s settings report */
|
|
36
|
+
export function historyDaysWrongText(w) {
|
|
37
|
+
return `${w.message}: ${w.why} — ${w.fix}`;
|
|
38
|
+
}
|
|
39
|
+
/** how often the rows past the retention are deleted (and once at open) */
|
|
40
|
+
export const TRIM_EVERY_MS = 60 * 60 * 1000;
|
|
41
|
+
/** A history table's connections and its hourly trim. */
|
|
42
|
+
export class History {
|
|
43
|
+
writer;
|
|
44
|
+
reader;
|
|
45
|
+
trimTimer = null;
|
|
46
|
+
constructor(writer, reader) { this.writer = writer; this.reader = reader; }
|
|
47
|
+
/** The writer connected, `_mason` and each of `ddl` run on it in order, then the reader connected. */
|
|
48
|
+
static async open(store, ddl) {
|
|
49
|
+
const writer = await store.instance.connect();
|
|
50
|
+
await writer.run(`CREATE SCHEMA IF NOT EXISTS ${MASON_SCHEMA}`);
|
|
51
|
+
for (const d of ddl)
|
|
52
|
+
await writer.run(d);
|
|
53
|
+
const reader = await store.instance.connect();
|
|
54
|
+
return new History(writer, reader);
|
|
55
|
+
}
|
|
56
|
+
/** `trim` now (its failure is the open's), then every TRIM_EVERY_MS, an hourly one that fails said by `failed`. */
|
|
57
|
+
async keepTrimmed(trim, failed) {
|
|
58
|
+
await trim();
|
|
59
|
+
this.trimTimer = setInterval(() => { trim().catch((e) => failed(e)); }, TRIM_EVERY_MS);
|
|
60
|
+
this.trimTimer.unref();
|
|
61
|
+
}
|
|
62
|
+
/** No more hourly trims (the first step of a close). */
|
|
63
|
+
stopTrimming() {
|
|
64
|
+
if (this.trimTimer !== null) {
|
|
65
|
+
clearInterval(this.trimTimer);
|
|
66
|
+
this.trimTimer = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Both connections closed (the last step of a close). */
|
|
70
|
+
closeConnections() {
|
|
71
|
+
this.writer.closeSync();
|
|
72
|
+
this.reader.closeSync();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** A streak of failures, said once: `failed` says the first of a streak and counts what each cost; `succeeded` says,
|
|
76
|
+
* once, what the streak cost, and ends it. */
|
|
77
|
+
export class Streak {
|
|
78
|
+
lost = 0;
|
|
79
|
+
failed(cost, sayFirst) {
|
|
80
|
+
if (this.lost === 0)
|
|
81
|
+
sayFirst();
|
|
82
|
+
this.lost += cost;
|
|
83
|
+
}
|
|
84
|
+
succeeded(sayEnd) {
|
|
85
|
+
if (this.lost > 0) {
|
|
86
|
+
sayEnd(this.lost);
|
|
87
|
+
this.lost = 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Rows written in batches: `push` one; a batch is written by `write` once `rows` are waiting or after `ms`, one batch
|
|
92
|
+
* at a time. `write` must not throw: a failure is its own to say (a Streak). */
|
|
93
|
+
export class Batches {
|
|
94
|
+
buffer = [];
|
|
95
|
+
timer = null;
|
|
96
|
+
/** the write in flight: one at a time, so batches commit in the order they were taken */
|
|
97
|
+
flushing = Promise.resolve();
|
|
98
|
+
rows;
|
|
99
|
+
ms;
|
|
100
|
+
write;
|
|
101
|
+
constructor(o) { this.rows = o.rows; this.ms = o.ms; this.write = o.write; }
|
|
102
|
+
push(row) {
|
|
103
|
+
this.buffer.push(row);
|
|
104
|
+
if (this.buffer.length >= this.rows) {
|
|
105
|
+
void this.flush();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.timer ??= setTimeout(() => { void this.flush(); }, this.ms);
|
|
109
|
+
}
|
|
110
|
+
/** Writes what is waiting, after any write in flight. Resolves whether it was written or not (`write` says a failure). */
|
|
111
|
+
flush() {
|
|
112
|
+
if (this.timer !== null) {
|
|
113
|
+
clearTimeout(this.timer);
|
|
114
|
+
this.timer = null;
|
|
115
|
+
}
|
|
116
|
+
const rows = this.buffer;
|
|
117
|
+
this.buffer = [];
|
|
118
|
+
if (rows.length === 0)
|
|
119
|
+
return this.flushing;
|
|
120
|
+
this.flushing = this.flushing.then(() => this.write(rows));
|
|
121
|
+
return this.flushing;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/dist/src/duck/kept.js
CHANGED
|
@@ -89,7 +89,7 @@ export function storeJsonPathsOf(env) {
|
|
|
89
89
|
if (v === "off")
|
|
90
90
|
return false;
|
|
91
91
|
throw new Error(`${STORE_JSON_PATHS_SETTING}=${JSON.stringify(env[STORE_JSON_PATHS_SETTING])} is not a setting: on (the default) stores the JSON paths statements keep reading ` +
|
|
92
|
-
"as columns of the
|
|
92
|
+
"as columns of the synced table, off stores none (and drops those stored, at this start)");
|
|
93
93
|
}
|
|
94
94
|
const ident = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
95
95
|
const literal = (s) => `'${s.replaceAll("'", "''")}'`;
|
|
@@ -381,7 +381,7 @@ export class KeptJsonPaths {
|
|
|
381
381
|
for (const p of this.paths.values()) {
|
|
382
382
|
if (p.state === "dropped" || p.state === "waiting" || named.has(`${p.source}\u0000${p.name}`))
|
|
383
383
|
continue;
|
|
384
|
-
await this.drop(w, p, "its column is not in the
|
|
384
|
+
await this.drop(w, p, "its column is not in the synced table");
|
|
385
385
|
}
|
|
386
386
|
// a jsonb's stored key is its collation's, told after this (`orderBy`): until then a write leaves it unset and no
|
|
387
387
|
// plan reads it, so a ready one is filled again where unset first — the backfill's sweep alone (its ranges are done)
|
|
@@ -482,7 +482,7 @@ export class KeptJsonPaths {
|
|
|
482
482
|
async swapping(c, source, columns) {
|
|
483
483
|
this.gone = [];
|
|
484
484
|
for (const p of this.physical(source).filter((x) => !columns.includes(x.column))) {
|
|
485
|
-
const why = `${p.column} is no longer a column of the
|
|
485
|
+
const why = `${p.column} is no longer a column of the synced table ${source}`;
|
|
486
486
|
await c.run(`UPDATE ${PATHS_TABLE} SET state = 'dropped', why = ${literal(why)} WHERE name = ${literal(p.name)}`);
|
|
487
487
|
this.gone.push({ p, why });
|
|
488
488
|
}
|
|
@@ -662,7 +662,7 @@ export class KeptJsonPaths {
|
|
|
662
662
|
await this.check(p);
|
|
663
663
|
}
|
|
664
664
|
catch (e) {
|
|
665
|
-
this.host.log(`stored JSON paths: the review failed, and runs again in ${Math.round(this.reviewMs / 1000)} s (the
|
|
665
|
+
this.host.log(`stored JSON paths: the review failed, and runs again in ${Math.round(this.reviewMs / 1000)} s (the synced tables serve as they were): ${e instanceof Error ? e.message : String(e)}`);
|
|
666
666
|
}
|
|
667
667
|
finally {
|
|
668
668
|
this.reviewing = null;
|
|
@@ -726,10 +726,10 @@ export class KeptJsonPaths {
|
|
|
726
726
|
return false;
|
|
727
727
|
const name = hiddenName(read);
|
|
728
728
|
const col = cols.find((c) => c.name === read.column);
|
|
729
|
-
const why = col === undefined ? `${read.column} is not a column of the
|
|
729
|
+
const why = col === undefined ? `${read.column} is not a column of the synced table`
|
|
730
730
|
: !/^jsonb?$/.test(col.type) ? `${read.column} is a ${col.type}, not a json or jsonb`
|
|
731
731
|
: isKeyRead(read) && col.type !== "jsonb" ? `${read.column} is a ${col.type}, which has no order or equality (a jsonb's is stored)`
|
|
732
|
-
: cols.some((c) => c.name.toLowerCase() === name) ? `the
|
|
732
|
+
: cols.some((c) => c.name.toLowerCase() === name) ? `the synced table has a column named ${name} of its own` : null;
|
|
733
733
|
if (why === null)
|
|
734
734
|
return true;
|
|
735
735
|
const key = pathKey(read);
|
package/dist/src/duck/plan.js
CHANGED
|
@@ -6,17 +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";
|
|
19
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";
|
|
20
21
|
const JSON_OID = 114, JSONB_OID = 3802;
|
|
21
22
|
/** what the rewrite asks of the stored JSON paths (src/duck/kept-route.ts KeptLookup): the copied table and column of
|
|
22
23
|
* each json or jsonb column the statement reads from a table (Typed.relation), by the catalog's OIDs; null when it
|
|
@@ -106,11 +107,11 @@ export function missingRelationHint(relation, copies) {
|
|
|
106
107
|
const matches = copies.filter((c) => c.source === relation || (!relation.includes(".") && c.source.endsWith(`.${relation}`)))
|
|
107
108
|
.sort((a, b) => Number(b.source.startsWith("public.")) - Number(a.source.startsWith("public.")));
|
|
108
109
|
const c = matches[0];
|
|
109
|
-
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)";
|
|
110
111
|
if (c === undefined)
|
|
111
|
-
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)`;
|
|
112
113
|
switch (c.phase) {
|
|
113
|
-
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}`;
|
|
114
115
|
case "copying": {
|
|
115
116
|
// the share is of the heap's PAGES read (`CopyProgressView.share`: exact, so it never moves backwards), the count
|
|
116
117
|
// the rows the committed chunks and the ones reading have in; a copy can outrun the source's estimate (Postgres's
|
|
@@ -123,15 +124,55 @@ export function missingRelationHint(relation, copies) {
|
|
|
123
124
|
: of === null || of <= 0 ? `${share}about ${so} rows so far`
|
|
124
125
|
: so > of ? `${share}${so} rows so far, past the ~${of} estimated`
|
|
125
126
|
: `${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
|
|
127
|
+
return `${c.source} is added and being synced (${how}): ${served}`;
|
|
127
128
|
}
|
|
128
|
-
case "failed": return `${c.source} is added, but its
|
|
129
|
-
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)`;
|
|
130
131
|
}
|
|
131
132
|
}
|
|
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
|
+
}
|
|
132
170
|
/** 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
|
-
|
|
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) {
|
|
135
176
|
const started = performance.now();
|
|
136
177
|
const analysis = await analyze(catalog, text, paramOids, pathSql(searchPath), timeZone);
|
|
137
178
|
if ("refused" in analysis) {
|
|
@@ -150,13 +191,51 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
150
191
|
if (depth >= MAX_VIEW_DEPTH)
|
|
151
192
|
throw viewRefused(`Mason cannot yet answer views read through more than ${MAX_VIEW_DEPTH} other views`, "read the tables the views read");
|
|
152
193
|
try {
|
|
153
|
-
return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept, wireBound);
|
|
194
|
+
return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept, wireBound, grants, walls);
|
|
154
195
|
}
|
|
155
196
|
catch (e) {
|
|
156
197
|
if (!(e instanceof StatementError))
|
|
157
198
|
throw e;
|
|
158
199
|
const { position: _p, ...fields } = e.fields;
|
|
159
|
-
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 };
|
|
160
239
|
}
|
|
161
240
|
}
|
|
162
241
|
// the catalog's own OIDs for the types an extension made (citext): the rewrite knows a few by name, and the wire sends
|
|
@@ -218,9 +297,8 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
218
297
|
catch (e) {
|
|
219
298
|
// a gap says its id (docs/SQL-SUPPORT.md); Postgres's own refusal is its SQLSTATE, and no gap
|
|
220
299
|
if (e instanceof Unrewritable) {
|
|
221
|
-
throw
|
|
222
|
-
|
|
223
|
-
: { 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);
|
|
224
302
|
}
|
|
225
303
|
throw e;
|
|
226
304
|
}
|
|
@@ -237,23 +315,22 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
237
315
|
if (unverified.length > 0) {
|
|
238
316
|
const names = await describeKeys((sql) => catalogRows(catalog, sql), unverified);
|
|
239
317
|
const listed = unverified.map((k) => names.get(k) ?? k);
|
|
240
|
-
throw
|
|
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);
|
|
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(", ")}`);
|
|
244
319
|
}
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
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);
|
|
248
324
|
// THE PRINTED TEXT MUST READ BACK AS THE TREE: a printing gap (the deparser drops or respells something) would run
|
|
249
325
|
// another statement than the one Postgres analysed — refused instead, by name
|
|
250
326
|
const back = (await parseSql(sql)).stmts?.[0]?.stmt;
|
|
251
327
|
const difference = back === undefined ? "nothing" : treeDifference(back, tree);
|
|
252
328
|
if (difference !== null) {
|
|
253
|
-
throw new StatementError({ code: "0A000", message: "Mason cannot run this statement
|
|
254
|
-
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);
|
|
255
331
|
}
|
|
256
332
|
const planned = { text: sql, functions: masonCalls(tree), ...(unboundedLateral ? { unboundedLateral: true } : {}) };
|
|
257
333
|
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) } : {}),
|
|
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 } };
|
|
259
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")
|