@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.
Files changed (111) hide show
  1. package/README.md +90 -71
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +222 -13
  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 +1173 -207
  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/poll.js +5 -5
  11. package/dist/src/cli/background.js +29 -6
  12. package/dist/src/cli/cancel.js +75 -0
  13. package/dist/src/cli/command.js +1 -0
  14. package/dist/src/cli/commands.js +622 -183
  15. package/dist/src/cli/control.js +59 -0
  16. package/dist/src/cli/demo.js +19 -10
  17. package/dist/src/cli/dev.js +351 -111
  18. package/dist/src/cli/doctor.js +20 -10
  19. package/dist/src/cli/guides.js +79 -31
  20. package/dist/src/cli/init.js +119 -58
  21. package/dist/src/cli/machine.js +12 -11
  22. package/dist/src/cli/main.js +3 -3
  23. package/dist/src/cli/mcp.js +29 -20
  24. package/dist/src/cli/metrics.js +79 -0
  25. package/dist/src/cli/pg-tls.js +13 -3
  26. package/dist/src/cli/pg-url.js +4 -0
  27. package/dist/src/cli/pin.js +13 -12
  28. package/dist/src/cli/preflight.js +8 -7
  29. package/dist/src/cli/project.js +43 -4
  30. package/dist/src/cli/readiness.js +38 -4
  31. package/dist/src/cli/registry.js +53 -34
  32. package/dist/src/cli/replication-checks.js +7 -5
  33. package/dist/src/cli/replication-mode.js +105 -26
  34. package/dist/src/cli/request-filter.js +134 -0
  35. package/dist/src/cli/roles.js +584 -0
  36. package/dist/src/cli/run-server.js +483 -41
  37. package/dist/src/cli/runs.js +1 -1
  38. package/dist/src/cli/security.js +10 -8
  39. package/dist/src/cli/serve.js +144 -42
  40. package/dist/src/cli/share-run.js +286 -0
  41. package/dist/src/cli/share.js +286 -0
  42. package/dist/src/cli/source-edit.js +65 -13
  43. package/dist/src/cli/sources.js +287 -38
  44. package/dist/src/cli/supervise.js +12 -1
  45. package/dist/src/cli/sync.js +55 -25
  46. package/dist/src/cli/tunnel.js +200 -0
  47. package/dist/src/cli/ui.js +9 -6
  48. package/dist/src/cli/usage.js +128 -0
  49. package/dist/src/cli/user.js +496 -40
  50. package/dist/src/cli/version.js +9 -7
  51. package/dist/src/demo/seed.js +3 -0
  52. package/dist/src/duck/cancel.js +10 -1
  53. package/dist/src/duck/events.js +134 -0
  54. package/dist/src/duck/executor-worker.js +18 -9
  55. package/dist/src/duck/executor.js +43 -30
  56. package/dist/src/duck/explain.js +56 -46
  57. package/dist/src/duck/extensions.js +160 -0
  58. package/dist/src/duck/functions.js +13 -14
  59. package/dist/src/duck/gaps.js +17 -8
  60. package/dist/src/duck/grants.js +176 -0
  61. package/dist/src/duck/history.js +123 -0
  62. package/dist/src/duck/kept.js +6 -6
  63. package/dist/src/duck/plan.js +102 -25
  64. package/dist/src/duck/publication.js +6 -6
  65. package/dist/src/duck/requests.js +265 -93
  66. package/dist/src/duck/rewrite.js +25 -5
  67. package/dist/src/duck/row-filter.js +649 -0
  68. package/dist/src/duck/store.js +156 -120
  69. package/dist/src/duck/stream.js +10 -5
  70. package/dist/src/duck/sync.js +835 -268
  71. package/dist/src/duck/types.js +6 -5
  72. package/dist/src/duck/usage.js +324 -0
  73. package/dist/src/duck/users.js +64 -15
  74. package/dist/src/duck/verified.js +3 -0
  75. package/dist/src/duck/views.js +2 -3
  76. package/dist/src/lib/database-name.js +62 -0
  77. package/dist/src/lib/dotenv.js +17 -4
  78. package/dist/src/lib/log-buffer.js +68 -0
  79. package/dist/src/lib/pg-numeric.js +5 -2
  80. package/dist/src/lib/project-version.js +23 -23
  81. package/dist/src/lib/table-sync.js +267 -0
  82. package/dist/src/web/app.css +706 -66
  83. package/dist/src/web/app.js +327 -410
  84. package/dist/src/web/charts.js +229 -0
  85. package/dist/src/web/index.html +82 -18
  86. package/dist/src/web/pages/queries.js +192 -0
  87. package/dist/src/web/pages/status.js +446 -0
  88. package/dist/src/web/pages/syncs.js +155 -0
  89. package/dist/src/web/pages/tables.js +434 -0
  90. package/dist/src/web/pages/usage.js +397 -0
  91. package/dist/src/web/pages/users.js +218 -0
  92. package/dist/src/web/shared.js +568 -0
  93. package/dist/src/web/vendor/README.md +25 -0
  94. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  95. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  96. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  97. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  98. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  99. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  100. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  101. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  102. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  103. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  104. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  106. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  107. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  108. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  109. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  110. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  111. 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
+ }
@@ -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 copy, off stores none (and drops those stored, at this start)");
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 copy");
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 copy of ${source}`;
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 copies serve as they were): ${e instanceof Error ? e.message : String(e)}`);
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 copy`
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 copy has a column named ${name} of its own` : null;
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);
@@ -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 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)";
110
111
  if (c === undefined)
111
- return `a table is in Mason once it is added: mason add ${relation} copies it (mason tables lists the database's)`;
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 copied: ${served}`;
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 copied (${how}): ${served}`;
127
+ return `${c.source} is added and being synced (${how}): ${served}`;
127
128
  }
128
- case "failed": return `${c.source} is added, but its copy failed: ${c.error ?? "no reason recorded"}; mason logs says more`;
129
- case "copied": return `${c.source} was just copied and joins the catalog as it is rebuilt: run the statement again in a moment (if it stays missing, mason logs says why)`;
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
- export async function planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, collation, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null, wireBound = false) {
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 new StatementError(typeof e.gap === "string"
222
- ? { code: "0A000", message: e.message, hint: e.hint, detail: `${gapMark(e.gap)} (docs/SQL-SUPPORT.md)` }
223
- : { code: e.gap.postgres, message: e.message, hint: e.hint }, text);
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 new StatementError({ code: "0A000",
241
- message: `Mason cannot yet answer this statement on DuckDB exactly as Postgres would: it uses ${listed.slice(0, 5).join("; ")}${listed.length > 5 ? ` and ${listed.length - 5} more` : ""}, which Mason has not verified`,
242
- hint: "write the statement without it (docs/SQL-SUPPORT.md lists what is verified), or report it: a statement Mason has not verified is refused rather than answered differently",
243
- detail: `${gapMark("unverified")}: ${unverified.join(", ")}` }, text);
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
- // DuckDB's COLUMNS(t.*) (decidedByKey's star) is an expression only unquoted; the deparser quotes the keyword. No
246
- // statement Postgres accepted calls a function "columns" (Postgres has none), and the read-back below checks the text
247
- const sql = deparseSync(tree, { pretty: false }).replaceAll('any_value("columns"(', "any_value(COLUMNS(");
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 on DuckDB exactly: its rewritten form does not read back as itself (a Mason gap: report it, with the statement)",
254
- detail: `read back: ${difference}; DuckDB SQL: ${sql}` }, text);
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) } : {}), 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 } };
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 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")