pgkiln 0.47.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.
@@ -0,0 +1,382 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { ddlScript, readSchema, type DdlObject } from '../ddl.ts';
4
+ import { esc, html, raw, type Raw } from '../html.ts';
5
+ import { icon } from '../icons.ts';
6
+ import { autoLayout, cleanObjects, diagramSvg, lines, markers, matchNames, modelNames, objectHref, placeAll, readModel, type ModelTable, type Placed } from '../datamodel.ts';
7
+ import type { Session } from '../session.ts';
8
+ import { back, BASE, bicon, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
+ import { currentWorkspace, sessionSchema, usableSchemas, workshopDb } from './workspaces.ts';
10
+
11
+ // (sprint 98, Database Actions DA-10: Data Modeler) SQL Workshop → Data Modeler: named relational diagrams of a
12
+ // schema's tables and views, per workspace (meta.sql_diagram, migration 111). A diagram keeps only the objects'
13
+ // names and their boxes' positions; everything drawn is read from the catalog when it is shown (src/datamodel.ts),
14
+ // with the workshop's connection (an isolated workspace's developer login sees only its own schemas). The page
15
+ // shows the diagram as server-rendered SVG at the saved positions (it works without JavaScript: add and remove
16
+ // objects, auto layout, export SVG, DDL and the data dictionary are forms and links); public/data-modeler.js adds
17
+ // dragging, undo/redo, zoom, fit, the mini-map, Export PNG and print, and saves the positions.
18
+
19
+ const M = `${BASE}/sql/modeler`;
20
+
21
+ interface Diagram {
22
+ id: string;
23
+ name: string;
24
+ schema_name: string;
25
+ objects: unknown;
26
+ created_by: string;
27
+ updated_by: string | null;
28
+ updated_at: string;
29
+ }
30
+
31
+ const ID_RE = /^\d{1,18}$/;
32
+
33
+ async function loadDiagram(s: Session, id: unknown): Promise<Diagram | null> {
34
+ if (typeof id !== 'string' || !ID_RE.test(id)) return null;
35
+ return (await owner.one<Diagram>(`select id::text, name, schema_name, objects, created_by, updated_by, updated_at from meta.sql_diagram where id = $1 and workspace_id = $2`,
36
+ [id, currentWorkspace(s)])) ?? null;
37
+ }
38
+
39
+ /** The diagram's tables as they are now, placed (its schema must still be usable by the workshop's connection). */
40
+ async function model(s: Session, d: Diagram) {
41
+ const db = await workshopDb(s);
42
+ if (!(await usableSchemas(db)).includes(d.schema_name)) return null;
43
+ const saved = cleanObjects(d.objects);
44
+ const tables = await readModel(db, d.schema_name, saved.map((o) => o.name));
45
+ // in the saved order (readModel sorts by name)
46
+ tables.sort((a, b) => saved.findIndex((o) => o.name === a.name) - saved.findIndex((o) => o.name === b.name));
47
+ return { db, tables, placed: placeAll(d.schema_name, tables, saved), missing: saved.filter((o) => !tables.some((t) => t.name === o.name)).map((o) => o.name) };
48
+ }
49
+
50
+ async function saveObjects(s: Session, id: string, objects: Placed[]) {
51
+ await owner.query(`update meta.sql_diagram set objects = $3, updated_by = $4, updated_at = now() where id = $1 and workspace_id = $2`,
52
+ [id, currentWorkspace(s), JSON.stringify(objects.slice(0, 500)), s.username]);
53
+ }
54
+
55
+ const notFound = (reply: FastifyReply) => reply.code(404).send('Not found');
56
+ const fileName = (name: string) => name.replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 80) || 'diagram';
57
+ const when = (t: string) => new Date(t).toISOString().slice(0, 16).replace('T', ' ');
58
+
59
+ /** The Diagrams list and the Create diagram form. */
60
+ async function listPage(req: Req, reply: FastifyReply) {
61
+ const s = await developer(req, reply);
62
+ if (!s) return;
63
+ const db = await workshopDb(s);
64
+ const [schemas, current, rows] = await Promise.all([
65
+ usableSchemas(db),
66
+ sessionSchema(s, db),
67
+ owner.query<Diagram & { n: number }>(
68
+ `select id::text, name, schema_name, jsonb_array_length(objects) as n, created_by, updated_by, updated_at
69
+ from meta.sql_diagram where workspace_id = $1 order by lower(name)`, [currentWorkspace(s)]),
70
+ ]);
71
+ const pick = current ?? (schemas.includes('public') ? 'public' : schemas[0]);
72
+ const list = rows.rows.length
73
+ ? html`<div class="table-wrap"><table class="report report-reflow" aria-label="Diagrams"><thead><tr><th>Name</th><th>Schema</th><th class="num">Objects</th><th>Updated</th><th>By</th><th><span class="sr-only">Actions</span></th></tr></thead>
74
+ <tbody>${rows.rows.map((d) => html`<tr>
75
+ <td data-label="Name" class="u-break"><a href="${M}/${d.id}">${d.name}</a></td>
76
+ <td data-label="Schema" class="u-break">${d.schema_name}</td>
77
+ <td data-label="Objects" class="num">${d.n}</td>
78
+ <td data-label="Updated">${when(d.updated_at)}</td>
79
+ <td data-label="By" class="u-break">${d.updated_by ?? d.created_by}</td>
80
+ <td data-label="Actions"><form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the diagram ${d.name}? The tables stay as they are.">${icon('trash')} Delete</button></form></td></tr>`)}</tbody></table></div>`
81
+ : html`<p class="muted">No diagrams in this workspace yet.</p>`;
82
+ const create = html`<form method="post" action="${M}" class="dm-create">${csrf(s)}
83
+ <div class="form-grid">
84
+ <div class="field"><label class="label" for="f_name">Name</label><input id="f_name" name="name" required maxlength="120" placeholder="core tables"></div>
85
+ <div class="field"><label class="label" for="f_schema">Schema</label><select id="f_schema" name="schema">${schemas.map((n) => html`<option${n === pick ? raw(' selected') : ''}>${n}</option>`)}</select></div>
86
+ <div class="field"><label class="label" for="f_objects">Objects</label><input id="f_objects" name="objects" maxlength="2000" placeholder="orders, customers or *">
87
+ <small class="help">Names separated by commas or spaces; <code>*</code> for all, <code>ord*</code> for those starting with ord. You can add more later.</small></div>
88
+ </div>
89
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} Create diagram</button></div></form>`;
90
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
91
+ <p class="muted u-mt0">Relational diagrams of a schema's tables and views: their columns, keys and the foreign keys between them. From a diagram: its DDL and a data dictionary.</p>
92
+ ${region('Diagrams', list)}<div class="u-spacer"></div>${region('Create diagram', create)}`;
93
+ return send(reply, s, shell(s, 'Data Modeler', [['SQL Workshop', `${BASE}/sql`], ['Data Modeler']], main, 'sql'));
94
+ }
95
+
96
+ /** One diagram: the toolbar, the SVG, its objects, Add objects. */
97
+ async function diagramPage(req: Req, reply: FastifyReply) {
98
+ const s = await developer(req, reply);
99
+ if (!s) return;
100
+ const d = await loadDiagram(s, req.params.id);
101
+ if (!d) return notFound(reply);
102
+ const m = await model(s, d);
103
+ const crumbs: [string, string?][] = [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name]];
104
+ if (!m) {
105
+ const main = html`<h1 class="u-mb1">${d.name}</h1>${workshopTabs('modeler')}<div class="alert alert-error" role="alert">The schema ${d.schema_name} doesn't exist any more, or this workspace can't use it.</div>
106
+ <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the diagram ${d.name}?">${icon('trash')} Delete the diagram</button></form>`;
107
+ return send(reply, s, shell(s, d.name, crumbs, main, 'sql'));
108
+ }
109
+ const { tables, placed, missing } = m;
110
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : '';
111
+ const shown = new Set(tables.map((t) => t.name));
112
+ const all = q ? await modelNames(m.db, d.schema_name) : [];
113
+ const found = q ? matchNames(all.filter((o) => !shown.has(o.name)).map((o) => o.name), q).slice(0, 500) : [];
114
+ const kindOf = new Map(all.map((o) => [o.name, o.kind]));
115
+ const svg = diagramSvg(d.schema_name, d.name, tables, placed, { links: true, id: 'dm-svg', auto: autoLayout(d.schema_name, tables) });
116
+ const fks = lines(d.schema_name, tables);
117
+ const jsBtn = (act: string, ic: Raw | '', label: string, extra = '') =>
118
+ html`<button type="button" class="btn btn-sm" data-dm="${act}" hidden title="${label}${extra ? ` (${extra})` : ''}" aria-label="${label}">${ic}<span class="dm-btn-label">${label}</span></button>`;
119
+ const toolbar = html`<div class="dm-toolbar" role="toolbar" aria-label="Diagram">
120
+ <form method="post" action="${M}/${d.id}/layout" class="dm-save-form">${csrf(s)}<input type="hidden" name="positions" value="">
121
+ ${jsBtn('save', icon('save'), 'Save', 'Ctrl+S')}<span class="dm-status muted small" role="status" aria-live="polite"></span></form>
122
+ ${jsBtn('undo', icon('undo'), 'Undo', 'Ctrl+Z')}${jsBtn('redo', icon('redo'), 'Redo', 'Ctrl+Y')}
123
+ ${jsBtn('zoom-out', bicon('zoom-out'), 'Zoom out')}${jsBtn('zoom-in', bicon('zoom-in'), 'Zoom in')}${jsBtn('fit', bicon('expand'), 'Fit')}
124
+ <form method="post" action="${M}/${d.id}/layout">${csrf(s)}<input type="hidden" name="layout" value="auto"><button class="btn btn-sm" data-dm="auto" title="Auto layout" aria-label="Auto layout">${icon('grid')}<span class="dm-btn-label">Auto layout</span></button></form>
125
+ ${jsBtn('minimap', icon('map'), 'Mini-map')}
126
+ <a class="btn btn-sm" href="${M}/${d.id}/svg" data-dm="svg" title="Export SVG" aria-label="Export SVG">${icon('download')}<span class="dm-btn-label">SVG</span></a>
127
+ ${jsBtn('png', icon('image'), 'PNG')}${jsBtn('print', icon('printer'), 'Print')}
128
+ <a class="btn btn-sm" href="${M}/${d.id}/ddl">${icon('code')}<span class="dm-btn-label">Generate DDL</span></a>
129
+ <a class="btn btn-sm" href="${M}/${d.id}/dictionary">${icon('book')}<span class="dm-btn-label">Data dictionary</span></a>
130
+ <a class="btn btn-sm btn-hot" href="${M}/${d.id}#dm-add">${icon('plus')}<span class="dm-btn-label">Add objects</span></a>
131
+ </div>`;
132
+ const objectList = tables.length
133
+ ? html`<div class="table-wrap"><table class="report report-reflow" aria-label="Objects in this diagram"><thead><tr><th>Object</th><th>Kind</th><th class="num">Columns</th><th>References</th><th><span class="sr-only">Actions</span></th></tr></thead>
134
+ <tbody>${tables.map((t) => html`<tr>
135
+ <td data-label="Object" class="u-break"><a href="${objectHref(d.schema_name, t)}" title="${t.kind === 'table' ? 'Table Properties' : 'Object Browser'}">${t.name}</a>${t.comment ? html`<br><span class="muted small">${t.comment}</span>` : ''}</td>
136
+ <td data-label="Kind">${t.kind}</td>
137
+ <td data-label="Columns" class="num">${t.columns.length}</td>
138
+ <td data-label="References" class="u-break">${fks.filter((l) => l.from === t.name).map((l) => l.to).join(', ') || '-'}</td>
139
+ <td data-label="Actions"><form method="post" action="${M}/${d.id}/objects">${csrf(s)}<input type="hidden" name="remove" value="${t.name}"><button class="btn btn-sm" aria-label="Remove ${t.name} from the diagram">${icon('minus')} Remove</button></form></td></tr>`)}</tbody></table></div>`
140
+ : html`<p class="muted">No objects yet: add some below.</p>`;
141
+ const add = html`<form method="get" action="${M}/${d.id}#dm-add" class="dm-search" role="search">
142
+ <label class="label" for="f_q">Search the tables and views of ${d.schema_name}</label>
143
+ <div class="dm-search-row"><input id="f_q" name="q" type="search" value="${q}" placeholder="Part of a name, emp*, or * for all" maxlength="200"><button class="btn">${icon('search')} Search</button></div></form>
144
+ ${q
145
+ ? found.length
146
+ ? html`<form method="post" action="${M}/${d.id}/objects">${csrf(s)}<fieldset class="field"><legend class="label">${found.length} found${found.length === 500 ? ' (the first 500)' : ''}</legend><div class="qb-pick">
147
+ ${found.map((n) => html`<label class="check u-break"><input type="checkbox" name="t" value="${n}" checked> ${n}${kindOf.get(n) !== 'table' ? html` <span class="tag">${kindOf.get(n)}</span>` : ''}</label>`)}</div></fieldset>
148
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} Add to diagram</button></div></form>`
149
+ : html`<p class="muted">Nothing else matches ${q}.</p>`
150
+ : ''}`;
151
+ const settings = html`<details class="dm-settings"><summary>Rename or delete the diagram</summary>
152
+ <form method="post" action="${M}/${d.id}/rename" class="dm-search-row">${csrf(s)}<label class="sr-only" for="f_rename">Name</label><input id="f_rename" name="name" value="${d.name}" maxlength="120" required><button class="btn">${icon('edit')} Rename</button></form>
153
+ <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the diagram ${d.name}? The tables stay as they are.">${icon('trash')} Delete diagram</button></form></details>`;
154
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
155
+ <div class="dm-head"><h2 class="dm-name">${d.name}</h2><span class="muted small">Schema ${d.schema_name} · ${tables.length} objects · updated ${when(d.updated_at)} by ${d.updated_by ?? d.created_by}</span></div>
156
+ ${missing.length ? html`<div class="alert alert-error" role="alert">Not in ${d.schema_name} any more: ${missing.join(', ')}. Saving the diagram leaves them out.</div>` : ''}
157
+ ${toolbar}
158
+ <div class="dm-stage" data-dm-stage data-name="${d.name}">
159
+ <div class="dm-canvas" tabindex="0" aria-label="Diagram ${d.name}: drag a box to move it, or focus it and use the arrow keys">${svg}</div>
160
+ </div>
161
+ <p class="muted small dm-legend">P primary key · F foreign key · U unique · <strong>*</strong> not null · a line runs from the referencing table (crow's foot: many) to the referenced one (bar: one); dashed when the foreign key may be empty. A box's name opens ${tables.some((t) => t.kind === 'table') ? 'Table Properties' : 'the object'}.</p>
162
+ ${region('Objects', objectList, '', 'dm-objects')}<div class="u-spacer"></div>
163
+ ${region('Add objects to diagram', add, '', 'dm-add')}<div class="u-spacer"></div>
164
+ ${settings}`;
165
+ return send(reply, s, shell(s, d.name, crumbs, main, 'sql'));
166
+ }
167
+
168
+ /** The DDL of the diagram's objects: tables, their keys, indexes, triggers, policies, comments and grants; views. */
169
+ export function diagramDdl(schema: string, names: Set<string>, objects: DdlObject[]) {
170
+ // a comment's or grant's target: "emp" or "emp.col" (quote_ident)
171
+ const relOf = (target: string) => {
172
+ const m = /^("(?:[^"]|"")*"|[^.]+)/.exec(target)?.[1] ?? '';
173
+ return m.startsWith('"') ? m.slice(1, -1).replace(/""/g, '"') : m;
174
+ };
175
+ const owners = new Set(objects.filter((o) => o.kind === 'sequence owner' && names.has(relOf(o.ddl.replace(/^.* owned by /, '').replace(/;$/, '')))).map((o) => o.name));
176
+ const keep = objects.filter((o) => {
177
+ switch (o.kind) {
178
+ case 'table': case 'view': case 'materialized view': return names.has(o.name);
179
+ case 'sequence': case 'sequence owner': return owners.has(o.name);
180
+ case 'comment': case 'grant': {
181
+ const target = o.key.replace(/^(comment|grant):(table|view|materialized view|column|sequence) /, '');
182
+ if (target === o.key) return false;
183
+ return /^(comment|grant):sequence /.test(o.key) ? owners.has(relOf(target)) : names.has(relOf(target));
184
+ }
185
+ default: return !!o.table && names.has(o.table);
186
+ }
187
+ });
188
+ return ddlScript(schema, keep).replace(/^-- DDL of schema .*$/m, `-- DDL of ${names.size} objects of schema ${schema} (a Data Modeler diagram)`);
189
+ }
190
+
191
+ const CON: Record<string, string> = { p: 'Primary key', u: 'Unique', f: 'Foreign key', c: 'Check' };
192
+
193
+ /** The data dictionary: tables, columns, comments, constraints. */
194
+ function dictionary(schema: string, d: Diagram, tables: ModelTable[]): Raw {
195
+ const fks = lines(schema, tables);
196
+ return html`<header class="dm-dict-head"><h1>Data dictionary: ${d.name}</h1>
197
+ <p>Schema ${schema} · ${tables.length} objects · generated ${when(new Date().toISOString())} UTC</p></header>
198
+ <nav aria-label="Objects"><ol class="dm-dict-toc">${tables.map((t, i) => html`<li><a href="#t${i}">${t.name}</a> <span class="muted">${t.kind}</span></li>`)}</ol></nav>
199
+ ${tables.map((t, i) => html`<section class="dm-dict-table" id="t${i}">
200
+ <h2>${t.name} <span class="muted small">${t.kind}</span></h2>
201
+ ${t.comment ? html`<p>${t.comment}</p>` : ''}
202
+ <div class="table-wrap"><table class="report" aria-label="Columns of ${t.name}"><thead><tr><th>#</th><th>Column</th><th>Type</th><th>Null</th><th>Default</th><th>Key</th><th>Comment</th></tr></thead>
203
+ <tbody>${t.columns.map((c, k) => html`<tr><td class="num">${k + 1}</td><td class="u-break"><code>${c.name}</code></td><td class="u-break">${c.type}</td><td>${c.notNull ? 'not null' : 'null'}</td>
204
+ <td class="u-break">${c.default ?? ''}</td><td>${markers(t, c.name)}</td><td class="u-break">${c.comment ?? ''}</td></tr>`)}</tbody></table></div>
205
+ ${t.constraints.length ? html`<h3>Constraints</h3><div class="table-wrap"><table class="report" aria-label="Constraints of ${t.name}"><thead><tr><th>Name</th><th>Type</th><th>Definition</th></tr></thead>
206
+ <tbody>${t.constraints.map((k) => html`<tr><td class="u-break"><code>${k.name}</code></td><td>${CON[k.type]}</td><td class="u-break"><code>${k.def}</code></td></tr>`)}</tbody></table></div>` : ''}
207
+ ${fks.some((l) => l.to === t.name) ? html`<h3>Referenced by</h3><ul>${fks.filter((l) => l.to === t.name).map((l) => html`<li>${l.label}</li>`)}</ul>` : ''}
208
+ </section>`)}`;
209
+ }
210
+
211
+ /** A standalone copy of the data dictionary (a downloaded file: its own small stylesheet). */
212
+ const DICT_CSS = `body{font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#1e293b;max-width:1100px;margin:24px auto;padding:0 16px}
213
+ h1{font-size:1.6rem}h2{font-size:1.25rem;margin-top:2rem;border-bottom:1px solid #cbd5e1;padding-bottom:4px}h3{font-size:1rem}
214
+ table{border-collapse:collapse;width:100%;margin:8px 0}th,td{border:1px solid #cbd5e1;padding:4px 6px;text-align:left;vertical-align:top}th{background:#f1f5f9}
215
+ .muted{color:#64748b}.small{font-size:.85em}.num{text-align:right}code{font-family:ui-monospace,Consolas,monospace;font-size:.9em}
216
+ .dm-dict-toc{columns:3 14rem}.table-wrap{overflow-x:auto}@media print{h2{break-after:avoid}.dm-dict-table{break-inside:avoid-page}}`;
217
+
218
+ export async function dataModelerRoutes(app: FastifyInstance) {
219
+ app.get(M, listPage);
220
+
221
+ app.post(M, async (req: Req, reply) => {
222
+ const s = await developer(req, reply);
223
+ if (!s) return;
224
+ const name = String(req.body?.name ?? '').trim().slice(0, 120);
225
+ const schema = String(req.body?.schema ?? '');
226
+ const db = await workshopDb(s);
227
+ if (!(await usableSchemas(db)).includes(schema)) return notFound(reply);
228
+ if (!name) {
229
+ flash(s, 'A diagram needs a name.', 'error');
230
+ return back(reply, s, M);
231
+ }
232
+ const names = matchNames((await modelNames(db, schema)).map((o) => o.name), String(req.body?.objects ?? '').slice(0, 2000), { exact: true }).slice(0, 500);
233
+ const tables = await readModel(db, schema, names);
234
+ try {
235
+ const row = await owner.one<{ id: string }>(
236
+ `insert into meta.sql_diagram (workspace_id, name, schema_name, objects, created_by) values ($1, $2, $3, $4, $5) returning id::text`,
237
+ [currentWorkspace(s), name, schema, JSON.stringify(autoLayout(schema, tables)), s.username]);
238
+ flash(s, `Diagram ${name} created${tables.length ? ` with ${tables.length} objects` : ': add objects to it'}.`);
239
+ return back(reply, s, `${M}/${row!.id}${tables.length ? '' : '#dm-add'}`);
240
+ } catch (e: any) {
241
+ if (e.code !== '23505') throw e;
242
+ flash(s, `There is already a diagram ${name} in this workspace.`, 'error');
243
+ return back(reply, s, M);
244
+ }
245
+ });
246
+
247
+ app.get(`${M}/:id`, diagramPage);
248
+
249
+ // add (t=name …) or remove (remove=name) objects
250
+ app.post(`${M}/:id/objects`, async (req: Req, reply) => {
251
+ const s = await developer(req, reply);
252
+ if (!s) return;
253
+ const d = await loadDiagram(s, req.params.id);
254
+ if (!d) return notFound(reply);
255
+ const m = await model(s, d);
256
+ if (!m) return notFound(reply);
257
+ const tv = (req.body as Record<string, unknown> | undefined)?.t;
258
+ const wanted = (Array.isArray(tv) ? tv : tv === undefined ? [] : [tv]).map(String);
259
+ const remove = typeof req.body?.remove === 'string' ? req.body.remove : '';
260
+ if (remove) {
261
+ await saveObjects(s, d.id, m.placed.filter((p) => p.name !== remove));
262
+ flash(s, `${remove} removed from the diagram (the table stays as it is).`);
263
+ return back(reply, s, `${M}/${d.id}`);
264
+ }
265
+ const existing = new Set((await modelNames(m.db, d.schema_name)).map((o) => o.name));
266
+ const adding = [...new Set(wanted)].filter((n) => existing.has(n) && !m.placed.some((p) => p.name === n)).slice(0, Math.max(0, 500 - m.placed.length));
267
+ if (!adding.length) {
268
+ flash(s, 'Nothing to add: pick tables or views of the schema that aren\'t in the diagram.', 'error');
269
+ return back(reply, s, `${M}/${d.id}#dm-add`);
270
+ }
271
+ const added = await readModel(m.db, d.schema_name, adding);
272
+ await saveObjects(s, d.id, placeAll(d.schema_name, [...m.tables, ...added], m.placed));
273
+ flash(s, `Added ${added.length} object${added.length === 1 ? '' : 's'}: ${added.map((t) => t.name).join(', ')}.`);
274
+ return back(reply, s, `${M}/${d.id}`);
275
+ });
276
+
277
+ // the positions (positions = JSON [{name, x, y}]), or the automatic layout (layout=auto)
278
+ app.post(`${M}/:id/layout`, async (req: Req, reply) => {
279
+ const s = await developer(req, reply);
280
+ if (!s) return;
281
+ const d = await loadDiagram(s, req.params.id);
282
+ if (!d) return notFound(reply);
283
+ const m = await model(s, d);
284
+ if (!m) return notFound(reply);
285
+ const json = req.body?.format === 'json';
286
+ let next: Placed[];
287
+ if (req.body?.layout === 'auto') next = autoLayout(d.schema_name, m.tables);
288
+ else {
289
+ let posted: unknown;
290
+ try {
291
+ posted = JSON.parse(String(req.body?.positions ?? '[]').slice(0, 100000));
292
+ } catch {
293
+ return reply.code(400).send(json ? { ok: false, error: 'Unreadable positions.' } : 'Unreadable positions.');
294
+ }
295
+ const given = new Map(cleanObjects(posted).map((p) => [p.name, p]));
296
+ // only the diagram's own objects; one left out keeps its place
297
+ next = m.placed.map((p) => given.get(p.name) ?? p);
298
+ }
299
+ await saveObjects(s, d.id, next);
300
+ if (json) return reply.send({ ok: true, saved: next.length });
301
+ flash(s, req.body?.layout === 'auto' ? 'The boxes were laid out again.' : 'Diagram saved.');
302
+ return back(reply, s, `${M}/${d.id}`);
303
+ });
304
+
305
+ app.post(`${M}/:id/rename`, async (req: Req, reply) => {
306
+ const s = await developer(req, reply);
307
+ if (!s) return;
308
+ const d = await loadDiagram(s, req.params.id);
309
+ if (!d) return notFound(reply);
310
+ const name = String(req.body?.name ?? '').trim().slice(0, 120);
311
+ if (!name) {
312
+ flash(s, 'A diagram needs a name.', 'error');
313
+ return back(reply, s, `${M}/${d.id}`);
314
+ }
315
+ try {
316
+ await owner.query(`update meta.sql_diagram set name = $3, updated_by = $4, updated_at = now() where id = $1 and workspace_id = $2`, [d.id, currentWorkspace(s), name, s.username]);
317
+ flash(s, `Diagram renamed to ${name}.`);
318
+ } catch (e: any) {
319
+ if (e.code !== '23505') throw e;
320
+ flash(s, `There is already a diagram ${name} in this workspace.`, 'error');
321
+ }
322
+ return back(reply, s, `${M}/${d.id}`);
323
+ });
324
+
325
+ app.post(`${M}/:id/delete`, async (req: Req, reply) => {
326
+ const s = await developer(req, reply);
327
+ if (!s) return;
328
+ const d = await loadDiagram(s, req.params.id);
329
+ if (!d) return notFound(reply);
330
+ await owner.query('delete from meta.sql_diagram where id = $1 and workspace_id = $2', [d.id, currentWorkspace(s)]);
331
+ flash(s, `Diagram ${d.name} deleted.`);
332
+ return back(reply, s, M);
333
+ });
334
+
335
+ // Export SVG: the diagram at its saved positions, as a file
336
+ app.get(`${M}/:id/svg`, async (req: Req, reply) => {
337
+ const s = await developer(req, reply);
338
+ if (!s) return;
339
+ const d = await loadDiagram(s, req.params.id);
340
+ const m = d && (await model(s, d));
341
+ if (!d || !m) return notFound(reply);
342
+ const svg = `<?xml version="1.0" encoding="UTF-8"?>\n${diagramSvg(d.schema_name, d.name, m.tables, m.placed)}\n`;
343
+ return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}.svg"`).header('x-content-type-options', 'nosniff').type('image/svg+xml; charset=utf-8').send(svg);
344
+ });
345
+
346
+ // Generate DDL for the diagram's objects
347
+ app.get(`${M}/:id/ddl`, async (req: Req, reply) => {
348
+ const s = await developer(req, reply);
349
+ if (!s) return;
350
+ const d = await loadDiagram(s, req.params.id);
351
+ const m = d && (await model(s, d));
352
+ if (!d || !m) return notFound(reply);
353
+ const script = diagramDdl(d.schema_name, new Set(m.tables.map((t) => t.name)), await readSchema(m.db.pool, d.schema_name));
354
+ if (req.query.download === '1')
355
+ return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}.sql"`).type('application/sql; charset=utf-8').send(script);
356
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
357
+ ${region(`DDL of ${d.name}`, html`<p class="muted u-mt0">The CREATE statements of the diagram's ${m.tables.length} objects in schema ${d.schema_name}: tables, their keys, indexes, triggers, row level security, policies, comments and grants, and views. Review before running.</p>
358
+ <pre class="source" tabindex="0" aria-label="DDL">${script}</pre>
359
+ <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${script}">
360
+ <div class="buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} Back to the diagram</a><a class="btn" href="${M}/${d.id}/ddl?download=1">${icon('download')} Download</a>
361
+ <button class="btn btn-hot">${icon('play')} Open in the SQL Worksheet</button></div></form>`)}`;
362
+ return send(reply, s, shell(s, `DDL of ${d.name}`, [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name, `${M}/${d.id}`], ['DDL']], main, 'sql'));
363
+ });
364
+
365
+ // the data dictionary report (download=1: a standalone HTML file)
366
+ app.get(`${M}/:id/dictionary`, async (req: Req, reply) => {
367
+ const s = await developer(req, reply);
368
+ if (!s) return;
369
+ const d = await loadDiagram(s, req.params.id);
370
+ const m = d && (await model(s, d));
371
+ if (!d || !m) return notFound(reply);
372
+ const body = dictionary(d.schema_name, d, m.tables);
373
+ if (req.query.download === '1')
374
+ return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}-dictionary.html"`).type('text/html; charset=utf-8')
375
+ .send(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Data dictionary: ${esc(d.name)}</title><style>${DICT_CSS}</style></head><body>${body}</body></html>`);
376
+ const main = html`<div class="dm-dict-actions buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} Back to the diagram</a>
377
+ <a class="btn" href="${M}/${d.id}/dictionary?download=1">${icon('download')} Download HTML</a>
378
+ <button type="button" class="btn" data-dm="print" hidden>${icon('printer')} Print or save as PDF</button></div>
379
+ <article class="dm-dict">${body}</article>`;
380
+ return send(reply, s, shell(s, `Data dictionary: ${d.name}`, [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name, `${M}/${d.id}`], ['Data dictionary']], main, 'sql'));
381
+ });
382
+ }