@mason-data/cli 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +147 -69
  2. package/dist/src/api/error.js +3 -0
  3. package/dist/src/api/postgres/catalog.js +258 -14
  4. package/dist/src/api/postgres/encode.js +3 -1
  5. package/dist/src/api/postgres/explain.js +3 -2
  6. package/dist/src/api/postgres/protocol.js +39 -23
  7. package/dist/src/api/postgres/server.js +1290 -223
  8. package/dist/src/api/postgres/translate.js +81 -9
  9. package/dist/src/api/request-log.js +18 -5
  10. package/dist/src/cdc/pg-wire.js +144 -38
  11. package/dist/src/cdc/poll.js +5 -5
  12. package/dist/src/cli/background.js +55 -8
  13. package/dist/src/cli/cancel.js +75 -0
  14. package/dist/src/cli/command.js +5 -3
  15. package/dist/src/cli/commands.js +843 -272
  16. package/dist/src/cli/control.js +59 -0
  17. package/dist/src/cli/demo.js +158 -0
  18. package/dist/src/cli/dev.js +458 -125
  19. package/dist/src/cli/doctor.js +25 -12
  20. package/dist/src/cli/guides.js +103 -31
  21. package/dist/src/cli/init.js +159 -58
  22. package/dist/src/cli/machine.js +18 -12
  23. package/dist/src/cli/main.js +5 -5
  24. package/dist/src/cli/mcp.js +735 -0
  25. package/dist/src/cli/metrics.js +79 -0
  26. package/dist/src/cli/pg-tls.js +13 -3
  27. package/dist/src/cli/pg-url.js +4 -0
  28. package/dist/src/cli/pin.js +13 -12
  29. package/dist/src/cli/preflight.js +24 -14
  30. package/dist/src/cli/project.js +48 -7
  31. package/dist/src/cli/readiness.js +38 -4
  32. package/dist/src/cli/registry.js +57 -35
  33. package/dist/src/cli/replication-checks.js +7 -5
  34. package/dist/src/cli/replication-mode.js +105 -26
  35. package/dist/src/cli/request-filter.js +134 -0
  36. package/dist/src/cli/roles.js +584 -0
  37. package/dist/src/cli/run-server.js +498 -133
  38. package/dist/src/cli/runs.js +1 -1
  39. package/dist/src/cli/security.js +10 -8
  40. package/dist/src/cli/serve.js +147 -40
  41. package/dist/src/cli/share-run.js +286 -0
  42. package/dist/src/cli/share.js +286 -0
  43. package/dist/src/cli/source-edit.js +168 -0
  44. package/dist/src/cli/sources.js +290 -39
  45. package/dist/src/cli/supervise.js +12 -1
  46. package/dist/src/cli/sync.js +55 -25
  47. package/dist/src/cli/tunnel.js +200 -0
  48. package/dist/src/cli/ui.js +9 -6
  49. package/dist/src/cli/usage.js +128 -0
  50. package/dist/src/cli/user.js +496 -40
  51. package/dist/src/cli/version.js +9 -7
  52. package/dist/src/demo/seed.js +240 -0
  53. package/dist/src/demo/source.js +655 -0
  54. package/dist/src/duck/cancel.js +10 -1
  55. package/dist/src/duck/distinct-key.js +228 -0
  56. package/dist/src/duck/events.js +134 -0
  57. package/dist/src/duck/executor-worker.js +25 -11
  58. package/dist/src/duck/executor.js +107 -53
  59. package/dist/src/duck/explain.js +158 -29
  60. package/dist/src/duck/extensions.js +160 -0
  61. package/dist/src/duck/functions.js +73 -19
  62. package/dist/src/duck/gaps.js +25 -11
  63. package/dist/src/duck/grants.js +176 -0
  64. package/dist/src/duck/history.js +123 -0
  65. package/dist/src/duck/jsonb-order.js +175 -0
  66. package/dist/src/duck/jsonb.js +10 -8
  67. package/dist/src/duck/kept-route.js +82 -2
  68. package/dist/src/duck/kept.js +211 -44
  69. package/dist/src/duck/pgtree.js +40 -0
  70. package/dist/src/duck/plan.js +120 -30
  71. package/dist/src/duck/publication.js +6 -6
  72. package/dist/src/duck/requests.js +265 -93
  73. package/dist/src/duck/rewrite.js +1193 -73
  74. package/dist/src/duck/row-filter.js +649 -0
  75. package/dist/src/duck/store.js +271 -159
  76. package/dist/src/duck/stream.js +10 -5
  77. package/dist/src/duck/sync.js +907 -275
  78. package/dist/src/duck/types.js +6 -5
  79. package/dist/src/duck/usage.js +324 -0
  80. package/dist/src/duck/users.js +64 -15
  81. package/dist/src/duck/verified.js +9 -0
  82. package/dist/src/duck/views.js +2 -3
  83. package/dist/src/lib/database-name.js +62 -0
  84. package/dist/src/lib/dotenv.js +17 -4
  85. package/dist/src/lib/log-buffer.js +68 -0
  86. package/dist/src/lib/pg-numeric.js +5 -2
  87. package/dist/src/lib/project-version.js +23 -23
  88. package/dist/src/lib/table-sync.js +267 -0
  89. package/dist/src/lib/timezone.js +3 -2
  90. package/dist/src/web/app.css +706 -55
  91. package/dist/src/web/app.js +330 -258
  92. package/dist/src/web/charts.js +229 -0
  93. package/dist/src/web/index.html +84 -15
  94. package/dist/src/web/pages/queries.js +192 -0
  95. package/dist/src/web/pages/status.js +446 -0
  96. package/dist/src/web/pages/syncs.js +155 -0
  97. package/dist/src/web/pages/tables.js +434 -0
  98. package/dist/src/web/pages/usage.js +397 -0
  99. package/dist/src/web/pages/users.js +218 -0
  100. package/dist/src/web/shared.js +568 -0
  101. package/dist/src/web/vendor/README.md +25 -0
  102. package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
  103. package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
  104. package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
  105. package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
  106. package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
  107. package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
  108. package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
  109. package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
  110. package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
  111. package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
  112. package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
  113. package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
  114. package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
  115. package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
  116. package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
  117. package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
  118. package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
  119. package/package.json +10 -3
@@ -0,0 +1,584 @@
1
+ /**
2
+ * THE PROJECT'S ROLES FILE (GitHub issue 30, stages 1a and 1b): `<folder>/roles.yml`, beside the sources file, the
3
+ * project's to commit and review as the sources file is — what each role may READ of what the sources file copies, and
4
+ * the role of a user added without one (`newRolesText` writes the first, `mason init`):
5
+ *
6
+ * default: everything the role of every user with no role of its own
7
+ * roles:
8
+ * everything:
9
+ * tables: all every synced table and column, those added later included
10
+ * agent:
11
+ * tables:
12
+ * orders: { columns: [id, status, amount] } those columns
13
+ * customers: { exclude: [email, phone] } every copied column but these
14
+ * regions: {} every column
15
+ * finance:
16
+ * tables:
17
+ * billing.invoices: {} a table named as the database names it
18
+ *
19
+ * The sources file's own words (`tables`, `columns`, `exclude`; a bare name is in public), so a software engineer who
20
+ * wrote the sources file reads this one. A role names copied tables only: the sources file is the outer boundary (what
21
+ * is copied at all), a role narrows within it. A `mason user` carries a role (`mason user add <name> --role <role>`,
22
+ * src/cli/user.ts). THE FILE DECIDES WHAT A USER WITH NO ROLE READS (stage 1b, never the code): no file, every copied
23
+ * table; `default: <role>`, that role; a file with no `default:`, nothing — every statement that reads a copied table
24
+ * is refused with the two ways out. A token (no user) reads every synced table. The listener enforces it on the analysed tree
25
+ * (src/duck/grants.ts) and the catalog Postgres mirrors it as real roles and GRANTs (src/api/postgres/catalog.ts), so
26
+ * `information_schema` and `mason mcp` list a role only what it may read.
27
+ *
28
+ * Read in two steps, as the sources file is (src/cli/sources.ts): `parseRolesText` (the file alone: its shape) and
29
+ * `resolveRoles` (each role's tables against the tables the run copies: an unknown table or column is refused by name,
30
+ * every mistake of the file on ONE report, `rolesFailure`). `LiveRoles` is the run's reader: the file read again when
31
+ * it changes, resolved against the tables copied now.
32
+ */
33
+ import * as fs from "node:fs";
34
+ import * as path from "node:path";
35
+ import { parseDocument } from "yaml";
36
+ import { closest, didYouMean } from "../lib/did-you-mean.js";
37
+ import { entryName, shownPath, sourceOf, tableNameProblem } from "./sources.js";
38
+ import { NO_ROLES } from "../duck/grants.js";
39
+ import { ATTRIBUTE_NAME_RE, ATTRIBUTE_NAME_RULE, missingAttributes, whereAttributes, whereParts } from "../duck/row-filter.js";
40
+ export { exampleRole, noRoleWayOut } from "../duck/grants.js";
41
+ export const ROLES_FILE = "roles.yml";
42
+ /** Where a project's roles file is: `<folder>/roles.yml`, beside `sources/`. */
43
+ export const rolesFile = (folder) => path.join(folder, ROLES_FILE);
44
+ /** A role's name: a Postgres role name the catalog can make (never one Postgres has: postgres, public, pg_*). */
45
+ export const ROLE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
46
+ export const ROLE_NAME_RULE = "1 to 63 lower case letters, digits, \"_\" or \"-\", starting with a letter or digit (e.g. agent), not postgres, public or pg_…";
47
+ const roleNameProblem = (name) => !ROLE_NAME_RE.test(name) ? `is not a role name: ${ROLE_NAME_RULE}` : name === "postgres" || name === "public" || name.startsWith("pg_") ? `is a name Postgres keeps for itself: choose another` : null;
48
+ const ROLE_KEYS = ["tables", "attributes"];
49
+ const TABLE_KEYS = ["columns", "exclude", "where", "through"];
50
+ const isPlain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
51
+ const yamlRead = (x) => `${x === null ? "null" : String(x)} is read by YAML as ${x === null ? "nothing (null)" : typeof x === "boolean" ? "a boolean" : typeof x === "number" ? "a number" : "a list or a map"}, not a name`;
52
+ /** A list of column names at `at`, or the problem with it. */
53
+ function nameList(v, at, what) {
54
+ if (Array.isArray(v) && v.length) {
55
+ const other = v.find((x) => typeof x !== "string");
56
+ if (other !== undefined)
57
+ return `${at}: ${yamlRead(other)} — quote it: "${String(other)}"`;
58
+ }
59
+ if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === "string" && x !== ""))
60
+ return `${at}: is ${what}, a list of column names: [id, status]`;
61
+ const twice = v.find((x, i) => v.indexOf(x) !== i);
62
+ if (twice !== undefined)
63
+ return `${at}: names ${String(twice)} twice`;
64
+ return v;
65
+ }
66
+ /** A role's `attributes:`, or the problem with it: `[tenant]` (every one a scalar) or `{ tenant: scalar, tenants: list }`. */
67
+ function attributesOf(v, at) {
68
+ if (v === undefined)
69
+ return undefined;
70
+ const shape = `is the attributes its users carry: [tenant] (each one value), or { tenant: scalar, tenants: list }`;
71
+ const out = new Map();
72
+ const entries = Array.isArray(v) ? v.map((x) => [x, "scalar"]) : isPlain(v) ? Object.entries(v) : [];
73
+ if (entries.length === 0)
74
+ return `${at}: ${shape}`;
75
+ for (const [name, kind] of entries) {
76
+ if (typeof name !== "string")
77
+ return `${at}: ${yamlRead(name)} — ${shape}`;
78
+ if (!ATTRIBUTE_NAME_RE.test(name))
79
+ return `${at}: ${name} is not an attribute's name: ${ATTRIBUTE_NAME_RULE}`;
80
+ if (out.has(name))
81
+ return `${at}: names ${name} twice`;
82
+ if (kind !== "scalar" && kind !== "list")
83
+ return `${at}.${name}: is scalar (one value: --set ${name}=42) or list (several: --set ${name}=12 --set ${name}=41)${typeof kind === "string" ? didYouMean(kind, ["scalar", "list"]) : ""}`;
84
+ out.set(name, kind);
85
+ }
86
+ return out;
87
+ }
88
+ /** A table's `where:` and `through:` (never both), or the problem with them: each `:name` an attribute of the role. */
89
+ function rowsOf(e, tat, attributes) {
90
+ if (e.where !== undefined && e.through !== undefined)
91
+ return `${tat}: where and through together — where: filters this table's rows by its own columns, through: by another table's; keep one`;
92
+ if (e.where !== undefined) {
93
+ if (typeof e.where !== "string" || e.where.trim() === "")
94
+ return `${tat}.where: is a Postgres condition on the table's columns, quoted: where: "tenant_id = :tenant"`;
95
+ const parts = whereParts(e.where);
96
+ if ("problem" in parts)
97
+ return `${tat}.where: ${parts.problem}`;
98
+ const declared = [...attributes.keys()];
99
+ for (const a of whereAttributes(parts)) {
100
+ if (!attributes.has(a))
101
+ return `${tat}.where: :${a} is not an attribute of the role${didYouMean(a, declared)} — declare it beside tables: attributes: [${[...declared, a].join(", ")}]`;
102
+ }
103
+ return { where: { text: e.where, parts } };
104
+ }
105
+ if (e.through !== undefined) {
106
+ const form = `is the table whose visible rows make this one's visible: through: orders, or through: { orders: order_id } naming the column that references it`;
107
+ let name, column = null;
108
+ if (typeof e.through === "string")
109
+ name = e.through;
110
+ else if (isPlain(e.through) && Object.keys(e.through).length === 1 && typeof Object.values(e.through)[0] === "string")
111
+ [name, column] = Object.entries(e.through)[0];
112
+ else
113
+ return `${tat}.through: ${form}`;
114
+ const bad = tableNameProblem(name);
115
+ if (bad !== null)
116
+ return `${tat}.through: ${bad}`;
117
+ return { through: { name, table: sourceOf(name), column } };
118
+ }
119
+ return {};
120
+ }
121
+ /** The file's text read as a roles file: its shape, never the copied tables (that is `resolveRoles`'s). */
122
+ export function parseRolesText(text, file) {
123
+ const doc = parseDocument(text, { uniqueKeys: true });
124
+ // the YAML library's first line ends "… at line 1, column 18:" before a snippet of the file: the snippet is dropped,
125
+ // and its colon with it, so the problem reads whole on one line (a tip, a log line)
126
+ if (doc.errors.length)
127
+ return { problems: [`it is not YAML: ${doc.errors[0].message.split("\n")[0].replace(/:\s*$/, "")}`] };
128
+ const v = doc.toJS();
129
+ if (!isPlain(v))
130
+ return { problems: [`it is ${v === null || v === undefined ? "empty" : "not a mapping"}: a roles file is roles: { <role>: { tables: { orders: {} } } }`] };
131
+ const problems = [];
132
+ for (const k of Object.keys(v))
133
+ if (k !== "roles" && k !== "default")
134
+ problems.push(`unexpected key ${k}${didYouMean(k, ["default", "roles"])} — a roles file takes default and roles`);
135
+ // THE DEFAULT: the role of every user added without --role, a role of this file (stage 1b: the file decides it)
136
+ let byDefault = null;
137
+ if (v.default !== undefined) {
138
+ const names = isPlain(v.roles) ? Object.keys(v.roles) : [];
139
+ if (typeof v.default !== "string" || v.default === "") {
140
+ const what = typeof v.default === "string" ? "" : typeof v.default === "object" && v.default !== null ? " (a list or a map is not a name)" : ` (${yamlRead(v.default)})`;
141
+ problems.push(`default: is a role's name, like default: everything${what}`);
142
+ }
143
+ else if (!names.includes(v.default))
144
+ problems.push(`default: there is no role ${v.default} in roles:${didYouMean(v.default, names)} — add it under roles:${names.length ? `, or name one of: ${names.join(", ")}` : ""}`);
145
+ else
146
+ byDefault = v.default;
147
+ }
148
+ const roles = [];
149
+ if (v.roles === undefined)
150
+ problems.push("roles: missing — write roles: {} and one entry per role: agent: { tables: { orders: {} } }");
151
+ else if (v.roles !== null && !isPlain(v.roles))
152
+ problems.push("roles: is one entry per role, like roles: { agent: { tables: { orders: {}, customers: { columns: [id, name] } } } }");
153
+ else {
154
+ for (const [name, raw] of Object.entries(v.roles ?? {})) {
155
+ const at = `roles.${name}`;
156
+ const bad = roleNameProblem(name);
157
+ if (bad !== null) {
158
+ problems.push(`${at}: ${name} ${bad}`);
159
+ continue;
160
+ }
161
+ if (!isPlain(raw)) {
162
+ problems.push(`${at}: is { tables: { … } }, the tables the role may read`);
163
+ continue;
164
+ }
165
+ const stray = Object.keys(raw).filter((k) => !ROLE_KEYS.includes(k));
166
+ for (const k of stray)
167
+ problems.push(`${at}: unexpected key ${k}${didYouMean(k, ROLE_KEYS)} — a role takes tables and attributes`);
168
+ // ATTRIBUTES: what each of its users carries (mason user add … --set tenant=42), read by its where:s as :tenant
169
+ const attrs = attributesOf(raw.attributes, `${at}.attributes`);
170
+ if (typeof attrs === "string")
171
+ problems.push(attrs);
172
+ const attributes = typeof attrs === "string" ? undefined : attrs;
173
+ const withAttrs = attributes === undefined || attributes.size === 0 ? {} : { attributes };
174
+ if (raw.tables === undefined) {
175
+ problems.push(`${at}.tables: missing — write tables: all (every synced table) or tables: { orders: {} }, the tables the role may read (a role with no table reads nothing: remove it instead)`);
176
+ continue;
177
+ }
178
+ const tablesShape = `${at}.tables: is all (every synced table and column) or one entry per table the role may read, like tables: { orders: {}, customers: { exclude: [email] } }`;
179
+ // `tables: all`: every synced table and every column, those the sources file adds later included (resolveRoles)
180
+ if (raw.tables === "all") {
181
+ if (stray.length === 0 && typeof attrs !== "string")
182
+ roles.push({ name, tables: "all", ...withAttrs });
183
+ continue;
184
+ }
185
+ if (typeof raw.tables === "string") {
186
+ problems.push(`${tablesShape} — ${raw.tables} is neither${didYouMean(raw.tables, ["all"])}`);
187
+ continue;
188
+ }
189
+ if (Array.isArray(raw.tables)) {
190
+ problems.push(`${tablesShape} — not a list: tables: all, or a key per table`);
191
+ continue;
192
+ }
193
+ if (!isPlain(raw.tables) || Object.keys(raw.tables).length === 0) {
194
+ problems.push(`${tablesShape} — a role with no table reads nothing: remove it instead`);
195
+ continue;
196
+ }
197
+ const tables = [];
198
+ const seen = new Map();
199
+ let ok = stray.length === 0 && typeof attrs !== "string";
200
+ for (const [key, entry] of Object.entries(raw.tables)) {
201
+ const tat = `${at}.tables.${key}`;
202
+ // `all` is the file's word for every table: a key of that name is a mistake either way, said with both meanings
203
+ if (key === "all") {
204
+ problems.push(`${tat}: all names every table — write tables: all for every synced table, or public.all for a table named all`);
205
+ ok = false;
206
+ continue;
207
+ }
208
+ const badName = tableNameProblem(key);
209
+ if (badName !== null) {
210
+ problems.push(`${tat}: ${badName}`);
211
+ ok = false;
212
+ continue;
213
+ }
214
+ const table = sourceOf(key);
215
+ const before = seen.get(table);
216
+ if (before !== undefined) {
217
+ problems.push(`${tat}: ${table} is named twice (as ${before} too) — keep one`);
218
+ ok = false;
219
+ continue;
220
+ }
221
+ seen.set(table, key);
222
+ if (entry !== null && !isPlain(entry)) {
223
+ problems.push(`${tat}: is {} (every column), { columns: [...] }, { exclude: [...] }, and where: or through: for the rows`);
224
+ ok = false;
225
+ continue;
226
+ }
227
+ const e = entry ?? {};
228
+ const odd = Object.keys(e).filter((k) => !TABLE_KEYS.includes(k));
229
+ for (const k of odd)
230
+ problems.push(`${tat}: unexpected key ${k}${didYouMean(k, TABLE_KEYS)} — a table takes columns or exclude, and where or through`);
231
+ const both = e.columns !== undefined && e.exclude !== undefined;
232
+ if (both)
233
+ problems.push(`${tat}: columns and exclude together — columns names what the role reads, exclude what it may not read of every column; keep one`);
234
+ const columns = e.columns === undefined ? null : nameList(e.columns, `${tat}.columns`, "the columns the role reads");
235
+ const exclude = e.exclude === undefined ? [] : nameList(e.exclude, `${tat}.exclude`, "the columns the role may not read");
236
+ for (const x of [columns, exclude])
237
+ if (typeof x === "string")
238
+ problems.push(x);
239
+ const rows = rowsOf(e, tat, attributes ?? new Map());
240
+ if (typeof rows === "string")
241
+ problems.push(rows);
242
+ if (typeof columns === "string" || typeof exclude === "string" || odd.length || both || typeof rows === "string") {
243
+ ok = false;
244
+ continue;
245
+ }
246
+ tables.push({ name: key, table, columns, exclude, ...rows });
247
+ }
248
+ // a through: names a table of this role that has a where: or a through: of its own, and never comes back round
249
+ for (const t of tables) {
250
+ if (t.through === undefined)
251
+ continue;
252
+ const tat = `${at}.tables.${t.name}.through`;
253
+ const parent = tables.find((x) => x.table === t.through.table);
254
+ if (parent === undefined) {
255
+ problems.push(`${tat}: ${t.through.name} is not a table of the role ${name}${didYouMean(t.through.name, tables.map((x) => x.name))} — add it with a where: (a row of ${t.name} is visible when the row of ${t.through.name} it references is)`);
256
+ ok = false;
257
+ continue;
258
+ }
259
+ if (parent.where === undefined && parent.through === undefined) {
260
+ problems.push(`${tat}: ${t.through.name} has no where: or through: of its own, so every row of it is visible — filter it (where: "…"), or remove the through: (every row of ${t.name} is visible then)`);
261
+ ok = false;
262
+ continue;
263
+ }
264
+ const seen = [t.table];
265
+ for (let p = parent; p?.through !== undefined; p = tables.find((x) => x.table === p.through.table)) {
266
+ if (seen.includes(p.table)) {
267
+ problems.push(`${tat}: comes back round (${[...seen, p.table].map((x) => tables.find((y) => y.table === x)?.name ?? x).join(" → ")}) — one of them needs a where: instead`);
268
+ ok = false;
269
+ break;
270
+ }
271
+ seen.push(p.table);
272
+ }
273
+ }
274
+ if (ok)
275
+ roles.push({ name, tables, ...withAttrs });
276
+ }
277
+ }
278
+ if (problems.length)
279
+ return { problems };
280
+ return { file, roles, default: byDefault };
281
+ }
282
+ /** A roles file's mistakes, all of them, as one failure (the sources file's `sourceFailure`, code bad_roles). */
283
+ export function rolesFailure(file, root, problems) {
284
+ const f = shownPath(root, file);
285
+ const one = problems.length === 1;
286
+ return {
287
+ message: one ? `${f}: ${problems[0]}` : `${f} has ${problems.length} mistakes`,
288
+ code: "bad_roles",
289
+ ...(one ? {} : { why: problems.map((p) => `- ${p}`).join("\n") }),
290
+ fix: `edit ${f}, then run the command again`,
291
+ };
292
+ }
293
+ /** The project's roles file, read: null when it has none (no roles: every user reads everything copied). */
294
+ export function readRoles(folder, root) {
295
+ const file = rolesFile(folder);
296
+ let text;
297
+ try {
298
+ text = fs.readFileSync(file, "utf8");
299
+ }
300
+ catch (e) {
301
+ if (e.code === "ENOENT")
302
+ return null;
303
+ return { message: `${shownPath(root, file)} cannot be read: ${e.message}`, code: "bad_roles", fix: "make it readable, or move it aside (no roles file: every user reads every synced table)" };
304
+ }
305
+ const parsed = parseRolesText(text, file);
306
+ return "problems" in parsed ? rolesFailure(file, root, parsed.problems) : parsed;
307
+ }
308
+ /**
309
+ * THE COLUMNS A `through:` COMPARES: the child's column and the parent's it references — by the foreign key from the
310
+ * child to the parent as the database's catalog reports it, or the column named (`through: { orders: order_id }`) and
311
+ * the foreign key on it, else the parent's one-column primary key. Null where the foreign keys are not known (a
312
+ * sources file read without the database: the run holds it). One problem, at its key, with the way out otherwise.
313
+ */
314
+ export function throughColumns(t, at, copied) {
315
+ const th = t.through;
316
+ const child = copied.find((x) => x.source === t.table), parent = copied.find((x) => x.source === th.table);
317
+ if (child === undefined || parent === undefined || child.foreignKeys === undefined)
318
+ return null;
319
+ const tat = `${at}.through`;
320
+ const fks = child.foreignKeys.filter((f) => f.target === th.table);
321
+ const explicit = `through: { ${th.name}: <column> }`;
322
+ let column, parentColumn;
323
+ if (th.column !== null) {
324
+ const have = child.columns?.map((c) => c.name);
325
+ if (have !== undefined && !have.includes(th.column))
326
+ return `${tat}.${th.name}: the synced ${t.table} has no column ${th.column}${didYouMean(th.column, have)} (its columns: ${have.join(", ")})`;
327
+ column = th.column;
328
+ const fk = fks.find((f) => f.column === th.column);
329
+ if (fk !== undefined)
330
+ parentColumn = fk.targetColumn;
331
+ else if (parent.key !== undefined && parent.key.length === 1)
332
+ parentColumn = parent.key[0];
333
+ else
334
+ return `${tat}.${th.name}: ${th.column} references ${th.table} by no foreign key, and ${th.table} has no one-column primary key to compare it with — a through: compares one column with the parent's key`;
335
+ }
336
+ else {
337
+ if (fks.length === 0)
338
+ return `${tat}: ${t.table} has no foreign key to ${th.table} (as the database's catalog reports them) — name the column that references it: ${explicit.replace("<column>", `<a column of ${t.name}>`)}`;
339
+ if (fks.length > 1)
340
+ return `${tat}: ${t.table} references ${th.table} by ${fks.length} foreign keys (${fks.map((f) => f.column).join(", ")}) — name the one: ${fks.map((f) => `through: { ${th.name}: ${f.column} }`).join(" or ")}`;
341
+ column = fks[0].column;
342
+ parentColumn = fks[0].targetColumn;
343
+ }
344
+ const childHave = child.columns?.map((c) => c.name), parentHave = parent.columns?.map((c) => c.name);
345
+ if (childHave !== undefined && !childHave.includes(column))
346
+ return `${tat}: the synced ${t.table} has no column ${column}, which references ${th.table} — sync it (the sources file's columns: for ${t.name})`;
347
+ if (parentHave !== undefined && !parentHave.includes(parentColumn))
348
+ return `${tat}: the synced ${th.table} has no column ${parentColumn}, which ${t.table}.${column} references — sync it (the sources file's columns: for ${th.name})`;
349
+ return { column, parentColumn };
350
+ }
351
+ /**
352
+ * Why a role's tables are not the copy's, each at its key: a table the sources file does not copy (refused by name:
353
+ * the sources file is the boundary a role narrows), a column the copy does not hold. None when every entry fits.
354
+ */
355
+ export function roleProblems(roles, copied) {
356
+ const problems = [];
357
+ const names = copied.map((t) => entryName(t.source));
358
+ for (const r of roles.roles) {
359
+ // `tables: all` names no table: it is every table copied now, so it cannot name one the sources file lacks
360
+ if (r.tables === "all")
361
+ continue;
362
+ for (const t of r.tables) {
363
+ const at = `roles.${r.name}.tables.${t.name}`;
364
+ const c = copied.find((x) => x.source === t.table);
365
+ if (c === undefined) {
366
+ problems.push(`${at}: ${t.table} is not a table the sources file syncs${nearestEntry(t.name, names)} — a role reads synced tables only (${names.length ? `synced: ${names.join(", ")}` : "none is synced yet"}; mason add ${t.name} syncs it)`);
367
+ continue;
368
+ }
369
+ if (t.through !== undefined) {
370
+ const th = throughColumns(t, at, copied);
371
+ if (typeof th === "string")
372
+ problems.push(th);
373
+ }
374
+ if (c.columns === null)
375
+ continue;
376
+ const have = c.columns.map((x) => x.name);
377
+ for (const [key, list] of [["columns", t.columns ?? []], ["exclude", t.exclude]]) {
378
+ for (const col of list.filter((x) => !have.includes(x)))
379
+ problems.push(`${at}.${key}: the synced ${t.table} has no column ${col}${didYouMean(col, have)} (its columns: ${have.join(", ")})`);
380
+ }
381
+ if (t.exclude.length && t.exclude.length >= have.length && have.every((h) => t.exclude.includes(h)))
382
+ problems.push(`${at}.exclude: leaves no column of ${t.table} to read — remove the table from the role instead`);
383
+ }
384
+ }
385
+ return problems;
386
+ }
387
+ /** The closest copied entry to `name`: by the whole name, else — for a bare name — by a qualified entry's own table
388
+ * part (`invoices` → `billing.invoices`, which the whole-name distance finds too far; a review nit of #35). */
389
+ function nearestEntry(name, names) {
390
+ const whole = didYouMean(name, names);
391
+ if (whole !== "" || name.includes("."))
392
+ return whole;
393
+ const byTail = new Map(names.filter((n) => n.includes(".")).map((n) => [n.slice(n.lastIndexOf(".") + 1), n]));
394
+ const hit = byTail.has(name) ? name : closest(name, byTail.keys());
395
+ return hit === null ? "" : ` — did you mean \`${byTail.get(hit)}\`?`;
396
+ }
397
+ /**
398
+ * Each role's grants, resolved against the tables the run copies (`exclude:` written out as the columns kept, in the
399
+ * copy's order), or the file's mistakes as one failure. `root`: how messages name the file.
400
+ */
401
+ export function resolveRoles(roles, copied, root) {
402
+ const problems = roleProblems(roles, copied);
403
+ if (problems.length)
404
+ return rolesFailure(roles.file, root, problems);
405
+ const file = shownPath(root, roles.file);
406
+ const out = new Map();
407
+ for (const r of roles.roles) {
408
+ const tables = new Map();
409
+ if (r.tables === "all") {
410
+ // every table copied NOW, every column: resolved again whenever the copied tables move (LiveRoles), so a table
411
+ // the sources file adds later is the role's at the next statement
412
+ for (const c of copied)
413
+ tables.set(c.source, { table: c.source, all: true, columns: c.columns.map((x) => x.name) });
414
+ out.set(r.name, { name: r.name, file, tables, all: true, ...(r.attributes !== undefined ? { attributes: r.attributes } : {}) });
415
+ continue;
416
+ }
417
+ const filters = new Map();
418
+ for (const t of r.tables) {
419
+ const have = copied.find((x) => x.source === t.table).columns.map((x) => x.name);
420
+ const all = t.columns === null && t.exclude.length === 0;
421
+ const columns = all ? have : t.columns !== null ? have.filter((c) => t.columns.includes(c)) : have.filter((c) => !t.exclude.includes(c));
422
+ tables.set(t.table, { table: t.table, all, columns });
423
+ const at = `roles.${r.name}.tables.${t.name}`;
424
+ if (t.where !== undefined)
425
+ filters.set(t.table, { table: t.table, at, where: t.where });
426
+ if (t.through !== undefined) {
427
+ // held by roleProblems above; foreign keys not known here are none, so an implicit through: is refused there
428
+ const th = throughColumns(t, at, copied.map((c) => ({ ...c, foreignKeys: c.foreignKeys ?? [] })));
429
+ if (th === null || typeof th === "string")
430
+ return rolesFailure(roles.file, root, [th ?? `${at}.through: the synced tables are not known`]);
431
+ filters.set(t.table, { table: t.table, at, through: { parent: t.through.table, ...th } });
432
+ }
433
+ }
434
+ out.set(r.name, { name: r.name, file, tables, all: false, ...(r.attributes !== undefined ? { attributes: r.attributes } : {}), ...(filters.size > 0 ? { filters } : {}) });
435
+ }
436
+ return out;
437
+ }
438
+ /**
439
+ * THE RUN'S ROLES, read again when the file changes (its inode, size or mtime: a stat per ask) or the copied tables do,
440
+ * and resolved against those tables; the same `RolesLive` object until one of them moves, so the listener knows when
441
+ * to rebuild the catalog. A file that cannot be read as it is now keeps the last good grants for the catalog and says
442
+ * the problem, which refuses every statement of a session with a role (src/duck/grants.ts RolesLive): fail closed.
443
+ */
444
+ export class LiveRoles {
445
+ folder;
446
+ root;
447
+ tables;
448
+ seen = null;
449
+ seenTables = null;
450
+ live = NO_ROLES;
451
+ lastGood = new Map();
452
+ lastGoodDefault = null;
453
+ constructor(folder, root, tables) {
454
+ this.folder = folder;
455
+ this.root = root;
456
+ this.tables = tables;
457
+ }
458
+ /** the roles now (one object until the file or the tables change) */
459
+ now() {
460
+ const file = rolesFile(this.folder);
461
+ let stamp;
462
+ try {
463
+ const s = fs.statSync(file);
464
+ stamp = `${s.ino}:${s.size}:${s.mtimeMs}`;
465
+ }
466
+ catch (e) {
467
+ stamp = e.code === "ENOENT" ? "none" : `error:${e.message}`;
468
+ }
469
+ const tables = this.tables();
470
+ // the tables by CONTENT, never by the array's identity: a caller that maps a fresh array per ask (a test did) would
471
+ // otherwise make a new RolesLive at every statement, and the listener rebuilds the catalog at each new one
472
+ const tablesKey = tables.map((t) => `${t.source}(${t.columns.map((c) => c.name).join(",")})`).join(";");
473
+ if (stamp === this.seen && tablesKey === this.seenTables)
474
+ return this.live;
475
+ this.seen = stamp;
476
+ this.seenTables = tablesKey;
477
+ const read = readRoles(this.folder, this.root);
478
+ if (read === null) {
479
+ this.lastGood = new Map();
480
+ this.lastGoodDefault = null;
481
+ this.live = NO_ROLES;
482
+ return this.live;
483
+ }
484
+ const shownFile = shownPath(this.root, file);
485
+ if ("message" in read) {
486
+ this.live = { file: shownFile, grants: this.lastGood, problem: problemText(read), default: this.lastGoodDefault };
487
+ return this.live;
488
+ }
489
+ const resolved = resolveRoles(read, tables, this.root);
490
+ if ("message" in resolved) {
491
+ this.live = { file: shownFile, grants: this.lastGood, problem: problemText(resolved), default: this.lastGoodDefault };
492
+ return this.live;
493
+ }
494
+ this.lastGood = resolved;
495
+ this.lastGoodDefault = read.default;
496
+ this.live = { file: shownFile, grants: resolved, problem: null, default: read.default };
497
+ return this.live;
498
+ }
499
+ }
500
+ /** what `mason user list` says (and the web page, held equal) while the roles file cannot be read as it is now */
501
+ export const rolesProblemText = (file, problem) => `${file} cannot be read as it is now, so every statement of a user held to it is refused: ${problem}`;
502
+ /** a failure's text on one line, for a log line or a refusal */
503
+ export const problemText = (f) => `${f.message}${f.why ? `: ${f.why.replace(/\n/g, "; ")}` : ""}`;
504
+ /**
505
+ * The users whose role the file does not name — said at the start, and by `mason user list`: their statements are
506
+ * refused by name until the role is added or the user made again (never silently widened).
507
+ */
508
+ export function usersWithoutRole(users, roles) {
509
+ return users.flatMap((u) => u.role !== undefined && !roles.has(u.role) ? [{ name: u.name, role: u.role }] : []);
510
+ }
511
+ /**
512
+ * THE USERS WITHOUT A VALUE THEIR ROLE'S ROW FILTERS READ (a user added before the file gained the attribute) — said at
513
+ * the start and by `mason user list`, as the listener refuses their every statement (src/api/postgres/server.ts
514
+ * withRows): each with its role, the attributes it lacks, and the commands that make it again with them.
515
+ */
516
+ export function usersMissingAttributes(users, live) {
517
+ if (live.problem !== null)
518
+ return [];
519
+ return users.flatMap((u) => {
520
+ const role = userRole(u, live).role;
521
+ const g = role === undefined ? undefined : live.grants.get(role);
522
+ if (g?.filters === undefined)
523
+ return [];
524
+ const missing = missingAttributes(g.filters.values(), (u.attributes ?? {})).map((m) => m.attr);
525
+ return missing.length === 0 ? [] : [{ name: u.name, role: g.name, missing }];
526
+ });
527
+ }
528
+ /** a user without a value its role's filters read, as `mason start` and `mason user list` say it: ONE wording */
529
+ export const missingAttributesText = (u) => `the user ${u.name} has no value for ${u.missing.join(", ")}, which the role ${u.role} filters rows by: its statements are refused — ` +
530
+ `mason user remove ${u.name} && mason user add ${u.name} --role ${u.role} ${u.missing.map((a) => `--set ${a}=<value>`).join(" ")}`;
531
+ /**
532
+ * THE ROLE A USER IS HELD TO, as the listener decides it (src/api/postgres/server.ts Session.grants): its own, else the
533
+ * file's default; `readsNothing` where the file names no default for a user with none. One function for `mason user
534
+ * list`, `mason user add`, `mason start` and the web page, so they say what the listener does. A file that cannot be
535
+ * read as it is now (`problem`) says nothing of a default — the listener refuses such a user 57P03 for the FILE, whose
536
+ * way out is to repair it, never to write `default:` — so a roleless user is then neither defaulted nor "reads nothing".
537
+ */
538
+ export function userRole(u, live) {
539
+ if (u.role !== undefined)
540
+ return { role: u.role, roleFrom: "file", readsNothing: false };
541
+ if (live.file === null || live.problem !== null)
542
+ return { roleFrom: "none", readsNothing: false };
543
+ if (live.default !== null)
544
+ return { role: live.default, roleFrom: "default", readsNothing: false };
545
+ return { roleFrom: "none", readsNothing: true };
546
+ }
547
+ /** The users with no role under a roles file that names no default — said at the start and by `mason user list`:
548
+ * their statements are refused until the file has a `default:` or the user is made again with a role. */
549
+ export function usersWithNoRole(users, live) {
550
+ return users.filter((u) => userRole(u, live).readsNothing).map((u) => u.name);
551
+ }
552
+ /**
553
+ * THE ROLES LINE `mason start` SAYS ONCE, after the listener's (as `syncDefaultLine` says the sources file's sync): what a
554
+ * user added without --role reads, in the file's three states. `shown`: the roles file as messages name it.
555
+ */
556
+ export function rolesDefaultLine(live, shown) {
557
+ if (live.file === null)
558
+ return `roles: no ${shown}: every user reads every synced table; mason init writes one`;
559
+ if (live.problem !== null)
560
+ return `roles: ${live.file} cannot be read as it is now, so every user's statements are refused until it is fixed: ${live.problem}`;
561
+ const names = [...live.grants.keys()];
562
+ const head = `${names.length} role${names.length === 1 ? "" : "s"} from ${live.file}${names.length ? ` (${names.join(", ")})` : ""}`;
563
+ return live.default !== null ? `${head}; default: ${live.default} (a user added without --role)` : `${head}; no default: a user with no role reads nothing`;
564
+ }
565
+ /**
566
+ * THE ROLES FILE `mason init` WRITES (the one writer of its first text, as `newSourceText` is the sources file's): one
567
+ * role reading every synced table, the default — today's access, said in a committed file — and a narrower role as a
568
+ * comment, the shape to copy.
569
+ */
570
+ export function newRolesText() {
571
+ return `# Who may read what of the tables the sources file syncs. Yours to commit, beside sources/.
572
+ default: everything # the role of a user added without --role (mason user add <name>)
573
+
574
+ roles:
575
+ everything:
576
+ tables: all # every synced table and column, tables added to the sources file later included
577
+ # A narrower role: mason user add agent --role agent. A bare name is in public, else schema.table;
578
+ # {} every column, columns: [id, status] those, exclude: [email] every column but those.
579
+ # agent:
580
+ # tables:
581
+ # orders: { columns: [id, status, amount] }
582
+ # customers: { exclude: [email] }
583
+ `;
584
+ }