pgkiln 0.46.0 → 0.48.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 (50) hide show
  1. package/db/migrations/108_sql_history.sql +25 -0
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/docs/apex-feature-parity.md +28 -2
  7. package/docs/guide/03-builder.md +178 -21
  8. package/docs/guide/08-security.md +19 -1
  9. package/docs/guide/10-tutorial.md +1 -1
  10. package/docs/guide/12-development.md +46 -2
  11. package/docs/guide/13-rest-api.md +74 -9
  12. package/docs/guide/16-files.md +2 -2
  13. package/package.json +1 -1
  14. package/public/builder.css +22 -0
  15. package/public/data-modeler.css +56 -0
  16. package/public/data-modeler.js +367 -0
  17. package/public/palette.js +133 -0
  18. package/public/worksheet.css +293 -0
  19. package/public/worksheet.js +669 -0
  20. package/src/app.ts +4 -0
  21. package/src/builder/ai-builder.ts +3 -3
  22. package/src/builder/autorest.ts +362 -0
  23. package/src/builder/data-modeler.ts +382 -0
  24. package/src/builder/db-users.ts +472 -0
  25. package/src/builder/launchpad.ts +140 -0
  26. package/src/builder/object-actions.ts +167 -0
  27. package/src/builder/object-browser.ts +47 -26
  28. package/src/builder/querybuilder.ts +1 -1
  29. package/src/builder/sampledata.ts +2 -2
  30. package/src/builder/scripts.ts +3 -3
  31. package/src/builder/sql-charts.ts +203 -0
  32. package/src/builder/sql-tools.ts +49 -0
  33. package/src/builder/sql.ts +28 -81
  34. package/src/builder/table-editor.ts +219 -0
  35. package/src/builder/ui.ts +21 -15
  36. package/src/builder/unload.ts +8 -4
  37. package/src/builder/worksheet.ts +592 -0
  38. package/src/builder/workspaces.ts +5 -3
  39. package/src/cli/replace.ts +3 -1
  40. package/src/datamodel.ts +324 -0
  41. package/src/dbroles.ts +215 -0
  42. package/src/runtime/autorest.ts +336 -0
  43. package/src/runtime/rest.ts +4 -4
  44. package/src/session.ts +2 -0
  45. package/src/sqlformat.ts +208 -0
  46. package/src/sqlplan.ts +133 -0
  47. package/src/sqlscript.ts +6 -2
  48. package/src/sqlworksheet.ts +278 -0
  49. package/src/tabledef.ts +226 -0
  50. package/src/unload.ts +14 -3
@@ -28,12 +28,13 @@ export const DEFAULT_WORKSPACE = 1;
28
28
  /** The workspaces a developer may use (administrators: all), and the current one, on the session object. */
29
29
  export async function loadWorkspaces(s: Session) {
30
30
  const rows = (
31
- await owner.query<WorkspaceRef>(
31
+ await owner.query<WorkspaceRef & { instance_admin?: boolean }>(
32
32
  `select w.id, w.name, w.message, w.banner_label, w.banner_color, w.isolated, w.sql_workshop, w.rest_services,
33
33
  coalesce((select json_agg(json_build_object('label', l.label, 'url', l.url, 'description', l.description) order by l.position, lower(l.label))
34
34
  from meta.workspace_link l where l.workspace_id = w.id), '[]') as links,
35
35
  exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
36
- or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin
36
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin,
37
+ exists (select 1 from meta.developer d where d.username = $1 and d.is_admin) as instance_admin
37
38
  from meta.workspace w
38
39
  where exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
39
40
  or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1)
@@ -42,6 +43,7 @@ export async function loadWorkspaces(s: Session) {
42
43
  )
43
44
  ).rows;
44
45
  s.workspaces = rows;
46
+ s.instanceAdmin = !!rows[0]?.instance_admin;
45
47
  const wanted = Number(s.state.__WS);
46
48
  s.workspace = rows.find((w) => w.id === wanted) ?? rows[0] ?? null;
47
49
  }
@@ -213,7 +215,7 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
213
215
  const check = (name: string, on: boolean, label: string, help: string) =>
214
216
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
215
217
  return region('Workspace preferences', html`<form method="post" action="${BASE}/workspaces/${w.id}/preferences">${csrf(s)}
216
- ${check('sql_workshop', w.sql_workshop, 'Enable the SQL Workshop', 'Off: SQL Commands, the Object Browser, SQL scripts, data loading and unloading, Quick SQL, the query builder, DDL, Compare and AI are refused while this workspace is current, for every developer.')}
218
+ ${check('sql_workshop', w.sql_workshop, 'Enable the SQL Workshop', 'Off: the SQL Worksheet, the Object Browser, SQL scripts, data loading and unloading, Quick SQL, the query builder, DDL, Compare and AI are refused while this workspace is current, for every developer.')}
217
219
  ${check('rest_services', w.rest_services, 'Enable RESTful services', 'Off: the REST modules of this workspace\'s applications answer 404 (Not found) at /a/<alias>/rest/…; they can still be edited.')}
218
220
  <div class="buttons"><button class="btn">Save preferences</button></div></form>`, '', 'preferences');
219
221
  }
@@ -72,7 +72,9 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
72
72
  // (081) the mail service an application sends through, and its queued and sent mail
73
73
  'app_mail_service', 'mail_queue',
74
74
  // (086) users' preferences
75
- 'user_preference'];
75
+ 'user_preference',
76
+ // (113) AutoREST: the tables and views the application publishes (SQL Workshop › RESTful Services)
77
+ 'rest_object'];
76
78
  /** Children of pages (replaced with their page). */
77
79
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
78
80
  /** References into replaced tables from kept data, repointed below: "table.column". */
@@ -0,0 +1,324 @@
1
+ import { html, raw, type Raw } from './html.ts';
2
+
3
+ // (sprint 98, Database Actions DA-10: Data Modeler) A relational diagram of some tables and views of one schema:
4
+ // the catalog read for them (columns, keys, foreign keys, comments), each box's size, an automatic layout, the
5
+ // foreign key lines with crow's feet, and the whole diagram as one SVG. The same SVG is the page's diagram (the
6
+ // boxes at their saved positions, so it shows without JavaScript), the Export SVG file and the source of the
7
+ // PNG export. It uses SVG presentation attributes only (no style attributes, no <style>): the strict CSP allows
8
+ // them, the builder's stylesheet themes them on the page, and a downloaded file looks the same anywhere.
9
+ // public/data-modeler.js moves the boxes and draws the lines again with the same geometry (fkPath).
10
+
11
+ export interface ModelColumn {
12
+ name: string;
13
+ type: string;
14
+ notNull: boolean;
15
+ default: string | null;
16
+ comment: string | null;
17
+ }
18
+ export interface ModelConstraint {
19
+ name: string;
20
+ /** p primary key, u unique, f foreign key, c check */
21
+ type: 'p' | 'u' | 'f' | 'c';
22
+ cols: string[];
23
+ refSchema: string | null;
24
+ refTable: string | null;
25
+ refCols: string[];
26
+ def: string;
27
+ }
28
+ export interface ModelTable {
29
+ name: string;
30
+ kind: 'table' | 'view' | 'materialized view';
31
+ /** the regclass text (schema-qualified, quoted when needed) */
32
+ ref: string;
33
+ comment: string | null;
34
+ columns: ModelColumn[];
35
+ constraints: ModelConstraint[];
36
+ }
37
+ export interface Placed {
38
+ name: string;
39
+ x: number;
40
+ y: number;
41
+ }
42
+
43
+ type Q = { query: <T extends Record<string, any> = any>(sql: string, params?: unknown[]) => Promise<{ rows: T[] }> };
44
+
45
+ /** Names of the tables and views of a schema (not partitions), for the Add objects search. */
46
+ export async function modelNames(db: Q, schema: string): Promise<{ name: string; kind: ModelTable['kind'] }[]> {
47
+ return (await db.query<{ name: string; kind: ModelTable['kind'] }>(
48
+ `select c.relname as name, case when c.relkind in ('r', 'p') then 'table' when c.relkind = 'm' then 'materialized view' else 'view' end as kind
49
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
50
+ where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
51
+ order by c.relname limit 5000`, [schema])).rows;
52
+ }
53
+
54
+ /** The named tables and views of a schema as the diagram shows them (missing names are left out). */
55
+ export async function readModel(db: Q, schema: string, names: string[]): Promise<ModelTable[]> {
56
+ if (!names.length) return [];
57
+ return (await db.query<ModelTable>(
58
+ `select c.relname as name, case when c.relkind in ('r', 'p') then 'table' when c.relkind = 'm' then 'materialized view' else 'view' end as kind,
59
+ c.oid::regclass::text as ref, obj_description(c.oid, 'pg_class') as comment,
60
+ coalesce((select json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod), 'notNull', a.attnotnull,
61
+ 'default', case when a.attidentity = 'a' then 'generated always as identity' when a.attidentity = 'd' then 'generated by default as identity'
62
+ when a.attgenerated = 's' then 'generated always as (' || pg_get_expr(d.adbin, d.adrelid) || ') stored'
63
+ else pg_get_expr(d.adbin, d.adrelid) end,
64
+ 'comment', col_description(c.oid, a.attnum)) order by a.attnum)
65
+ from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
66
+ where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped), '[]') as columns,
67
+ coalesce((select json_agg(json_build_object('name', k.conname, 'type', k.contype,
68
+ 'cols', array(select a.attname from unnest(k.conkey) with ordinality u(n, i) join pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.n order by u.i),
69
+ 'refSchema', fn.nspname, 'refTable', f.relname,
70
+ 'refCols', coalesce(array(select a.attname from unnest(k.confkey) with ordinality u(n, i) join pg_attribute a on a.attrelid = k.confrelid and a.attnum = u.n order by u.i), '{}'),
71
+ 'def', pg_get_constraintdef(k.oid)) order by position(k.contype in 'pufc'), k.conname)
72
+ from pg_constraint k left join pg_class f on f.oid = k.confrelid left join pg_namespace fn on fn.oid = f.relnamespace
73
+ where k.conrelid = c.oid and k.contype in ('p', 'u', 'f', 'c')), '[]') as constraints
74
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
75
+ where n.nspname = $1 and c.relname = any ($2::text[]) and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
76
+ order by c.relname`, [schema, names])).rows;
77
+ }
78
+
79
+ /**
80
+ * The names a search picks: `*` all of them; a pattern with `*` (emp*, *_log) matches whole names; other words match
81
+ * a part of a name (any case). Several words or patterns, separated by spaces or commas, add up.
82
+ */
83
+ export function matchNames(all: string[], q: string, opts: { exact?: boolean } = {}): string[] {
84
+ const terms = q.split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).slice(0, 50);
85
+ if (!terms.length) return [];
86
+ const tests = terms.map((t) => {
87
+ if (t.includes('*')) {
88
+ const re = new RegExp(`^${t.split('*').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 'i');
89
+ return (n: string) => re.test(n);
90
+ }
91
+ const low = t.toLowerCase();
92
+ return opts.exact ? (n: string) => n === t || n.toLowerCase() === low : (n: string) => n.toLowerCase().includes(low);
93
+ });
94
+ return all.filter((n) => tests.some((f) => f(n)));
95
+ }
96
+
97
+ /** A diagram's saved objects, cleaned: names (unique) and positions within 0..20000. */
98
+ export function cleanObjects(v: unknown): Placed[] {
99
+ if (!Array.isArray(v)) return [];
100
+ const seen = new Set<string>();
101
+ const out: Placed[] = [];
102
+ for (const o of v.slice(0, 500)) {
103
+ if (!o || typeof o !== 'object') continue;
104
+ const name = (o as any).name;
105
+ if (typeof name !== 'string' || !name || name.length > 63 || seen.has(name)) continue;
106
+ seen.add(name);
107
+ const n = (x: unknown) => (Number.isFinite(Number(x)) ? Math.min(20000, Math.max(0, Math.round(Number(x)))) : 0);
108
+ out.push({ name, x: n((o as any).x), y: n((o as any).y) });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ // ---------------------------------------------------------------- geometry
114
+
115
+ export const HEAD = 30;
116
+ export const ROW = 20;
117
+ const FOOT = 8;
118
+ /** columns drawn per box; the others are counted in a last row */
119
+ export const MAX_ROWS = 40;
120
+ const CHAR = 7;
121
+ const TYPE_CHAR = 6.4;
122
+ const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
123
+
124
+ export interface Box {
125
+ name: string;
126
+ x: number;
127
+ y: number;
128
+ w: number;
129
+ h: number;
130
+ }
131
+
132
+ /** The rows a box shows: its columns (at most MAX_ROWS) and a "more" row. */
133
+ const shownColumns = (t: ModelTable) => t.columns.slice(0, t.columns.length > MAX_ROWS ? MAX_ROWS - 1 : MAX_ROWS);
134
+
135
+ export function boxSize(t: ModelTable): { w: number; h: number } {
136
+ const cols = shownColumns(t);
137
+ const rows = cols.length + (cols.length < t.columns.length ? 1 : 0);
138
+ const widest = Math.max(0, ...cols.map((c) => 30 + cut(c.name, 36).length * CHAR + 18 + cut(c.type, 24).length * TYPE_CHAR + 10));
139
+ const title = 24 + cut(t.name, 48).length * 7.6 + (t.kind === 'table' ? 0 : 50);
140
+ return { w: Math.round(Math.min(480, Math.max(160, widest, title))), h: HEAD + Math.max(1, rows) * ROW + 6 };
141
+ }
142
+
143
+ /** The y offset (in its box) of a column's row; the header for a column not drawn. */
144
+ export function rowOffset(t: ModelTable, column: string) {
145
+ const i = shownColumns(t).findIndex((c) => c.name === column);
146
+ return i < 0 ? HEAD / 2 : HEAD + i * ROW + ROW / 2;
147
+ }
148
+
149
+ /**
150
+ * A foreign key line from a child's row (a, ay) to its parent's row (b, by): an orthogonal path, the crow's foot
151
+ * (many) at the child and a bar (one) at the parent. The same function is in public/data-modeler.js.
152
+ */
153
+ export function fkPath(a: Box, ay: number, b: Box, by: number) {
154
+ const y1 = a.y + ay;
155
+ const y2 = b.y + by;
156
+ let x1: number, x2: number, d1: number, d2: number, path: string;
157
+ if (a.x + a.w + 40 <= b.x) {
158
+ [x1, x2, d1, d2] = [a.x + a.w, b.x, 1, -1];
159
+ const mx = Math.round((x1 + x2) / 2);
160
+ path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
161
+ } else if (b.x + b.w + 40 <= a.x) {
162
+ [x1, x2, d1, d2] = [a.x, b.x + b.w, -1, 1];
163
+ const mx = Math.round((x1 + x2) / 2);
164
+ path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
165
+ } else {
166
+ // one above the other (or the same table): around the right-hand sides
167
+ [x1, x2, d1, d2] = [a.x + a.w, b.x + b.w, 1, 1];
168
+ const lx = Math.max(x1, x2) + (a === b ? 18 : 30);
169
+ const yy = a === b && y1 === y2 ? y2 + 10 : y2;
170
+ path = `M ${x1} ${y1} H ${lx} V ${yy} H ${x2}`;
171
+ }
172
+ const foot = `M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 - 7} M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 + 7} M ${x1 + d1 * 14} ${y1 - 7} V ${y1 + 7}`;
173
+ const bar = `M ${x2 + d2 * 10} ${y2 - 7} V ${y2 + 7}`;
174
+ return { path, ends: `${foot} ${bar}`, circle: { cx: x2 + d2 * 20, cy: y2 } };
175
+ }
176
+
177
+ export interface Line {
178
+ from: string;
179
+ to: string;
180
+ name: string;
181
+ fy: number;
182
+ ty: number;
183
+ optional: boolean;
184
+ label: string;
185
+ }
186
+
187
+ /** The foreign key lines between the shown tables. */
188
+ export function lines(schema: string, tables: ModelTable[]): Line[] {
189
+ const by = new Map(tables.map((t) => [t.name, t]));
190
+ return tables.flatMap((t) =>
191
+ t.constraints.filter((k) => k.type === 'f' && k.refSchema === schema && k.refTable && by.has(k.refTable)).map((k) => ({
192
+ from: t.name,
193
+ to: k.refTable!,
194
+ name: k.name,
195
+ fy: rowOffset(t, k.cols[0]),
196
+ ty: rowOffset(by.get(k.refTable!)!, k.refCols[0]),
197
+ optional: k.cols.some((c) => !t.columns.find((x) => x.name === c)?.notNull),
198
+ label: `${t.name}(${k.cols.join(', ')}) → ${k.refTable}(${k.refCols.join(', ')}): ${k.name}`,
199
+ })),
200
+ );
201
+ }
202
+
203
+ /**
204
+ * Positions for all tables: parents left of their children (by foreign key levels), each level a column, a column
205
+ * wraps when it gets taller than the others need.
206
+ */
207
+ export function autoLayout(schema: string, tables: ModelTable[]): Placed[] {
208
+ const size = new Map(tables.map((t) => [t.name, boxSize(t)]));
209
+ const parents = new Map(tables.map((t) => [t.name, [...new Set(t.constraints.filter((k) => k.type === 'f' && k.refSchema === schema && k.refTable !== t.name && size.has(k.refTable!)).map((k) => k.refTable!))]]));
210
+ const level = new Map<string, number>();
211
+ const visiting = new Set<string>();
212
+ const levelOf = (n: string): number => {
213
+ if (level.has(n)) return level.get(n)!;
214
+ if (visiting.has(n)) return 0; // a cycle: this edge doesn't count
215
+ visiting.add(n);
216
+ const l = Math.max(-1, ...parents.get(n)!.map(levelOf)) + 1;
217
+ visiting.delete(n);
218
+ level.set(n, l);
219
+ return l;
220
+ };
221
+ for (const t of tables) levelOf(t.name);
222
+ const total = tables.reduce((s, t) => s + size.get(t.name)!.h + 40, 0);
223
+ const maxH = Math.max(900, Math.sqrt(total * 900), ...tables.map((t) => size.get(t.name)!.h));
224
+ const levels = [...new Set(level.values())].sort((a, b) => a - b);
225
+ const index = new Map<string, number>();
226
+ const out: Placed[] = [];
227
+ let x = 20;
228
+ for (const l of levels) {
229
+ const members = tables.filter((t) => level.get(t.name) === l).map((t) => t.name);
230
+ // near their parents: by the parents' mean position, then by name
231
+ const key = (n: string) => {
232
+ const p = parents.get(n)!.filter((x) => index.has(x));
233
+ return p.length ? p.reduce((s, x) => s + index.get(x)!, 0) / p.length : Number.MAX_SAFE_INTEGER;
234
+ };
235
+ members.sort((a, b) => key(a) - key(b) || a.localeCompare(b));
236
+ let y = 20;
237
+ let colW = 0;
238
+ for (const n of members) {
239
+ const s = size.get(n)!;
240
+ if (y > 20 && y + s.h > maxH) {
241
+ x += colW + 60;
242
+ y = 20;
243
+ colW = 0;
244
+ }
245
+ index.set(n, index.size);
246
+ out.push({ name: n, x, y });
247
+ y += s.h + 40;
248
+ colW = Math.max(colW, s.w);
249
+ }
250
+ x += colW + 100;
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /** Saved positions for the shown tables; tables without one get the automatic layout's (below the others when some are placed). */
256
+ export function placeAll(schema: string, tables: ModelTable[], saved: Placed[]): Placed[] {
257
+ const known = new Map(saved.map((p) => [p.name, p]));
258
+ const missing = tables.filter((t) => !known.has(t.name));
259
+ if (!missing.length) return tables.map((t) => known.get(t.name)!);
260
+ const placedTables = tables.filter((t) => known.has(t.name));
261
+ if (!placedTables.length) return autoLayout(schema, tables);
262
+ const bottom = Math.max(...placedTables.map((t) => known.get(t.name)!.y + boxSize(t).h)) + 60;
263
+ const extra = autoLayout(schema, missing).map((p) => ({ ...p, y: p.y + bottom - 20 }));
264
+ return tables.map((t) => known.get(t.name) ?? extra.find((p) => p.name === t.name)!);
265
+ }
266
+
267
+ // ---------------------------------------------------------------- SVG
268
+
269
+ export const COLORS = { bg: '#ffffff', frame: '#94a3b8', head: '#e8eefc', title: '#1e293b', text: '#334155', muted: '#64748b', key: '#b45309', line: '#475569', view: '#f1f5f9' };
270
+
271
+ /** Where a box's title links: Table Properties for a table, the Object Browser for a view. */
272
+ export const objectHref = (schema: string, t: ModelTable) =>
273
+ t.kind === 'table'
274
+ ? `/builder/sql/objects/table?${new URLSearchParams({ o: t.ref })}`
275
+ : `/builder/sql/objects?${new URLSearchParams({ schema, k: t.kind === 'view' ? 'view' : 'matview', o: t.ref })}`;
276
+
277
+ /** Key markers of a column: P primary key, F foreign key, U unique. */
278
+ export function markers(t: ModelTable, column: string) {
279
+ const has = (type: string) => t.constraints.some((k) => k.type === type && k.cols.includes(column));
280
+ return `${has('p') ? 'P' : ''}${has('f') ? 'F' : ''}${!has('p') && has('u') ? 'U' : ''}`;
281
+ }
282
+
283
+ /** The whole diagram. `title` names it; `links` makes box titles links (the page; not an exported file); `auto`: each box's automatic position (data-ax/ay, for the page's Auto layout). */
284
+ export function diagramSvg(schema: string, title: string, tables: ModelTable[], placed: Placed[], opts: { links?: boolean; id?: string; auto?: Placed[] } = {}): Raw {
285
+ const autoAt = new Map((opts.auto ?? []).map((p) => [p.name, p]));
286
+ const pos = new Map(placed.map((p) => [p.name, p]));
287
+ const boxes = new Map(tables.map((t) => [t.name, { ...pos.get(t.name)!, ...boxSize(t) } as Box]));
288
+ const all = [...boxes.values()];
289
+ const W = Math.max(400, ...all.map((b) => b.x + b.w + 60));
290
+ const H = Math.max(240, ...all.map((b) => b.y + b.h + 40));
291
+ const c = COLORS;
292
+ const fk = lines(schema, tables).map((l) => {
293
+ const p = fkPath(boxes.get(l.from)!, l.fy, boxes.get(l.to)!, l.ty);
294
+ return html`<g class="dm-fk${l.optional ? ' dm-optional' : ''}" data-from="${l.from}" data-to="${l.to}" data-fy="${l.fy}" data-ty="${l.ty}"><title>${l.label}</title>
295
+ <path class="dm-line" d="${p.path}" fill="none" stroke="${c.line}" stroke-width="1.5"${l.optional ? raw(' stroke-dasharray="6 4"') : ''}/>
296
+ <path class="dm-end" d="${p.ends}" fill="none" stroke="${c.line}" stroke-width="1.5"/>${l.optional ? html`<circle class="dm-opt" cx="${p.circle.cx}" cy="${p.circle.cy}" r="4" fill="${c.bg}" stroke="${c.line}" stroke-width="1.5"/>` : ''}</g>`;
297
+ });
298
+ const box = (t: ModelTable) => {
299
+ const b = boxes.get(t.name)!;
300
+ const cols = shownColumns(t);
301
+ const titleText = html`<text class="dm-title" x="10" y="20" font-weight="600" font-size="13" fill="${c.title}">${cut(t.name, 48)}</text>`;
302
+ return html`<g class="dm-box dm-${t.kind === 'table' ? 'table' : 'view'}" data-name="${t.name}" data-x="${b.x}" data-y="${b.y}" data-w="${b.w}" data-h="${b.h}"${autoAt.has(t.name) ? html` data-ax="${autoAt.get(t.name)!.x}" data-ay="${autoAt.get(t.name)!.y}"` : ''} transform="translate(${b.x} ${b.y})"${opts.links ? raw(' tabindex="0"') : ''} aria-label="${t.kind} ${t.name}">
303
+ <title>${t.kind} ${schema}.${t.name}${t.comment ? `: ${t.comment}` : ''}</title>
304
+ <rect class="dm-frame" width="${b.w}" height="${b.h}" rx="4" fill="${t.kind === 'table' ? c.bg : c.view}" stroke="${c.frame}"/>
305
+ <path class="dm-head" d="M 0.5 4.5 a 4 4 0 0 1 4 -4 H ${b.w - 4.5} a 4 4 0 0 1 4 4 V ${HEAD} H 0.5 Z" fill="${c.head}" stroke="${c.frame}"/>
306
+ ${opts.links ? html`<a class="dm-link" href="${objectHref(schema, t)}">${titleText}</a>` : titleText}
307
+ ${t.kind === 'table' ? '' : html`<text class="dm-kind" x="${b.w - 8}" y="20" text-anchor="end" font-size="10" fill="${c.muted}">${t.kind === 'view' ? 'view' : 'mat. view'}</text>`}
308
+ ${cols.map((col, i) => {
309
+ const y = HEAD + i * ROW + 14;
310
+ return html`<g class="dm-row"><title>${col.name} ${col.type}${col.notNull ? ' not null' : ''}${col.comment ? `: ${col.comment}` : ''}</title>
311
+ <text class="dm-key" x="6" y="${y}" font-size="10" font-weight="700" fill="${c.key}">${markers(t, col.name)}</text>
312
+ <text class="dm-col" x="26" y="${y}" font-size="12" fill="${c.text}"${col.notNull ? raw(' font-weight="600"') : ''}>${col.notNull ? '* ' : ''}${cut(col.name, 36)}</text>
313
+ <text class="dm-type" x="${b.w - 8}" y="${y}" text-anchor="end" font-size="11" fill="${c.muted}">${cut(col.type, 24)}</text></g>`;
314
+ })}
315
+ ${cols.length < t.columns.length ? html`<text class="dm-more" x="26" y="${HEAD + cols.length * ROW + 14}" font-size="11" font-style="italic" fill="${c.muted}">… ${t.columns.length - cols.length} more columns</text>` : ''}
316
+ ${!t.columns.length ? html`<text class="dm-more" x="26" y="${HEAD + 14}" font-size="11" font-style="italic" fill="${c.muted}">no columns</text>` : ''}
317
+ </g>`;
318
+ };
319
+ return html`<svg xmlns="http://www.w3.org/2000/svg" class="dm-svg"${opts.id ? raw(` id="${opts.id.replace(/[^a-z0-9-]/g, '')}"`) : ''} width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" role="group" aria-label="Diagram ${title}">
320
+ <title>${title}: ${tables.length} objects of schema ${schema}</title>
321
+ <rect class="dm-bg" width="100%" height="100%" fill="${c.bg}"/>
322
+ <g class="dm-content"><g class="dm-lines">${fk}</g><g class="dm-boxes">${tables.map(box)}</g></g>
323
+ </svg>`;
324
+ }
package/src/dbroles.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { createHash, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto';
2
+ import pg from 'pg';
3
+
4
+ // (sprint 99, Database Actions DA-13) Database Users: the SQL that creates, changes and drops a PostgreSQL role,
5
+ // from what the form says and what the catalog says now. Pure functions: src/builder/db-users.ts reads the
6
+ // catalog and runs the statements; test/db-users.test.ts tests them.
7
+ //
8
+ // Passwords: the server never sends a password to PostgreSQL. scramVerifier() hashes it here (SCRAM-SHA-256,
9
+ // like psql's \password and libpq's PQencryptPasswordConn), and the statement carries the verifier as a literal
10
+ // (pg's escapeLiteral). Statements are built with PASSWORD_SLOT in its place: maskSql() shows them with
11
+ // '********' (the review, the activity log), withVerifier() fills in the verifier only for running them.
12
+
13
+ const ident = pg.escapeIdentifier;
14
+ // pg's escapeLiteral starts a literal with a backslash as " E'…'": without the space
15
+ const literal = (s: string) => pg.escapeLiteral(s).trim();
16
+
17
+ /** Where the password goes in a generated statement (never shown: see maskSql / withVerifier). */
18
+ export const PASSWORD_SLOT = '\u0000password\u0000';
19
+ export const MASK = `'********'`;
20
+
21
+ export type DefaultPriv = 'none' | 'read' | 'write' | 'custom';
22
+ export type ExistingPriv = '' | 'read' | 'write' | 'revoke';
23
+
24
+ export interface SchemaGrant {
25
+ schema: string;
26
+ usage: boolean;
27
+ create: boolean;
28
+ /** privileges on tables (and sequences) the creator makes in the schema from now on */
29
+ defaults: DefaultPriv;
30
+ /** a one-off grant on the tables there now ('' = no change) */
31
+ existing?: ExistingPriv;
32
+ }
33
+
34
+ export interface RoleSpec {
35
+ name: string;
36
+ login: boolean;
37
+ /** keep the password, set a new one, or remove it (an existing role); none / set for a new one */
38
+ password: 'keep' | 'set' | 'remove';
39
+ /** YYYY-MM-DD, '' = no expiry */
40
+ validUntil: string;
41
+ /** -1 = no limit */
42
+ connLimit: number;
43
+ memberOf: string[];
44
+ /** the search path in this database, comma separated ('' = the server's default) */
45
+ searchPath: string;
46
+ comment: string;
47
+ schemas: SchemaGrant[];
48
+ }
49
+
50
+ export interface RoleContext {
51
+ /** the current database (ALTER ROLE … IN DATABASE) */
52
+ database: string;
53
+ /** the role whose new objects the default privileges cover (pgkiln's owner connection) */
54
+ creator: string;
55
+ }
56
+
57
+ export const emptyRole = (name = ''): RoleSpec => ({ name, login: false, password: 'keep', validUntil: '', connLimit: -1, memberOf: [], searchPath: '', comment: '', schemas: [] });
58
+
59
+ /** A name for a new role: PostgreSQL reserves pg_, pgkiln reserves pgkiln. */
60
+ export const ROLE_NAME = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
61
+ export const reservedName = (name: string) => /^pg_/i.test(name) || /^pgkiln/i.test(name);
62
+
63
+ /** The schemas of a search path ($user stays $user), or null when one isn't a plain name. */
64
+ export function parseSearchPath(text: string): string[] | null {
65
+ const parts = text.split(',').map((p) => p.trim()).filter(Boolean).map((p) => (/^".*"$/.test(p) ? p.slice(1, -1).replace(/""/g, '"') : p));
66
+ return parts.every((p) => p === '$user' || /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/.test(p)) ? parts : null;
67
+ }
68
+
69
+ const validDate = (d: string) => {
70
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) return false;
71
+ const t = new Date(`${d}T00:00:00Z`);
72
+ return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === d;
73
+ };
74
+
75
+ export interface CheckOptions {
76
+ isNew: boolean;
77
+ /** roles a role may be made a member of (existing memberships may stay) */
78
+ grantable: string[];
79
+ /** schemas whose privileges the form may change */
80
+ schemas: string[];
81
+ before: RoleSpec | null;
82
+ }
83
+
84
+ /** What is wrong with a spec (empty: fine). */
85
+ export function checkRole(spec: RoleSpec, o: CheckOptions): string[] {
86
+ const errors: string[] = [];
87
+ if (o.isNew) {
88
+ if (!ROLE_NAME.test(spec.name)) errors.push('The name starts with a letter or _ and has only letters, digits, _ and $ (at most 63).');
89
+ else if (reservedName(spec.name)) errors.push('Names starting with pg_ or pgkiln are reserved (PostgreSQL, pgkiln).');
90
+ }
91
+ if (spec.validUntil && !validDate(spec.validUntil)) errors.push('Valid until is a date (YYYY-MM-DD).');
92
+ if (!Number.isInteger(spec.connLimit) || spec.connLimit < -1 || spec.connLimit > 100000) errors.push('The connection limit is a whole number from -1 (no limit) to 100000.');
93
+ if (spec.searchPath && !parseSearchPath(spec.searchPath)) errors.push('The search path is a comma-separated list of schema names ($user for the role\'s own).');
94
+ if (spec.comment.length > 1000) errors.push('The comment has at most 1000 characters.');
95
+ const kept = new Set(o.before?.memberOf ?? []);
96
+ for (const m of spec.memberOf) {
97
+ if (m === spec.name) errors.push('A role is not a member of itself.');
98
+ else if (!kept.has(m) && !o.grantable.includes(m)) errors.push(`The role ${m} can't be granted here (pgkiln's own roles, superusers and login roles can't).`);
99
+ }
100
+ for (const s of spec.schemas) {
101
+ if (!o.schemas.includes(s.schema)) errors.push(`Privileges on the schema ${s.schema} aren't changed here.`);
102
+ if (!['none', 'read', 'write', 'custom'].includes(s.defaults)) errors.push(`Unknown default privileges for ${s.schema}.`);
103
+ if (s.existing && !['read', 'write', 'revoke'].includes(s.existing)) errors.push(`Unknown privileges on the tables of ${s.schema}.`);
104
+ }
105
+ if (o.isNew && spec.password === 'remove') errors.push('A new role has no password to remove.');
106
+ return errors;
107
+ }
108
+
109
+ /** What is wrong with a new password and its confirmation (empty: fine). */
110
+ export function checkPassword(password: string, confirm: string): string[] {
111
+ if (password.length < 8) return ['The password has at least 8 characters.'];
112
+ if (password.length > 1024) return ['The password has at most 1024 characters.'];
113
+ if (password.includes('\0')) return ['The password can\'t contain a NUL character.'];
114
+ if (password !== confirm) return ['The password and its confirmation differ.'];
115
+ return [];
116
+ }
117
+
118
+ const privs: Record<Exclude<DefaultPriv, 'custom' | 'none'>, string> = { read: 'SELECT', write: 'SELECT, INSERT, UPDATE, DELETE' };
119
+
120
+ /**
121
+ * The statements that make `before` (the catalog; null for a new role) into `after`, the password as
122
+ * PASSWORD_SLOT. Nothing that is already so is repeated.
123
+ */
124
+ export function roleSql(before: RoleSpec | null, after: RoleSpec, ctx: RoleContext): string[] {
125
+ const out: string[] = [];
126
+ const r = ident(after.name);
127
+ const b = before ?? emptyRole(after.name);
128
+ const opts: string[] = [];
129
+ if (!before || before.login !== after.login) opts.push(after.login ? 'LOGIN' : 'NOLOGIN');
130
+ if (after.password === 'set') opts.push(`PASSWORD ${PASSWORD_SLOT}`);
131
+ else if (after.password === 'remove' && before) opts.push('PASSWORD NULL');
132
+ if (b.validUntil !== after.validUntil) opts.push(`VALID UNTIL ${literal(after.validUntil || 'infinity')}`);
133
+ if (b.connLimit !== after.connLimit) opts.push(`CONNECTION LIMIT ${after.connLimit}`);
134
+ if (!before) out.push(`CREATE ROLE ${r}${opts.length ? ` ${opts.join(' ')}` : ''};`);
135
+ else if (opts.length) out.push(`ALTER ROLE ${r} ${opts.join(' ')};`);
136
+
137
+ for (const g of after.memberOf.filter((m) => !b.memberOf.includes(m))) out.push(`GRANT ${ident(g)} TO ${r};`);
138
+ for (const g of b.memberOf.filter((m) => !after.memberOf.includes(m))) out.push(`REVOKE ${ident(g)} FROM ${r};`);
139
+
140
+ const path = (t: string) => (parseSearchPath(t) ?? []).join(',');
141
+ if (path(b.searchPath) !== path(after.searchPath)) {
142
+ const p = parseSearchPath(after.searchPath) ?? [];
143
+ out.push(p.length ? `ALTER ROLE ${r} IN DATABASE ${ident(ctx.database)} SET search_path TO ${p.map(ident).join(', ')};`
144
+ : `ALTER ROLE ${r} IN DATABASE ${ident(ctx.database)} RESET search_path;`);
145
+ }
146
+
147
+ for (const s of after.schemas) {
148
+ const was = b.schemas.find((x) => x.schema === s.schema) ?? { schema: s.schema, usage: false, create: false, defaults: 'none' as DefaultPriv };
149
+ const n = ident(s.schema);
150
+ const grant = [s.usage && !was.usage ? 'USAGE' : '', s.create && !was.create ? 'CREATE' : ''].filter(Boolean);
151
+ const revoke = [!s.usage && was.usage ? 'USAGE' : '', !s.create && was.create ? 'CREATE' : ''].filter(Boolean);
152
+ if (grant.length) out.push(`GRANT ${grant.join(', ')} ON SCHEMA ${n} TO ${r};`);
153
+ if (revoke.length) out.push(`REVOKE ${revoke.join(', ')} ON SCHEMA ${n} FROM ${r};`);
154
+ if (s.defaults !== 'custom' && s.defaults !== was.defaults) {
155
+ const adp = `ALTER DEFAULT PRIVILEGES FOR ROLE ${ident(ctx.creator)} IN SCHEMA ${n}`;
156
+ if (was.defaults !== 'none') out.push(`${adp} REVOKE ALL ON TABLES FROM ${r};`, `${adp} REVOKE ALL ON SEQUENCES FROM ${r};`);
157
+ if (s.defaults !== 'none') out.push(`${adp} GRANT ${privs[s.defaults]} ON TABLES TO ${r};`);
158
+ if (s.defaults === 'write') out.push(`${adp} GRANT USAGE, SELECT ON SEQUENCES TO ${r};`);
159
+ }
160
+ if (s.existing === 'revoke') out.push(`REVOKE ALL ON ALL TABLES IN SCHEMA ${n} FROM ${r};`, `REVOKE ALL ON ALL SEQUENCES IN SCHEMA ${n} FROM ${r};`);
161
+ else if (s.existing === 'read' || s.existing === 'write') {
162
+ out.push(`GRANT ${privs[s.existing]} ON ALL TABLES IN SCHEMA ${n} TO ${r};`);
163
+ if (s.existing === 'write') out.push(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${n} TO ${r};`);
164
+ }
165
+ }
166
+
167
+ if (b.comment !== after.comment) out.push(`COMMENT ON ROLE ${r} IS ${after.comment ? literal(after.comment) : 'NULL'};`);
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * The statements that drop a role: its objects reassigned to another role (or, without one, dropped with it),
173
+ * its privileges and default privileges in this database revoked (DROP OWNED), then the role.
174
+ */
175
+ export function dropSql(name: string, reassignTo: string | null): string[] {
176
+ const r = ident(name);
177
+ return [...(reassignTo ? [`REASSIGN OWNED BY ${r} TO ${ident(reassignTo)};`] : []), `DROP OWNED BY ${r};`, `DROP ROLE ${r};`];
178
+ }
179
+
180
+ /** The statements as the review and the activity log show them: the password masked. */
181
+ export const maskSql = (stmts: string[]) => stmts.map((s) => s.split(PASSWORD_SLOT).join(MASK)).join('\n');
182
+
183
+ /** The statements to run: the password slot filled with the SCRAM verifier (never shown or logged). */
184
+ export const withVerifier = (stmts: string[], verifier: string | null) =>
185
+ stmts.map((s) => {
186
+ if (!s.includes(PASSWORD_SLOT)) return s;
187
+ if (!verifier) throw new Error('A password is needed.');
188
+ return s.split(PASSWORD_SLOT).join(literal(verifier));
189
+ });
190
+
191
+ /** A digest of the reviewed (masked) statements: Apply runs only what the review showed. */
192
+ export const reviewDigest = (stmts: string[]) => createHash('sha256').update(maskSql(stmts)).digest('base64url');
193
+
194
+ // ------------------------------------------------------------------ SCRAM-SHA-256
195
+
196
+ // RFC 4013 SASLprep, as PostgreSQL applies it (src/common/saslprep.c): non-ASCII spaces become spaces,
197
+ // characters "mapped to nothing" go, NFKC; a password it can't prepare (control characters) is used as it is.
198
+ const NON_ASCII_SPACE = /[   -    ]/g;
199
+ const TO_NOTHING = /[­͏᠆᠋-᠍​-‍⁠︀-️]/g;
200
+ export function saslprep(password: string): string {
201
+ // eslint-disable-next-line no-control-regex
202
+ if (/^[\x20-\x7E]*$/.test(password)) return password;
203
+ const prepared = password.replace(NON_ASCII_SPACE, ' ').replace(TO_NOTHING, '').normalize('NFKC');
204
+ // eslint-disable-next-line no-control-regex
205
+ return /[\u0000-\u001F\u007F-\u009F]/.test(prepared) || !prepared ? password : prepared;
206
+ }
207
+
208
+ /** A SCRAM-SHA-256 verifier for ALTER ROLE … PASSWORD (PostgreSQL stores a verifier as it is). */
209
+ export function scramVerifier(password: string, salt: Buffer = randomBytes(16), iterations = 4096): string {
210
+ const salted = pbkdf2Sync(Buffer.from(saslprep(password), 'utf8'), salt, iterations, 32, 'sha256');
211
+ const clientKey = createHmac('sha256', salted).update('Client Key').digest();
212
+ const storedKey = createHash('sha256').update(clientKey).digest();
213
+ const serverKey = createHmac('sha256', salted).update('Server Key').digest();
214
+ return `SCRAM-SHA-256$${iterations}:${salt.toString('base64')}$${storedKey.toString('base64')}:${serverKey.toString('base64')}`;
215
+ }