pgkiln 0.48.0 → 0.49.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 (48) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/116_dump_jobs.sql +41 -0
  3. package/db/migrations/117_rest_handlers.sql +63 -0
  4. package/db/migrations/118_change_log.sql +54 -0
  5. package/docs/apex-feature-parity.md +7 -7
  6. package/docs/guide/01-installation.md +4 -0
  7. package/docs/guide/03-builder.md +179 -5
  8. package/docs/guide/08-security.md +11 -0
  9. package/docs/guide/12-development.md +44 -6
  10. package/docs/guide/13-rest-api.md +59 -1
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/examples/hr/hr_62_rest_module.sql +39 -0
  13. package/package.json +1 -1
  14. package/public/app.css +1 -1
  15. package/public/builder-icons.svg +2 -0
  16. package/public/builder.css +420 -173
  17. package/public/builder.js +48 -0
  18. package/public/sql-monitor.js +73 -0
  19. package/src/app.ts +6 -0
  20. package/src/automations.ts +6 -0
  21. package/src/binds.ts +2 -1
  22. package/src/builder/advisor.ts +24 -2
  23. package/src/builder/autorest.ts +3 -2
  24. package/src/builder/changelog.ts +240 -0
  25. package/src/builder/components.ts +1 -1
  26. package/src/builder/datapump.ts +364 -0
  27. package/src/builder/ddl-tools.ts +13 -4
  28. package/src/builder/find-workspace.ts +2 -2
  29. package/src/builder/plan-view.ts +25 -0
  30. package/src/builder/rest-modules.ts +629 -0
  31. package/src/builder/rest.ts +1 -0
  32. package/src/builder/routes.ts +4 -2
  33. package/src/builder/scheduling.ts +495 -0
  34. package/src/builder/sql-monitor.ts +397 -0
  35. package/src/builder/sql-tools.ts +6 -2
  36. package/src/builder/sql-utilities.ts +7 -39
  37. package/src/builder/sql.ts +6 -0
  38. package/src/builder/table-editor.ts +4 -1
  39. package/src/builder/top-sql.ts +24 -6
  40. package/src/builder/ui.ts +6 -4
  41. package/src/builder/worksheet.ts +5 -21
  42. package/src/changelog.ts +109 -0
  43. package/src/cron-schedule.ts +282 -0
  44. package/src/datapump.ts +546 -0
  45. package/src/dumpscript.ts +407 -0
  46. package/src/pgcron.ts +225 -0
  47. package/src/runtime/rest-handlers.ts +75 -0
  48. package/src/runtime/rest.ts +116 -22
@@ -0,0 +1,364 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir, rm } from 'node:fs/promises';
4
+ import { pipeline } from 'node:stream/promises';
5
+ import type { FastifyInstance, FastifyReply } from 'fastify';
6
+ import {
7
+ archiveSchema, cancelJob, DumpError, DUMP_DIR, exportableSchemas, filePath, findTools, getJob, KEEP_DAYS, listJobs, readJobFile, readToc,
8
+ schemaTables, SCHEMA_NAME, startExport, startImport, deleteJob, uploadMaxMb, uploadJob, jobFile, type Content, type Job, type ToolState,
9
+ } from '../datapump.ts';
10
+ import { html, raw, type Raw } from '../html.ts';
11
+ import { icon } from '../icons.ts';
12
+ import { clientIp, logActivity, type Session } from '../session.ts';
13
+ import { isAdmin } from './locks.ts';
14
+ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
15
+
16
+ // (sprint 103, Database Actions DA-16: Data Pump) SQL Workshop › Schema Export / Import, for administrators: pg_dump of a
17
+ // schema (custom or plain, definitions and/or data, tables in or out) and pg_restore of an archive into a new or
18
+ // existing schema, as background jobs with their logs, sizes and timing; files are kept for PGKILN_DUMP_KEEP_DAYS.
19
+ // The work: src/datapump.ts (jobs, pg_dump, pg_restore) and src/dumpscript.ts (the import's script runner).
20
+
21
+ const URL_BASE = `${BASE}/sql/data-pump`;
22
+ const TITLE = 'Schema Export / Import';
23
+ const jobUrl = (id: number) => `${URL_BASE}/job/${id}`;
24
+ const crumbs = (...more: [string, string?][]): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], [TITLE, more.length ? URL_BASE : undefined], ...more];
25
+ const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
26
+ const CONTENT: [Content, string][] = [['all', 'Definitions and data'], ['definitions', 'Definitions only'], ['data', 'Data only']];
27
+
28
+ /** An administrator's session; others get a 403 page. */
29
+ async function admin(req: Req, reply: FastifyReply) {
30
+ const s = await developer(req, reply);
31
+ if (!s) return null;
32
+ if (await isAdmin(s.username)) return s;
33
+ const main = html`<h1 class="u-mb1">${TITLE}</h1><div class="alert alert-error" role="alert">Only administrators export and import schemas: an export holds every row of a schema, an import runs its SQL as pgkiln's owner role.</div>`;
34
+ await send(reply.code(403), s, shell(s, TITLE, crumbs(), main, 'sql'));
35
+ return null;
36
+ }
37
+
38
+ const page = (reply: FastifyReply, s: Session, title: string, trail: [string, string?][], body: Raw, code = 200) =>
39
+ send(reply.code(code), s, shell(s, title, crumbs(...trail), html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('datapump')}${body}`, 'sql'));
40
+
41
+ export const size = (n: number | null) =>
42
+ n === null ? '' : n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MB` : `${(n / 1024 ** 3).toFixed(2)} GB`;
43
+
44
+ const STATUS: Record<Job['status'], [string, string]> = {
45
+ uploaded: ['tag-info', 'uploaded'], running: ['tag-warning', 'running'], succeeded: ['tag-ok', 'succeeded'], failed: ['tag-error', 'failed'], cancelled: ['', 'cancelled'],
46
+ };
47
+ const status = (j: Job) => html`<span class="tag ${STATUS[j.status][0]}">${STATUS[j.status][1]}</span>`;
48
+ const what = (j: Job) =>
49
+ j.kind === 'export'
50
+ ? html`Export of <strong>${j.schema_name ?? ''}</strong>${(j.options.format as string) === 'plain' ? ' (plain SQL)' : ''}`
51
+ : j.status === 'uploaded'
52
+ ? html`Upload <strong class="u-break">${j.original_name ?? ''}</strong>`
53
+ : html`Import of <strong>${j.schema_name ?? ''}</strong> into <strong>${j.target_schema ?? ''}</strong>`;
54
+
55
+ /** Can this job's file be imported? (an export in custom format, or an upload; while its file is kept) */
56
+ const importable = (j: Job) =>
57
+ j.file_live && ((j.kind === 'export' && j.status === 'succeeded' && j.options.format !== 'plain') || (j.kind === 'import' && !j.source_job && j.status !== 'running'));
58
+
59
+ const toolNote = (t: ToolState) =>
60
+ t.ok
61
+ ? html`<p class="muted small">${t.tools.version} and pg_restore for a PostgreSQL ${String(t.server)} server. Files are kept ${String(KEEP_DAYS)} day${KEEP_DAYS === 1 ? '' : 's'} in the dump directory.</p>`
62
+ : html`<div class="alert alert-warning" role="alert"><strong>Export and import need pg_dump and pg_restore.</strong> ${t.reason}${t.found.length ? html` Found: ${t.found.join('; ')}.` : ''}</div>`;
63
+
64
+ async function listPage(reply: FastifyReply, s: Session) {
65
+ const [jobs, tools] = await Promise.all([listJobs(), findTools()]);
66
+ const body = html`<div class="ide-head-title u-mb1"><div><h2 class="u-m0">${TITLE}</h2><span class="muted small">A schema to a file with pg_dump, and a file into a schema with pg_restore (Data Pump), as background jobs.</span></div>
67
+ <div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/export">${icon('download')} Export a schema</a> <a class="btn" href="${URL_BASE}/import">${icon('upload')} Import</a></div></div>
68
+ ${toolNote(tools)}
69
+ ${region('Jobs', html`<div class="table-wrap"><table class="report report-reflow dp-jobs"><thead><tr><th>Job</th><th>What</th><th>Status</th><th class="num">Size</th><th>Started</th><th class="num">Seconds</th><th>By</th></tr></thead>
70
+ <tbody>${jobs.length ? jobs.map((j) => html`<tr>
71
+ <td data-label="Job"><a href="${jobUrl(j.id)}">#${String(j.id)}</a></td>
72
+ <td data-label="What" class="u-break">${what(j)}</td>
73
+ <td data-label="Status">${status(j)}</td>
74
+ <td data-label="Size" class="num">${size(j.bytes)}${j.file_name && !j.file_live ? '' : j.bytes !== null && !j.file_name ? html` <span class="muted small">(removed)</span>` : ''}</td>
75
+ <td data-label="Started">${j.started_at ?? j.created_at}</td>
76
+ <td data-label="Seconds" class="num">${j.seconds === null ? '' : String(j.seconds)}</td>
77
+ <td data-label="By" class="u-break">${j.username}</td></tr>`)
78
+ : html`<tr><td colspan="7" class="empty">No exports or imports yet.</td></tr>`}</tbody></table></div>`)}`;
79
+ return page(reply, s, TITLE, [], body);
80
+ }
81
+
82
+ async function exportPage(reply: FastifyReply, s: Session, q: Record<string, string | string[] | undefined>, errors: string[] = []) {
83
+ const schemas = await exportableSchemas();
84
+ const schema = schemas.includes(String(q.schema ?? '')) ? String(q.schema) : '';
85
+ const tables = schema ? await schemaTables(schema) : [];
86
+ const mode = ['all', 'only', 'except'].includes(String(q.mode)) ? String(q.mode) : 'all';
87
+ const picked = new Set(many(q.t));
88
+ const format = q.format === 'plain' ? 'plain' : 'custom';
89
+ const content = CONTENT.some(([c]) => c === q.content) ? String(q.content) : 'all';
90
+ const privileges = q.schema === undefined || q.privileges === '1';
91
+ const body = html`${toolNote(await findTools())}
92
+ ${errors.length ? html`<div class="alert alert-error" role="alert">${errors.map((e) => html`<div>${e}</div>`)}</div>` : ''}
93
+ <form method="get" action="${URL_BASE}/export" class="u-mb1"><div class="form-grid"><div class="field"><label class="label" for="dp-schema">Schema</label>
94
+ <select id="dp-schema" name="schema" required><option value="">- choose a schema -</option>${schemas.map((x) => html`<option value="${x}"${x === schema ? raw(' selected') : ''}>${x}</option>`)}</select></div></div>
95
+ <div class="buttons"><button class="btn">${icon('table')} Choose</button></div></form>
96
+ ${schema ? region(`Export ${schema}`, html`<form method="post" action="${URL_BASE}/export">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
97
+ <fieldset class="prop-group"><legend>Format</legend><div class="form-grid">
98
+ <label class="check"><input type="radio" name="format" value="custom"${format === 'custom' ? raw(' checked') : ''}> Custom (compressed; imports here with pg_restore)</label>
99
+ <label class="check"><input type="radio" name="format" value="plain"${format === 'plain' ? raw(' checked') : ''}> Plain SQL (for psql; not imported here)</label></div></fieldset>
100
+ <fieldset class="prop-group"><legend>Content</legend><div class="form-grid">
101
+ ${CONTENT.map(([c, label]) => html`<label class="check"><input type="radio" name="content" value="${c}"${content === c ? raw(' checked') : ''}> ${label}</label>`)}
102
+ <label class="check"><input type="checkbox" name="privileges" value="1"${privileges ? raw(' checked') : ''}> Grants</label></div></fieldset>
103
+ <fieldset class="prop-group"><legend>Tables, views and sequences</legend>
104
+ <div class="form-grid">${[['all', 'The whole schema'], ['only', 'Only the checked ones'], ['except', 'All but the checked ones']].map(([m, label]) =>
105
+ html`<label class="check"><input type="radio" name="mode" value="${m}"${mode === m ? raw(' checked') : ''}> ${label}</label>`)}</div>
106
+ ${tables.length ? html`<div class="dp-tables">${tables.map((t) => html`<label class="check u-break"><input type="checkbox" name="t" value="${t}"${picked.has(t) ? raw(' checked') : ''}> ${t}</label>`)}</div>`
107
+ : html`<p class="muted">The schema has no tables, views or sequences.</p>`}
108
+ <p class="muted small">Only some tables: the file has no CREATE SCHEMA, and functions, types and the other objects of the schema stay out.</p></fieldset>
109
+ <div class="buttons"><button class="btn btn-hot">${icon('download')} Start the export</button></div></form>`) : ''}`;
110
+ return page(reply, s, 'Export a schema', [['Export']], body, errors.length ? 422 : 200);
111
+ }
112
+
113
+ async function importPage(reply: FastifyReply, s: Session) {
114
+ const files = (await listJobs()).filter(importable);
115
+ const body = html`${toolNote(await findTools())}
116
+ ${region('Upload a dump', html`<form method="post" action="${URL_BASE}/upload" enctype="multipart/form-data">${csrf(s)}
117
+ <div class="form-grid"><div class="field"><label class="label" for="dp-file">A custom or tar archive of one schema (pg_dump -Fc or -Ft), up to ${String(uploadMaxMb())} MB</label>
118
+ <input type="file" id="dp-file" name="file" required></div></div>
119
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} Upload</button></div></form>
120
+ <p class="muted small">The next page shows what the archive holds before anything runs. A dump is code: its functions and triggers run as pgkiln's owner role, so import only dumps you trust.</p>`)}
121
+ ${region('Or an export of this server', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Job</th><th>File</th><th class="num">Size</th><th>Made</th><th></th></tr></thead>
122
+ <tbody>${files.length ? files.map((j) => html`<tr><td data-label="Job"><a href="${jobUrl(j.id)}">#${String(j.id)}</a></td><td data-label="File" class="u-break">${what(j)}</td>
123
+ <td data-label="Size" class="num">${size(j.bytes)}</td><td data-label="Made">${j.finished_at ?? j.created_at}</td>
124
+ <td><a class="btn btn-sm" href="${jobUrl(j.id)}/import">${icon('upload')} Import…</a></td></tr>`)
125
+ : html`<tr><td colspan="5" class="empty">No exports in custom format are kept.</td></tr>`}</tbody></table></div>`)}`;
126
+ return page(reply, s, 'Import', [['Import']], body);
127
+ }
128
+
129
+ async function importForm(reply: FastifyReply, s: Session, j: Job, body: Body = {}, errors: string[] = []) {
130
+ let preview: Raw;
131
+ let schema = '';
132
+ try {
133
+ const toc = await readToc(jobFile(j)!);
134
+ let problem = '';
135
+ try {
136
+ schema = archiveSchema(toc);
137
+ } catch (e) {
138
+ problem = (e as Error).message;
139
+ }
140
+ const tables = toc.entries.filter((e) => e.desc === 'TABLE').map((e) => e.name);
141
+ const rows = toc.entries.filter((e) => e.desc === 'TABLE DATA').length;
142
+ preview = html`${problem ? html`<div class="alert alert-error" role="alert">${problem}</div>` : ''}
143
+ <dl class="dp-facts">${toc.header.filter(([k]) => /^(Archive created at|dbname|Format|Dumped from database version|Dumped by pg_dump version)$/.test(k)).map(([k, v]) => html`<dt>${k}</dt><dd class="u-break">${v}</dd>`)}
144
+ <dt>Schema</dt><dd>${toc.schemas.join(', ') || '-'}${toc.schemaEntry ? '' : html` <span class="muted small">(some tables: no CREATE SCHEMA)</span>`}</dd>
145
+ <dt>Tables</dt><dd class="u-break">${tables.length ? tables.join(', ') : html`<span class="muted">none</span>`}${rows ? html` <span class="muted small">(data of ${String(rows)})</span>` : ''}</dd></dl>
146
+ <div class="table-wrap"><table class="report"><thead><tr><th>Object type</th><th class="num">Entries</th></tr></thead>
147
+ <tbody>${toc.counts.map(([d, n]) => html`<tr><td>${d.toLowerCase()}</td><td class="num">${String(n)}</td></tr>`)}</tbody></table></div>`;
148
+ if (problem) schema = '';
149
+ } catch (e) {
150
+ preview = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
151
+ }
152
+ const target = body.target ?? schema;
153
+ const content = CONTENT.some(([c]) => c === body.content) ? body.content! : 'all';
154
+ const form = schema ? region('Import into', html`<form method="post" action="${jobUrl(j.id)}/import">${csrf(s)}
155
+ <div class="form-grid"><div class="field"><label class="label" for="dp-target">Schema</label>
156
+ <input id="dp-target" name="target" value="${target}" required maxlength="63" pattern="[A-Za-z_][A-Za-z0-9_$]{0,62}" autocomplete="off" spellcheck="false">
157
+ <span class="help muted small">A new schema is created; into an existing one, the dump's objects are added (or replaced, below). A name other than ${schema} renames the dump's schema in the same transaction.</span></div></div>
158
+ <fieldset class="prop-group"><legend>Content</legend><div class="form-grid">
159
+ ${CONTENT.map(([c, label]) => html`<label class="check"><input type="radio" name="content" value="${c}"${content === c ? raw(' checked') : ''}> ${label}</label>`)}
160
+ <label class="check"><input type="checkbox" name="clean" value="1"${body.clean === '1' ? raw(' checked') : ''}> Drop the dump's objects in the schema first</label>
161
+ <label class="check"><input type="checkbox" name="privileges" value="1"${body.target === undefined || body.privileges === '1' ? raw(' checked') : ''}> Grants</label></div></fieldset>
162
+ <p class="muted small">Everything runs in one transaction: an error rolls it all back. Objects are owned by pgkiln's owner role. Names inside PL/pgSQL bodies are text: after a rename they still say ${schema}.</p>
163
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} Start the import</button></div></form>`) : '';
164
+ const main = html`${errors.length ? html`<div class="alert alert-error" role="alert">${errors.map((e) => html`<div>${e}</div>`)}</div>` : ''}
165
+ ${region(`What ${j.original_name ?? j.file_name ?? ''} holds`, preview)}${form}`;
166
+ return page(reply, s, 'Import', [[`Job #${j.id}`, jobUrl(j.id)], ['Import']], main, errors.length ? 422 : 200);
167
+ }
168
+
169
+ async function jobPage(reply: FastifyReply, s: Session, j: Job) {
170
+ const o = j.options;
171
+ const facts: [string, Raw | string][] = [
172
+ ['Status', html`${status(j)}${j.error ? html` <span class="u-break">${j.error}</span>` : ''}`],
173
+ ['By', j.username],
174
+ ['Created', j.created_at],
175
+ ...(j.started_at ? [['Started', j.started_at] as [string, string]] : []),
176
+ ...(j.finished_at ? [['Finished', `${j.finished_at} (${j.seconds} s)`] as [string, string]] : []),
177
+ ...(j.kind === 'export' ? [['Options', `${o.format} format, ${CONTENT.find(([c]) => c === o.content)?.[1].toLowerCase() ?? ''}${o.privileges ? ', grants' : ''}${(o.tables as string[])?.length ? `; only ${(o.tables as string[]).join(', ')}` : ''}${(o.exclude as string[])?.length ? `; without ${(o.exclude as string[]).join(', ')}` : ''}`] as [string, string]] : []),
178
+ ...(j.kind === 'import' && j.target_schema ? [['Options', `${CONTENT.find(([c]) => c === o.content)?.[1].toLowerCase() ?? ''}${o.clean ? ', existing objects dropped first' : ''}${o.privileges ? ', grants' : ''}`] as [string, string]] : []),
179
+ ...(j.source_job ? [['From', html`<a href="${jobUrl(j.source_job)}">job #${String(j.source_job)}</a>`] as [string, Raw]] : []),
180
+ ['File', j.file_live ? html`${j.original_name ?? j.file_name ?? ''} · ${size(j.bytes)} · kept until ${j.expires_at ?? ''}` : j.file_name || j.bytes === null ? html`<span class="muted">none</span>` : html`<span class="muted">removed (${size(j.bytes)})</span>`],
181
+ ];
182
+ const actions = html`<div class="buttons">
183
+ ${j.status === 'running' ? html`<a class="btn" href="${jobUrl(j.id)}">${icon('refresh')} Refresh</a>
184
+ <form method="post" action="${jobUrl(j.id)}/cancel" class="inline">${csrf(s)}<button class="btn">${icon('close')} Cancel</button></form>` : ''}
185
+ ${j.file_live && j.kind === 'export' && j.status === 'succeeded' ? html`<a class="btn btn-hot" href="${jobUrl(j.id)}/download">${icon('download')} Download</a>` : ''}
186
+ ${importable(j) ? html`<a class="btn" href="${jobUrl(j.id)}/import">${icon('upload')} Import…</a>` : ''}
187
+ ${j.status !== 'running' ? html`<form method="post" action="${jobUrl(j.id)}/delete" class="inline">${csrf(s)}<button class="btn btn-danger">${icon('trash')} Delete${j.file_live ? ' with its file' : ''}</button></form>` : ''}</div>`;
188
+ const body = html`${region(`Job #${j.id}: ${j.kind}`, html`<p>${what(j)}</p><dl class="dp-facts">${facts.map(([k, v]) => html`<dt>${k}</dt><dd>${v}</dd>`)}</dl>${actions}`)}
189
+ ${region('Log', j.log ? html`<pre class="ddl wrap-pre dp-log">${j.log}</pre>` : html`<p class="muted">Nothing logged${j.status === 'running' ? ' yet' : ''}.</p>`)}`;
190
+ return page(reply, s, `Job #${j.id}`, [[`Job #${j.id}`]], body);
191
+ }
192
+
193
+ const exportOptions = (b: Body) => {
194
+ const tables = many((b as Record<string, unknown>).t);
195
+ return {
196
+ format: b.format === 'plain' ? ('plain' as const) : ('custom' as const),
197
+ content: (CONTENT.some(([c]) => c === b.content) ? b.content : 'all') as Content,
198
+ tables: b.mode === 'only' ? tables : [],
199
+ exclude: b.mode === 'except' ? tables : [],
200
+ privileges: b.privileges === '1',
201
+ };
202
+ };
203
+
204
+ export async function dataPumpRoutes(app: FastifyInstance) {
205
+ app.get(URL_BASE, async (req: Req, reply) => {
206
+ const s = await admin(req, reply);
207
+ return s && listPage(reply, s);
208
+ });
209
+
210
+ app.get(`${URL_BASE}/export`, async (req: Req, reply) => {
211
+ const s = await admin(req, reply);
212
+ return s && exportPage(reply, s, req.query ?? {});
213
+ });
214
+
215
+ app.post(`${URL_BASE}/export`, async (req: Req, reply) => {
216
+ const s = await admin(req, reply);
217
+ if (!s) return;
218
+ const b = req.body ?? {};
219
+ const o = exportOptions(b);
220
+ if (b.mode === 'only' && !o.tables.length) return exportPage(reply, s, { ...b, t: [] } as never, ['Check the tables to export.']);
221
+ try {
222
+ const id = await startExport(s.username!, String(b.schema ?? ''), o);
223
+ await logActivity({ username: s.username, event: 'schema_export', ip: clientIp(req), detail: `${b.schema}: job ${id}` });
224
+ return reply.redirect(jobUrl(id), 303);
225
+ } catch (e) {
226
+ if (!(e instanceof DumpError)) throw e;
227
+ return exportPage(reply, s, b as never, [e.message]);
228
+ }
229
+ });
230
+
231
+ app.get(`${URL_BASE}/import`, async (req: Req, reply) => {
232
+ const s = await admin(req, reply);
233
+ return s && importPage(reply, s);
234
+ });
235
+
236
+ // the file is streamed to the dump directory (never held in memory); the CSRF token comes before it in the form
237
+ app.post(`${URL_BASE}/upload`, async (req: Req, reply) => {
238
+ if (!req.isMultipart()) return reply.code(415).send('Send the file as multipart/form-data.');
239
+ const body: Body = {};
240
+ let saved: { name: string; original: string; bytes: number; truncated: boolean } | null = null;
241
+ let s: Session | null = null;
242
+ for await (const part of req.parts({ limits: { fileSize: uploadMaxMb() * 1024 * 1024, files: 1 } })) {
243
+ if (part.type !== 'file') {
244
+ body[part.fieldname] = String(part.value ?? '');
245
+ continue;
246
+ }
247
+ if (!s) {
248
+ req.body = body;
249
+ s = await admin(req, reply);
250
+ if (!s) {
251
+ part.file.resume();
252
+ return reply;
253
+ }
254
+ }
255
+ if (part.fieldname !== 'file' || !part.filename || saved) {
256
+ part.file.resume();
257
+ continue;
258
+ }
259
+ await mkdir(DUMP_DIR, { recursive: true, mode: 0o700 });
260
+ const name = `upload-${Date.now()}-${randomBytes(6).toString('hex')}.dump`;
261
+ let bytes = 0;
262
+ part.file.on('data', (c: Buffer) => (bytes += c.length));
263
+ await pipeline(part.file, createWriteStream(filePath(name), { mode: 0o600 }));
264
+ saved = { name, original: part.filename.replace(/[\u0000-\u001f]/g, '').slice(0, 255), bytes, truncated: part.file.truncated };
265
+ }
266
+ if (!s) {
267
+ req.body = body;
268
+ s = await admin(req, reply);
269
+ if (!s) return reply;
270
+ }
271
+ const session = s as Session;
272
+ if (!saved) {
273
+ flash(session, 'Choose a file.', 'error');
274
+ return back(reply, session, `${URL_BASE}/import`);
275
+ }
276
+ if (saved.truncated || !saved.bytes) {
277
+ await rm(filePath(saved.name), { force: true });
278
+ flash(session, saved.truncated ? `The file is larger than ${uploadMaxMb()} MB (PGKILN_DUMP_MAX_MB).` : 'The file is empty.', 'error');
279
+ return back(reply, session, `${URL_BASE}/import`);
280
+ }
281
+ const id = await uploadJob(session.username!, saved.name, saved.original, saved.bytes);
282
+ await logActivity({ username: session.username, event: 'schema_upload', ip: clientIp(req), detail: `${saved.original}: job ${id}` });
283
+ return reply.redirect(`${jobUrl(id)}/import`, 303);
284
+ });
285
+
286
+ const withJob = async (req: Req, reply: FastifyReply) => {
287
+ const s = await admin(req, reply);
288
+ if (!s) return null;
289
+ const j = await getJob(req.params.id);
290
+ if (!j) {
291
+ reply.code(404).send('Not found');
292
+ return null;
293
+ }
294
+ return { s, j };
295
+ };
296
+
297
+ app.get(`${URL_BASE}/job/:id`, async (req: Req, reply) => {
298
+ const x = await withJob(req, reply);
299
+ return x && jobPage(reply, x.s, x.j);
300
+ });
301
+
302
+ app.get(`${URL_BASE}/job/:id/import`, async (req: Req, reply) => {
303
+ const x = await withJob(req, reply);
304
+ if (!x) return;
305
+ if (!importable(x.j)) return reply.redirect(jobUrl(x.j.id));
306
+ return importForm(reply, x.s, x.j);
307
+ });
308
+
309
+ app.post(`${URL_BASE}/job/:id/import`, async (req: Req, reply) => {
310
+ const x = await withJob(req, reply);
311
+ if (!x) return;
312
+ const b = req.body ?? {};
313
+ if (!importable(x.j)) return importForm(reply, x.s, x.j, b, ['This file can\'t be imported (any more).']);
314
+ const target = String(b.target ?? '').trim();
315
+ if (!SCHEMA_NAME.test(target)) return importForm(reply, x.s, x.j, b, ['The schema: letters, digits, _ and $, up to 63, not starting with a digit.']);
316
+ try {
317
+ const id = await startImport(x.s.username!, x.j, {
318
+ target, content: (CONTENT.some(([c]) => c === b.content) ? b.content : 'all') as Content, clean: b.clean === '1', privileges: b.privileges === '1',
319
+ });
320
+ await logActivity({ username: x.s.username, event: 'schema_import', ip: clientIp(req), detail: `job ${x.j.id} into ${target}: job ${id}` });
321
+ return reply.redirect(jobUrl(id), 303);
322
+ } catch (e) {
323
+ if (!(e instanceof DumpError)) throw e;
324
+ return importForm(reply, x.s, x.j, b, [e.message]);
325
+ }
326
+ });
327
+
328
+ app.get(`${URL_BASE}/job/:id/download`, async (req: Req, reply) => {
329
+ const x = await withJob(req, reply);
330
+ if (!x) return;
331
+ const j = x.j;
332
+ if (!j.file_live || j.kind !== 'export' || j.status !== 'succeeded') return reply.code(404).send('Not found');
333
+ const plain = j.options.format === 'plain';
334
+ const name = `${(j.schema_name ?? 'schema').replace(/[^A-Za-z0-9_-]/g, '_')}-${j.created_at.slice(0, 10)}.${plain ? 'sql' : 'dump'}`;
335
+ await logActivity({ username: x.s.username, event: 'schema_download', ip: clientIp(req), detail: `job ${j.id}` });
336
+ return reply
337
+ .header('content-type', plain ? 'application/sql; charset=utf-8' : 'application/octet-stream')
338
+ .header('content-disposition', `attachment; filename="${name}"`)
339
+ .header('cache-control', 'no-store')
340
+ .header('content-length', String(j.bytes ?? ''))
341
+ .send(readJobFile(j));
342
+ });
343
+
344
+ app.post(`${URL_BASE}/job/:id/cancel`, async (req: Req, reply) => {
345
+ const x = await withJob(req, reply);
346
+ if (!x) return;
347
+ flash(x.s, cancelJob(x.j.id) ? 'Cancelling the job.' : 'The job doesn\'t run on this server (any more).', 'ok');
348
+ return back(reply, x.s, jobUrl(x.j.id));
349
+ });
350
+
351
+ app.post(`${URL_BASE}/job/:id/delete`, async (req: Req, reply) => {
352
+ const x = await withJob(req, reply);
353
+ if (!x) return;
354
+ try {
355
+ await deleteJob(x.j);
356
+ } catch (e) {
357
+ if (!(e instanceof DumpError)) throw e;
358
+ flash(x.s, e.message, 'error');
359
+ return back(reply, x.s, jobUrl(x.j.id));
360
+ }
361
+ flash(x.s, `Job #${x.j.id} deleted.`);
362
+ return back(reply, x.s, URL_BASE);
363
+ });
364
+ }
@@ -5,12 +5,14 @@ import { changeScript, compare, ddlScript, OBJECT_TYPES, pick, readSchema, userS
5
5
  import { html, raw, type Raw } from '../html.ts';
6
6
  import { icon } from '../icons.ts';
7
7
  import { clientIp, logActivity } from '../session.ts';
8
- import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
8
+ import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
+ import type { Session } from '../session.ts';
9
10
  import { workshopDb } from './workspaces.ts';
10
11
 
11
12
  // SQL Workshop → Generate DDL and Schema Comparison (APEX: SQL Workshop →
12
13
  // Utilities). Both only read the catalog (src/ddl.ts); the scripts they
13
- // write are shown and downloaded, never run.
14
+ // write are shown and downloaded. (sprint 106) Compare's Apply and record
15
+ // runs the change script and records it (changelog.ts).
14
16
 
15
17
  const TYPE_LABELS: Record<(typeof OBJECT_TYPES)[number], string> = {
16
18
  types: 'Types and domains', sequences: 'Sequences', tables: 'Tables (constraints, indexes)', views: 'Views and materialized views',
@@ -29,6 +31,12 @@ const diffBlock = (d: Difference, source: string, target: string) => {
29
31
  return html`<pre class="diff">${text.trimEnd().split('\n').map((l, i) => html`${i ? '\n' : ''}<span class="${l.startsWith('+') && !l.startsWith('+++') ? 'diff-add' : l.startsWith('-') && !l.startsWith('---') ? 'diff-del' : null}">${l}</span>`)}</pre>`;
30
32
  };
31
33
 
34
+ /** (sprint 106, DA-17) Apply the change script in one transaction and record it in the change log (changelog.ts). */
35
+ const applyForm = (s: Session, source: string, target: string) =>
36
+ html`<form method="post" action="${BASE}/sql/compare/apply" class="u-mt1">${csrf(s)}<input type="hidden" name="source" value="${source}"><input type="hidden" name="target" value="${target}">
37
+ <button class="btn btn-hot" data-confirm="Run the change script on ${target} in one transaction?">${icon('play')} Apply and record</button>
38
+ <span class="muted small">Runs the script on ${target} (commented-out drops stay out) and records it in the <a href="${BASE}/sql/changes?schema=${encodeURIComponent(target)}">change log</a>.</span></form>`;
39
+
32
40
  export async function ddlToolRoutes(app: FastifyInstance) {
33
41
  app.get(`${BASE}/sql/ddl`, async (req: Req, reply) => {
34
42
  const s = await developer(req, reply);
@@ -98,9 +106,10 @@ export async function ddlToolRoutes(app: FastifyInstance) {
98
106
  html`<ul class="compare-list">${list.map((d) => html`<li><details><summary><span class="tag">${d.kind}</span> ${d.name}</summary>
99
107
  ${st === 'different' ? diffBlock(d, source, target) : html`<pre class="diff">${d.source ?? d.target}</pre>`}</details></li>`)}</ul>`)}`)}
100
108
  <div class="u-spacer"></div>
101
- ${region(`Change script: make ${target} like ${source}`, html`<p class="muted">Creates what only ${source} has, changes what differs, and lists what only ${target} has as commented-out drops; columns that change identity or generation, and types, domains and sequences that differ, are left for review. Nothing is run here.</p>
109
+ ${region(`Change script: make ${target} like ${source}`, html`<p class="muted">Creates what only ${source} has, changes what differs, and lists what only ${target} has as commented-out drops; columns that change identity or generation, and types, domains and sequences that differ, are left for review. Nothing runs until you choose Apply and record.</p>
102
110
  <textarea class="code" rows="20" spellcheck="false" readonly aria-label="Change script" data-code="plpgsql">${script}</textarea>`,
103
- html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} Download</a>`)}`;
111
+ html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} Download</a>`)}
112
+ ${differences.length ? applyForm(s, source, target) : ''}`;
104
113
  } catch (e) {
105
114
  result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
106
115
  }
@@ -6,7 +6,7 @@ import { documentShell } from '../layout.ts';
6
6
  import { senderAllowed, sendWith, type MailService } from '../mail/mail.ts';
7
7
  import { clientIp, getSession, logActivity, saveState, takeFlash, type Session } from '../session.ts';
8
8
  import { publicUrl } from '../sso.ts';
9
- import { back, BASE, builderHead, csrf, flash, THEME_COOKIE, validTheme, type Req } from './ui.ts';
9
+ import { back, BASE, brandMark, builderHead, csrf, flash, THEME_COOKIE, validTheme, type Req } from './ui.ts';
10
10
 
11
11
  // "Find my workspace" on the builder's sign-in page (migration 096; APEX: Find
12
12
  // My Workspace): a developer who forgot which workspace to sign in to enters
@@ -50,7 +50,7 @@ const page = (s: Session, theme: ReturnType<typeof validTheme>, body: Raw) =>
50
50
  'Find my workspace · Builder',
51
51
  html`<main class="signin">
52
52
  <section class="signin-hero" aria-label="pgkiln">
53
- <span class="signin-logo" aria-hidden="true">pg</span>
53
+ <span class="signin-logo" aria-hidden="true">${brandMark()}</span>
54
54
  <h1 class="signin-title">pgkiln</h1>
55
55
  <p class="signin-tagline">Build data applications on PostgreSQL, in SQL you can trust</p>
56
56
  </section>
@@ -0,0 +1,25 @@
1
+ import { html, raw, type Raw } from '../html.ts';
2
+ import type { Plan, PlanNode } from '../sqlplan.ts';
3
+
4
+ // (sprint 92, DA-4; shared since sprint 102) A query plan (src/sqlplan.ts) drawn as a tree: the SQL Worksheet's
5
+ // Explain Plan and Autotrace tabs and the Database Monitor's statement page. Styles: .ws-plan-* in worksheet.css.
6
+
7
+ const fmtMs = (n: number) => (n >= 100 ? n.toFixed(0) : n >= 1 ? n.toFixed(1) : n.toFixed(3));
8
+
9
+ /** (sprint 92, DA-4) A plan as a tree of steps (open), with the text plan behind a toggle. */
10
+ export function planView(plan: Plan, text: string): Raw {
11
+ const step = (n: PlanNode): Raw => html`<li><details open${n.slowest ? raw(' class="is-slowest"') : ''}>
12
+ <summary><span class="ws-plan-op">${n.op}</span>${n.target ? html` <span class="ws-plan-target">${n.target}</span>` : ''}
13
+ <span class="ws-plan-nums">cost ${n.startupCost.toFixed(2)}..${n.totalCost.toFixed(2)} · rows ${n.planRows} · width ${n.width}${plan.analyze
14
+ ? n.neverRun ? html` · <em>never executed</em>` : html` · <strong>${fmtMs(n.actualTotal ?? 0)} ms</strong> × ${n.loops} · ${n.actualRows} row${n.actualRows === 1 ? '' : 's'}${n.selfMs !== undefined ? ` · self ${fmtMs(n.selfMs)} ms` : ''}${n.sharedHit || n.sharedRead ? ` · buffers ${n.sharedHit ?? 0} hit${n.sharedRead ? `, ${n.sharedRead} read` : ''}` : ''}`
15
+ : ''}</span>
16
+ ${n.slowest ? html`<span class="tag tag-warn">slowest step</span>` : ''}
17
+ ${n.misestimate ? html`<span class="tag tag-warn" title="Estimated ${n.planRows} rows, got ${n.actualRows}: ANALYZE the table, or look at the statistics">estimate ${n.misestimate >= 1 ? `${Math.round(n.misestimate)}× too high` : `${Math.round(1 / n.misestimate)}× too low`}</span>` : ''}
18
+ </summary>
19
+ ${n.details.length ? html`<dl class="ws-plan-details">${n.details.map(([k, v]) => html`<dt>${k}</dt><dd><code>${v}</code></dd>`)}</dl>` : ''}
20
+ ${n.children.length ? html`<ul class="ws-plan-tree">${n.children.map(step)}</ul>` : ''}
21
+ </details></li>`;
22
+ return html`${plan.planningMs !== undefined || plan.executionMs !== undefined ? html`<p class="muted small ws-info">${plan.planningMs !== undefined ? `Planning ${fmtMs(plan.planningMs)} ms` : ''}${plan.executionMs !== undefined ? ` · execution ${fmtMs(plan.executionMs)} ms (rolled back)` : ''}</p>` : ''}
23
+ <ul class="ws-plan-tree ws-plan-root">${step(plan.root)}</ul>
24
+ <details class="ws-plan-text"><summary>Text plan</summary><pre class="ws-log ws-plan">${text}</pre></details>`;
25
+ }