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,397 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { parsePlan, planText, type Plan } from '../sqlplan.ts';
6
+ import { splitScript } from '../sqlscript.ts';
7
+ import type { Session } from '../session.ts';
8
+ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
+ import { isAdmin } from './locks.ts';
10
+ import { planView } from './plan-view.ts';
11
+ import { statementsNote, statementsState } from './top-sql.ts';
12
+
13
+ // (sprint 102, Database Actions DA-14) SQL Workshop › SQL Monitor, for administrators: Database Actions' Real Time
14
+ // SQL Monitor and Performance Hub on PostgreSQL's statistics views.
15
+ // - Sessions (GET /builder/sql/monitor): the client sessions of this database (pg_stat_activity) with what they
16
+ // run, wait for and who blocks them, a summary of wait events and the maintenance commands in progress
17
+ // (pg_stat_progress_*). public/sql-monitor.js reloads the live part every 5, 15 or 60 seconds (?fragment=1),
18
+ // with a pause button; without JavaScript, Refresh reloads the page. A row's Cancel / Terminate post to
19
+ // /builder/sql/monitor/session (pg_cancel_backend / pg_terminate_backend), after a confirmation.
20
+ // - Top SQL (GET /builder/sql/monitor/top): pg_stat_statements for the whole database, sortable by any column,
21
+ // total and mean time as bars; Reset statistics posts to /builder/sql/monitor/reset.
22
+ // - A statement (GET /builder/sql/monitor/statements/:queryid): its text, its totals per role, an Explain Plan
23
+ // (EXPLAIN only, never ANALYZE: it is someone else's statement; a text with $1 parameters gets a generic plan,
24
+ // PostgreSQL 16+), and Open in worksheet.
25
+ // Every read runs read only with a statement timeout, as the owner connection.
26
+
27
+ const URL_BASE = `${BASE}/sql/monitor`;
28
+ export const REFRESH_SECONDS = [5, 15, 60] as const;
29
+
30
+ type Row = Record<string, unknown>;
31
+
32
+ async function readOnly<T>(fn: (q: (sql: string, params?: unknown[]) => Promise<Row[]>) => Promise<T>): Promise<T> {
33
+ return owner.tx(async (c) => {
34
+ await c.query('set transaction read only');
35
+ await c.query(`set local statement_timeout = '10s'`);
36
+ return fn(async (sql, params) => (await c.query(sql, params)).rows);
37
+ });
38
+ }
39
+
40
+ const crumbs = (...more: [string, string?][]): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], ['SQL Monitor', more.length ? URL_BASE : undefined], ...more];
41
+ const num = (v: unknown) => (v == null ? '' : Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 }));
42
+ const QUERYID_RE = /^-?\d{1,20}$/;
43
+
44
+ /** A bar (an SVG: the CSP allows no style attributes) for `value` out of `max`, next to the number. */
45
+ const bar = (value: number, max: number) =>
46
+ html`<svg class="sm-bar" viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="sm-bar-track" width="100" height="8"></rect><rect class="sm-bar-fill" width="${max > 0 ? Math.max(1, Math.round((value / max) * 100)) : 0}" height="8"></rect></svg>`;
47
+
48
+ const viewTabs = (active: 'sessions' | 'top') =>
49
+ html`<nav class="seg u-mb1" aria-label="SQL Monitor views">
50
+ <a href="${URL_BASE}"${active === 'sessions' ? raw(' aria-current="page"') : ''}>Sessions</a>
51
+ <a href="${URL_BASE}/top"${active === 'top' ? raw(' aria-current="page"') : ''}>Top SQL</a></nav>`;
52
+
53
+ // ------------------------------------------------------------------ sessions
54
+
55
+ async function liveData(showIdle: boolean) {
56
+ return readOnly(async (q) => ({
57
+ sessions: await q(
58
+ `select a.pid, a.usename as "user", a.application_name as app, a.client_addr::text as client, a.state,
59
+ a.wait_event_type as wait_type, a.wait_event as wait, a.query_id::text as queryid,
60
+ case when a.state = 'active' then round(extract(epoch from now() - a.query_start)::numeric, 1) end as running_s,
61
+ case when a.state like 'idle in transaction%' then round(extract(epoch from now() - a.state_change)::numeric, 1) end as idle_tx_s,
62
+ round(extract(epoch from now() - a.xact_start)::numeric, 1) as tx_s,
63
+ pg_blocking_pids(a.pid) as blocked_by, left(a.query, 2000) as query
64
+ from pg_stat_activity a
65
+ where a.datname = current_database() and a.backend_type = 'client backend' and a.pid <> pg_backend_pid()
66
+ and ($1 or a.state is distinct from 'idle')
67
+ order by a.state = 'active' desc, a.query_start nulls last limit 200`, [showIdle]),
68
+ totals: (await q(
69
+ `select count(*)::int as sessions, count(*) filter (where state = 'active')::int as active,
70
+ count(*) filter (where state like 'idle in transaction%')::int as idle_tx,
71
+ count(*) filter (where cardinality(pg_blocking_pids(pid)) > 0)::int as blocked,
72
+ coalesce(max(extract(epoch from now() - query_start)) filter (where state = 'active'), 0)::numeric(12, 1)::float8 as longest,
73
+ (select setting::int from pg_settings where name = 'max_connections') as max
74
+ from pg_stat_activity where datname = current_database() and backend_type = 'client backend' and pid <> pg_backend_pid()`))[0],
75
+ waits: await q(
76
+ `select coalesce(wait_event_type, 'CPU') as type, coalesce(wait_event, 'running') as event, count(*)::int as sessions
77
+ from pg_stat_activity
78
+ where state = 'active' and pid <> pg_backend_pid() and (datname = current_database() or datname is null)
79
+ group by 1, 2 order by 3 desc, 1, 2`),
80
+ progress: await q(
81
+ `select pid, 'VACUUM' as command, relid::regclass::text as relation, phase,
82
+ case when heap_blks_total > 0 then round(100.0 * heap_blks_scanned / heap_blks_total) end::int as pct
83
+ from pg_stat_progress_vacuum where datname = current_database()
84
+ union all
85
+ select pid, 'ANALYZE', relid::regclass::text, phase,
86
+ case when sample_blks_total > 0 then round(100.0 * sample_blks_scanned / sample_blks_total) end::int
87
+ from pg_stat_progress_analyze where datname = current_database()
88
+ union all
89
+ select pid, command, coalesce(index_relid::regclass::text, relid::regclass::text), phase,
90
+ case when blocks_total > 0 then round(100.0 * blocks_done / blocks_total)
91
+ when tuples_total > 0 then round(100.0 * tuples_done / tuples_total) end::int
92
+ from pg_stat_progress_create_index where datname = current_database()
93
+ union all
94
+ select pid, command, relid::regclass::text, phase,
95
+ case when heap_blks_total > 0 then round(100.0 * heap_blks_scanned / heap_blks_total) end::int
96
+ from pg_stat_progress_cluster where datname = current_database()
97
+ union all
98
+ select pid, command || ' ' || type, relid::regclass::text, tuples_processed || ' rows',
99
+ case when bytes_total > 0 then round(100.0 * bytes_processed / bytes_total) end::int
100
+ from pg_stat_progress_copy where datname = current_database()
101
+ order by pid`),
102
+ }));
103
+ }
104
+
105
+ /** The part of the Sessions view that public/sql-monitor.js reloads in place. */
106
+ async function livePart(s: Session, showIdle: boolean): Promise<Raw> {
107
+ const d = await liveData(showIdle);
108
+ const t = d.totals as Record<string, number>;
109
+ const backTo = `${URL_BASE}${showIdle ? '?idle=1' : ''}`;
110
+ const action = (pid: unknown, kind: 'cancel' | 'terminate') => html`<form method="post" action="${URL_BASE}/session" class="sm-action">${csrf(s)}
111
+ <input type="hidden" name="pid" value="${String(pid)}"><input type="hidden" name="action" value="${kind}"><input type="hidden" name="idle" value="${showIdle ? '1' : ''}">
112
+ <button class="btn btn-sm${kind === 'terminate' ? ' btn-danger' : ''}" data-confirm="${kind === 'cancel' ? `Cancel the statement session ${pid} is running?` : `Terminate session ${pid}? Its open transaction is rolled back and the client is disconnected.`}"
113
+ aria-label="${kind === 'cancel' ? 'Cancel the statement of' : 'Terminate'} session ${String(pid)}">${kind === 'cancel' ? 'Cancel' : 'Terminate'}</button></form>`;
114
+ return html`<div class="stat-grid u-mb1">
115
+ ${([['Sessions', `${t.sessions} / ${t.max}`], ['Active', t.active], ['Idle in a transaction', t.idle_tx], ['Blocked', t.blocked], ['Longest running (s)', t.longest]] as [string, unknown][])
116
+ .map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
117
+ </div>
118
+ ${region('Sessions', html`<p class="muted u-mt0">The client sessions of this database (pg_stat_activity)${showIdle ? '' : ', without idle ones'}, active ones first.
119
+ <a href="${showIdle ? URL_BASE : `${URL_BASE}?idle=1`}">${showIdle ? 'Hide idle sessions' : 'Show idle sessions'}</a>.
120
+ A query's text is shown only for roles whose statistics the owner may read (<code>pg_read_all_stats</code>).</p>
121
+ <div class="table-wrap"><table class="report report-reflow sm-sessions">
122
+ <thead><tr><th class="num">PID</th><th>User</th><th>Application</th><th>State</th><th>Wait</th><th class="num">Running (s)</th><th>Blocked by</th><th>Query</th><th>Actions</th></tr></thead>
123
+ <tbody>${d.sessions.length ? d.sessions.map((x) => html`<tr${(x.blocked_by as number[]).length ? raw(' class="is-blocked"') : ''}>
124
+ <td class="num" data-label="PID">${String(x.pid)}</td>
125
+ <td data-label="User">${String(x.user ?? '')}</td>
126
+ <td data-label="Application" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
127
+ <td data-label="State">${x.idle_tx_s != null ? html`<span class="tag tag-warning">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
128
+ <td data-label="Wait">${x.wait_type ? `${x.wait_type}: ${x.wait ?? ''}` : ''}</td>
129
+ <td class="num" data-label="Running">${x.running_s != null ? String(x.running_s) : ''}</td>
130
+ <td data-label="Blocked by">${(x.blocked_by as number[]).length ? html`<span class="tag tag-warning">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
131
+ <td data-label="Query" class="u-break">${x.query ? x.queryid && x.queryid !== '0'
132
+ ? html`<a href="${URL_BASE}/statements/${String(x.queryid)}" title="Statistics and plan of this statement"><code>${String(x.query).slice(0, 500)}</code></a>`
133
+ : html`<code>${String(x.query).slice(0, 500)}</code>` : ''}</td>
134
+ <td data-label="Actions"><div class="sm-actions">${x.state === 'active' ? action(x.pid, 'cancel') : ''}${action(x.pid, 'terminate')}</div></td></tr>`)
135
+ : html`<tr><td colspan="9" class="empty">No ${showIdle ? 'other' : 'busy'} sessions.</td></tr>`}</tbody></table></div>`)}
136
+ ${region('Wait events', html`<p class="muted u-mt0">What the active sessions of the server wait for now (<em>CPU: running</em> when they don't wait).</p>
137
+ ${d.waits.length ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Type</th><th>Event</th><th class="num">Sessions</th></tr></thead>
138
+ <tbody>${d.waits.map((w) => html`<tr><td data-label="Type">${String(w.type)}</td><td data-label="Event">${String(w.event)}</td><td class="num" data-label="Sessions">${String(w.sessions)}</td></tr>`)}</tbody></table></div>`
139
+ : html`<p class="muted">No other active sessions.</p>`}`)}
140
+ ${d.progress.length ? region('In progress', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">PID</th><th>Command</th><th>Object</th><th>Phase</th><th>Done</th></tr></thead>
141
+ <tbody>${d.progress.map((p) => html`<tr><td class="num" data-label="PID">${String(p.pid)}</td><td data-label="Command">${String(p.command)}</td>
142
+ <td data-label="Object" class="u-break">${String(p.relation ?? '')}</td><td data-label="Phase">${String(p.phase ?? '')}</td>
143
+ <td data-label="Done">${p.pct != null ? html`<progress max="100" value="${String(p.pct)}" aria-label="${String(p.command)} ${String(p.pid)}"></progress> ${String(p.pct)}%` : ''}</td></tr>`)}</tbody></table></div>`) : ''}
144
+ <p class="muted small sm-updated" data-sm-updated>Updated ${new Date().toISOString().slice(11, 19)} UTC · <a href="${backTo}">Refresh</a></p>`;
145
+ }
146
+
147
+ // ------------------------------------------------------------------ Top SQL
148
+
149
+ const SORTS: Record<string, { col: string; label: string; asc?: boolean }> = {
150
+ role: { col: 'r.rolname', label: 'Role', asc: true },
151
+ calls: { col: 's.calls', label: 'Calls' },
152
+ total: { col: 's.total_exec_time', label: 'Total ms' },
153
+ mean: { col: 's.mean_exec_time', label: 'Mean ms' },
154
+ rows: { col: 's.rows', label: 'Rows' },
155
+ hit: { col: 's.shared_blks_hit', label: 'Blocks hit' },
156
+ read: { col: 's.shared_blks_read', label: 'Blocks read' },
157
+ query: { col: 's.query', label: 'Statement', asc: true },
158
+ };
159
+
160
+ export interface TopRow { queryid: string | null; role: string | null; calls: number; total_ms: number; mean_ms: number; rows: number; hit: number; read: number; query: string }
161
+
162
+ /** pg_stat_statements of this database, by `sort` (a SORTS key), optionally filtered on the text. */
163
+ export async function topStatements(sort: string, desc: boolean, filter: string, limit = 100): Promise<TopRow[]> {
164
+ const by = SORTS[sort] ?? SORTS.total;
165
+ return (await readOnly((q) => q(
166
+ `select s.queryid::text as queryid, r.rolname as role, s.calls::float8 as calls, round(s.total_exec_time::numeric, 2)::float8 as total_ms,
167
+ round(s.mean_exec_time::numeric, 3)::float8 as mean_ms, s.rows::float8 as rows, s.shared_blks_hit::float8 as hit,
168
+ s.shared_blks_read::float8 as read, left(s.query, 1000) as query
169
+ from pg_stat_statements s left join pg_roles r on r.oid = s.userid
170
+ where s.dbid = (select oid from pg_database where datname = current_database())
171
+ and ($1 = '' or s.query ilike '%' || replace(replace(replace($1, '\\', '\\\\'), '%', '\\%'), '_', '\\_') || '%')
172
+ order by ${by.col} ${desc ? 'desc' : 'asc'} nulls last, s.queryid limit $2`, [filter, limit]))) as unknown as TopRow[];
173
+ }
174
+
175
+ // ------------------------------------------------------------------ Explain
176
+
177
+ const EXPLAINABLE = /^(select|with|insert|update|delete|merge|values|table)\b/i;
178
+
179
+ export type MonitorPlan = { ok: true; plan: Plan; text: string; generic: boolean } | { ok: false; error: string };
180
+
181
+ /**
182
+ * EXPLAIN (never ANALYZE) of a statement someone else ran: one statement only, of a kind that has a plan, in a
183
+ * read-only transaction that is rolled back, with timeouts. A normalised text with $1… gets a generic plan
184
+ * (PostgreSQL 16+). `schema` is put first in the search path, as unqualified names were resolved in the original.
185
+ */
186
+ export async function explainForMonitor(sql: string, schema: string | null): Promise<MonitorPlan> {
187
+ const stmts = splitScript(sql);
188
+ if (stmts.length !== 1 || stmts[0].psql) return { ok: false, error: 'Only one SQL statement can be explained.' };
189
+ const text = stmts[0].sql;
190
+ if (!EXPLAINABLE.test(text)) return { ok: false, error: 'Only SELECT, INSERT, UPDATE, DELETE, MERGE and VALUES statements have a plan.' };
191
+ const generic = /\$\d+/.test(text);
192
+ const c = await owner.pool.connect();
193
+ try {
194
+ if (generic && Number((await c.query(`select current_setting('server_version_num') as v`)).rows[0].v) < 160000)
195
+ return { ok: false, error: 'The statement has parameters ($1, …): PostgreSQL 16 or later can explain it with a generic plan. Open it in the worksheet and fill in values.' };
196
+ await c.query('begin transaction read only');
197
+ await c.query(`set local statement_timeout = '5s'`);
198
+ await c.query(`set local lock_timeout = '1s'`);
199
+ if (schema) await c.query(`select set_config('search_path', format('%I, public', $1::text), true)`, [schema]);
200
+ // the extended protocol (Parse/Bind): the server accepts one statement only, whatever the text hides; a generic
201
+ // plan's parameters are bound to nulls, which it doesn't look at
202
+ const params = Math.max(0, ...[...text.matchAll(/\$(\d+)/g)].map((m) => Number(m[1])));
203
+ if (params > 100) return { ok: false, error: 'The statement has too many parameters to explain.' };
204
+ const out = await c.query({ text: `explain (format json, verbose, costs${generic ? ', generic_plan' : ''}) ${text}`, values: Array(params).fill(null), queryMode: 'extended' } as never);
205
+ const json = (out as { rows: Row[] }).rows[0]?.['QUERY PLAN'];
206
+ const plan = parsePlan(typeof json === 'string' ? JSON.parse(json) : json, false);
207
+ return { ok: true, plan, text: planText(plan), generic };
208
+ } catch (e) {
209
+ const err = e as { message: string; code?: string };
210
+ return { ok: false, error: `${err.message}${err.code ? ` (${err.code})` : ''}` };
211
+ } finally {
212
+ await c.query('rollback').catch(() => {});
213
+ c.release();
214
+ }
215
+ }
216
+
217
+ // ------------------------------------------------------------------ routes
218
+
219
+ /** The administrator's session, or a 403 (anonymous: the sign-in redirect). */
220
+ async function admin(req: Req, reply: Parameters<typeof developer>[1]) {
221
+ const s = await developer(req, reply);
222
+ if (!s) return null;
223
+ if (!(await isAdmin(s.username))) {
224
+ reply.code(403).send('Administrators only.');
225
+ return null;
226
+ }
227
+ return s;
228
+ }
229
+
230
+ const statementOf = async (queryid: string) => {
231
+ const rows = await readOnly((q) => q(
232
+ `select r.rolname as role, s.toplevel, s.calls::float8 as calls, s.total_exec_time::float8 as total_ms, s.mean_exec_time::float8 as mean_ms,
233
+ s.stddev_exec_time::float8 as stddev_ms, s.min_exec_time::float8 as min_ms, s.max_exec_time::float8 as max_ms, s.rows::float8 as rows,
234
+ s.shared_blks_hit::float8 as hit, s.shared_blks_read::float8 as read, s.temp_blks_read::float8 as temp_read,
235
+ s.temp_blks_written::float8 as temp_written, s.query
236
+ from pg_stat_statements s left join pg_roles r on r.oid = s.userid
237
+ where s.queryid = $1::bigint and s.dbid = (select oid from pg_database where datname = current_database())
238
+ order by s.total_exec_time desc`, [queryid]));
239
+ return rows;
240
+ };
241
+
242
+ export async function sqlMonitorRoutes(app: FastifyInstance) {
243
+ app.get(URL_BASE, async (req: Req, reply) => {
244
+ const s = await admin(req, reply);
245
+ if (!s) return;
246
+ const showIdle = req.query?.idle === '1';
247
+ const live = await livePart(s, showIdle);
248
+ if (req.query?.fragment === '1') return reply.header('cache-control', 'no-store').type('text/html').send(String(live));
249
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('monitor')}${viewTabs('sessions')}
250
+ <div class="sm-toolbar" data-sm-controls hidden>
251
+ <label for="sm-interval">Refresh every</label>
252
+ <select id="sm-interval" data-sm-interval>${REFRESH_SECONDS.map((n) => html`<option value="${n}">${n} seconds</option>`)}</select>
253
+ <button type="button" class="btn btn-sm" data-sm-pause aria-pressed="false">Pause</button>
254
+ </div>
255
+ <div class="sm-live" data-sm-live="${URL_BASE}?${showIdle ? 'idle=1&' : ''}fragment=1" aria-live="off">${live}</div>`;
256
+ return send(reply, s, shell(s, 'SQL Monitor', crumbs(), main, 'sql'));
257
+ });
258
+
259
+ app.post(`${URL_BASE}/session`, async (req: Req, reply) => {
260
+ const s = await admin(req, reply);
261
+ if (!s) return;
262
+ const b = (req.body ?? {}) as Record<string, string>;
263
+ const to = `${URL_BASE}${b.idle === '1' ? '?idle=1' : ''}`;
264
+ if (!/^\d{1,9}$/.test(String(b.pid ?? '')) || !['cancel', 'terminate'].includes(String(b.action))) return reply.code(400).send('Bad request');
265
+ // only a client session of this database, and not the connection doing it
266
+ const target = await owner.one<{ pid: number }>(
267
+ `select pid from pg_stat_activity where pid = $1 and datname = current_database() and backend_type = 'client backend' and pid <> pg_backend_pid()`, [Number(b.pid)]);
268
+ if (!target) {
269
+ flash(s, `Session ${b.pid} isn't a client session of this database (any more).`, 'error');
270
+ return back(reply, s, to);
271
+ }
272
+ try {
273
+ const fn = b.action === 'cancel' ? 'pg_cancel_backend' : 'pg_terminate_backend';
274
+ const r = await owner.one<{ ok: boolean }>(`select ${fn}($1) as ok`, [target.pid]);
275
+ if (r?.ok) flash(s, b.action === 'cancel' ? `Asked session ${target.pid} to cancel its statement.` : `Terminated session ${target.pid}.`);
276
+ else flash(s, `Session ${target.pid} could not be signalled.`, 'error');
277
+ } catch (e) {
278
+ flash(s, (e as Error).message, 'error');
279
+ }
280
+ return back(reply, s, to);
281
+ });
282
+
283
+ app.get(`${URL_BASE}/top`, async (req: Req, reply) => {
284
+ const s = await admin(req, reply);
285
+ if (!s) return;
286
+ const q = req.query ?? {};
287
+ const sort = SORTS[q.sort ?? ''] ? q.sort! : 'total';
288
+ const desc = q.dir ? q.dir !== 'asc' : !SORTS[sort].asc;
289
+ const filter = String(q.q ?? '').slice(0, 200);
290
+ const state = await statementsState();
291
+ let body: Raw;
292
+ if (!state.ok) body = statementsNote(state.reason, state.message);
293
+ else {
294
+ const [rows, info] = await Promise.all([
295
+ topStatements(sort, desc, filter),
296
+ readOnly((r) => r(`select to_char(stats_reset, 'YYYY-MM-DD HH24:MI') as reset, dealloc from pg_stat_statements_info`)).catch(() => [] as Row[]),
297
+ ]);
298
+ const maxTotal = Math.max(0, ...rows.map((r) => r.total_ms));
299
+ const maxMean = Math.max(0, ...rows.map((r) => r.mean_ms));
300
+ const link = (key: string) => {
301
+ const on = key === sort;
302
+ const nextDesc = on ? !desc : !SORTS[key].asc;
303
+ const params = new URLSearchParams({ sort: key, dir: nextDesc ? 'desc' : 'asc', ...(filter ? { q: filter } : {}) });
304
+ return html`<a href="${URL_BASE}/top?${params}" title="Sort by ${SORTS[key].label.toLowerCase()}">${SORTS[key].label}${on ? (desc ? ' ▼' : ' ▲') : ''}</a>`;
305
+ };
306
+ const th = (key: string, numeric = true) => html`<th${numeric ? raw(' class="num"') : ''}${key === sort ? raw(` aria-sort="${desc ? 'descending' : 'ascending'}"`) : ''}>${link(key)}</th>`;
307
+ const i = info[0] as Row | undefined;
308
+ body = html`<form method="get" action="${URL_BASE}/top" class="sm-filter u-mb1" role="search">
309
+ <input type="hidden" name="sort" value="${sort}"><input type="hidden" name="dir" value="${desc ? 'desc' : 'asc'}">
310
+ <label class="sr-only" for="sm-q">Statement contains</label><input id="sm-q" name="q" type="search" value="${filter}" maxlength="200" placeholder="Statement contains…">
311
+ <button class="btn btn-sm">${icon('search')} Search</button></form>
312
+ <div class="table-wrap"><table class="report report-reflow sm-top"><thead><tr>${th('role', false)}${th('calls')}${th('total')}${th('mean')}${th('rows')}${th('hit')}${th('read')}${th('query', false)}</tr></thead>
313
+ <tbody>${rows.length ? rows.map((r) => html`<tr>
314
+ <td data-label="Role">${r.role ?? ''}</td>
315
+ <td class="num" data-label="Calls">${num(r.calls)}</td>
316
+ <td class="num sm-barcell" data-label="Total ms">${bar(r.total_ms, maxTotal)}<span>${num(r.total_ms)}</span></td>
317
+ <td class="num sm-barcell" data-label="Mean ms">${bar(r.mean_ms, maxMean)}<span>${num(r.mean_ms)}</span></td>
318
+ <td class="num" data-label="Rows">${num(r.rows)}</td>
319
+ <td class="num" data-label="Blocks hit">${num(r.hit)}</td>
320
+ <td class="num" data-label="Blocks read">${num(r.read)}</td>
321
+ <td data-label="Statement" class="u-break">${r.queryid ? html`<a href="${URL_BASE}/statements/${r.queryid}"><code class="sql-text">${r.query}</code></a>` : html`<code class="sql-text">${r.query}</code>`}</td></tr>`)
322
+ : html`<tr><td colspan="8" class="empty">${filter ? 'No statements match.' : 'No statements recorded yet.'}</td></tr>`}</tbody></table></div>
323
+ <p class="muted small">${i?.reset ? `Statistics since ${String(i.reset)}. ` : ''}At most 100 statements are shown; literals and binds show as <code>$1</code>, <code>$2</code>….</p>
324
+ <form method="post" action="${URL_BASE}/reset">${csrf(s)}
325
+ <button class="btn" data-confirm="Clear the statement statistics of the whole server?">${icon('history')} Reset statistics</button></form>`;
326
+ }
327
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('monitor')}${viewTabs('top')}
328
+ ${region('Top SQL', html`<p class="muted u-mt0">The statements of this database from <code>pg_stat_statements</code>, every role. Click a statement for its plan.</p>${body}`)}`;
329
+ return send(reply, s, shell(s, 'Top SQL', crumbs(['Top SQL']), main, 'sql'));
330
+ });
331
+
332
+ app.post(`${URL_BASE}/reset`, async (req: Req, reply) => {
333
+ const s = await admin(req, reply);
334
+ if (!s) return;
335
+ try {
336
+ await owner.query('select pg_stat_statements_reset()');
337
+ flash(s, 'Statement statistics reset.');
338
+ } catch (e) {
339
+ flash(s, (e as Error).message, 'error');
340
+ }
341
+ return back(reply, s, `${URL_BASE}/top`);
342
+ });
343
+
344
+ app.get(`${URL_BASE}/statements/:queryid`, async (req: Req, reply) => {
345
+ const s = await admin(req, reply);
346
+ if (!s) return;
347
+ const queryid = String(req.params.queryid);
348
+ if (!QUERYID_RE.test(queryid)) return reply.code(404).send('Not found');
349
+ const state = await statementsState();
350
+ if (!state.ok) {
351
+ const main = html`<h1 class="u-mb1">Statement</h1>${statementsNote(state.reason, state.message)}`;
352
+ return send(reply, s, shell(s, 'Statement', crumbs(['Statement']), main, 'sql'));
353
+ }
354
+ let rows: Row[];
355
+ try {
356
+ rows = await statementOf(queryid);
357
+ } catch {
358
+ return reply.code(404).send('Not found');
359
+ }
360
+ if (!rows.length) return reply.code(404).send('Not found');
361
+ const sql = String(rows[0].query ?? '');
362
+ const schemas = (await readOnly((q) => q(`select nspname from pg_namespace where nspname !~ '^pg_' and nspname <> 'information_schema' order by 1`))).map((r) => String(r.nspname));
363
+ const schema = typeof req.query?.schema === 'string' && schemas.includes(req.query.schema) ? req.query.schema : '';
364
+ const explained = req.query?.explain === '1' ? await explainForMonitor(sql, schema || null) : null;
365
+ const sum = (k: string) => rows.reduce((a, r) => a + Number(r[k] ?? 0), 0);
366
+ const calls = sum('calls');
367
+ const total = sum('total_ms');
368
+ const tile = (label: string, v: string) => html`<div class="stat"><b>${v}</b><span>${label}</span></div>`;
369
+ const main = html`<h1 class="u-mb1">Statement</h1>
370
+ <div class="stat-grid u-mb1">${tile('Calls', num(calls))}${tile('Total ms', num(total))}${tile('Mean ms', num(calls ? total / calls : 0))}${tile('Rows', num(sum('rows')))}
371
+ ${tile('Blocks hit', num(sum('hit')))}${tile('Blocks read', num(sum('read')))}${tile('Temp blocks written', num(sum('temp_written')))}</div>
372
+ ${region('Text', html`<pre class="source u-break sm-sql">${sql}</pre>
373
+ <div class="buttons"><form method="post" action="${URL_BASE}/statements/${queryid}/worksheet">${csrf(s)}<button class="btn">${icon('code')} Open in worksheet</button></form>
374
+ <a class="btn" href="${URL_BASE}/top">Back to Top SQL</a></div>`)}
375
+ ${region('By role', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Role</th><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Std dev ms</th><th class="num">Min ms</th><th class="num">Max ms</th><th class="num">Rows</th><th class="num">Hit</th><th class="num">Read</th><th class="num">Temp read</th><th class="num">Temp written</th></tr></thead>
376
+ <tbody>${rows.map((r) => html`<tr><td data-label="Role">${String(r.role ?? '')}${r.toplevel === false ? html` <span class="tag">nested</span>` : ''}</td>
377
+ ${(['calls', 'total_ms', 'mean_ms', 'stddev_ms', 'min_ms', 'max_ms', 'rows', 'hit', 'read', 'temp_read', 'temp_written'] as const).map((k) =>
378
+ html`<td class="num" data-label="${k.replace('_ms', ' ms').replace('_', ' ')}">${num(r[k])}</td>`)}</tr>`)}</tbody></table></div>`)}
379
+ ${region('Explain Plan', html`<p class="muted u-mt0">EXPLAIN without running the statement${/\$\d+/.test(sql) ? ': its parameters ($1, …) get a generic plan' : ''}. Unqualified names are looked up in the schema you choose first.</p>
380
+ <form method="get" action="${URL_BASE}/statements/${queryid}" class="sm-filter u-mb1"><input type="hidden" name="explain" value="1">
381
+ <label for="sm-schema">Schema</label><select id="sm-schema" name="schema"><option value="">(the search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
382
+ <button class="btn">${icon('play')} Explain</button></form>
383
+ ${explained ? explained.ok ? planView(explained.plan, explained.text) : html`<div class="alert alert-error" role="alert">${explained.error}</div>` : ''}`)}`;
384
+ return send(reply, s, shell(s, 'Statement', crumbs(['Statement']), main, 'sql'));
385
+ });
386
+
387
+ app.post(`${URL_BASE}/statements/:queryid/worksheet`, async (req: Req, reply) => {
388
+ const s = await admin(req, reply);
389
+ if (!s) return;
390
+ const queryid = String(req.params.queryid);
391
+ if (!QUERYID_RE.test(queryid) || !(await statementsState()).ok) return reply.code(404).send('Not found');
392
+ const rows = await statementOf(queryid).catch(() => [] as Row[]);
393
+ if (!rows.length) return reply.code(404).send('Not found');
394
+ s.state.__SQLDRAFT = String(rows[0].query ?? '');
395
+ return back(reply, s, `${BASE}/sql/worksheet`);
396
+ });
397
+ }
@@ -34,15 +34,19 @@ export const SQL_TOOLS: SqlTool[] = [
34
34
  { key: 'sample', href: '/builder/sql/sample-data', icon: 'layers', label: 'Sample Data', description: 'Rows that look real, for development.', group: 'development', tab: 'sample' },
35
35
  { key: 'ddl', href: '/builder/sql/ddl', icon: 'file', label: 'Generate DDL', description: 'A schema\'s objects as one script.', group: 'development', tab: 'ddl' },
36
36
  { key: 'compare', href: '/builder/sql/compare', icon: 'layers', label: 'Schema Comparison', description: 'The differences between two schemas, and the SQL to align them.', group: 'development', tab: 'compare' },
37
+ { key: 'changes', href: '/builder/sql/changes', icon: 'history', label: 'Change Log', description: 'DDL run here as change sets per schema; download as Flyway, Liquibase or SQL migrations.', group: 'development', tab: 'changes' },
37
38
  { key: 'modeler', href: '/builder/sql/modeler', icon: 'org', label: 'Data Modeler', description: 'Relational diagrams of tables and their foreign keys; DDL and a data dictionary from them.', group: 'development', tab: 'modeler' },
38
- { key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: 'Tables and views as REST endpoints (AutoREST): URLs, examples, OpenAPI.', group: 'development', tab: 'rest' },
39
+ { key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: 'REST modules, templates and handlers; tables and views as REST endpoints (AutoREST); OpenAPI.', group: 'development', tab: 'rest' },
39
40
  { key: 'ai', href: '/builder/sql/ai', icon: 'bolt', label: 'AI', description: 'SQL from a question, explanations and table descriptions.', group: 'development', tab: 'ai' },
40
41
  { key: 'utilities', href: '/builder/sql/utilities', icon: 'wrench', label: 'Utilities', description: 'Object reports, About Database and the other utilities.', group: 'administration', tab: 'utilities', main: true },
41
42
  { key: 'backups', href: '/builder/backups', icon: 'archive', label: 'Manage Backups', description: 'Backups of the workspace\'s applications.', group: 'administration' },
42
43
  { key: 'workspaces', href: '/builder/workspaces', icon: 'layers', label: 'Workspaces', description: 'Workspaces, their developers and database roles.', group: 'administration', admin: true },
43
44
  { key: 'dbusers', href: '/builder/db-users', icon: 'key', label: 'Database Users', description: 'PostgreSQL roles: sign-in, expiry, memberships and schema privileges.', group: 'administration', admin: true },
45
+ { key: 'datapump', href: '/builder/sql/data-pump', icon: 'archive', label: 'Schema Export / Import', description: 'A schema to a file with pg_dump, a file into a schema with pg_restore (Data Pump).', group: 'administration', tab: 'datapump', admin: true },
46
+ { key: 'scheduling', href: '/builder/sql/scheduling', icon: 'clock', label: 'Scheduling', description: 'pg_cron jobs: SQL on a schedule, next runs, run history; pgkiln\'s automations without it.', group: 'administration', tab: 'scheduling', admin: true },
44
47
  { key: 'developers', href: '/builder/developers', icon: 'users', label: 'Developers', description: 'Builder accounts and their workspaces.', group: 'administration' },
45
- { key: 'monitor', href: '/builder/sql/utilities/monitor', icon: 'activity', label: 'Database Monitor', description: 'Sessions, what they run and what blocks them.', group: 'monitoring', admin: true },
48
+ { key: 'monitor', href: '/builder/sql/monitor', icon: 'activity', label: 'SQL Monitor', description: 'Sessions live: what they run, wait for and what blocks them; cancel or terminate.', group: 'monitoring', tab: 'monitor', admin: true },
49
+ { key: 'topsql', href: '/builder/sql/monitor/top', icon: 'chart', label: 'Top SQL', description: 'The slowest and most called statements of the database, with their plans.', group: 'monitoring', tab: 'monitor', admin: true },
46
50
  { key: 'about', href: '/builder/sql/utilities/about', icon: 'info', label: 'About Database', description: 'The server: version, settings, extensions and sizes.', group: 'monitoring' },
47
51
  { key: 'reports', href: '/builder/sql/utilities/reports', icon: 'list', label: 'Object Reports', description: 'Tables without keys, unindexed foreign keys, row level security gaps and more.', group: 'monitoring' },
48
52
  { key: 'activity', href: '/builder/dashboard', icon: 'chart', label: 'Monitor Activity', description: 'Page views, errors and slow pages of the applications.', group: 'monitoring' },
@@ -15,8 +15,8 @@ import { workshopDb, workshopRunsAs } from './workspaces.ts';
15
15
  // the SQL Workshop's connection may use (workshopDb: an isolated
16
16
  // workspace's developers see only their own roles' schemas);
17
17
  // - About Database: version, a few settings, extensions, sizes;
18
- // - Database Monitor (administrators): sessions and blocked queries of this
19
- // database (pg_stat_activity), read only.
18
+ // - (sprint 102) the Database Monitor moved to SQL Workshop › SQL Monitor
19
+ // (sql-monitor.ts); its old address redirects there.
20
20
  //
21
21
  // Every query runs read only with a statement timeout.
22
22
 
@@ -153,7 +153,9 @@ export async function sqlUtilityRoutes(app: FastifyInstance) {
153
153
  ${tile('/sql/compare', 'layers', 'Schema Comparison', 'The differences between two schemas, and the SQL to align them.')}
154
154
  ${tile('/sql/utilities/reports', 'list', 'Object Reports', 'Tables without a primary key, foreign keys without an index, row-level security, grants, sizes.')}
155
155
  ${tile('/sql/utilities/about', 'help', 'About Database', 'The PostgreSQL version, settings, extensions and sizes.')}
156
- ${admin ? tile('/sql/utilities/monitor', 'activity', 'Database Monitor', 'Sessions, running and blocked queries of this database (administrators).') : ''}
156
+ ${admin ? tile('/sql/data-pump', 'archive', 'Schema Export / Import', 'A schema to a file with pg_dump, a file into a schema with pg_restore (administrators).') : ''}
157
+ ${admin ? tile('/sql/scheduling', 'clock', 'Scheduling', 'pg_cron jobs: SQL on a schedule, next runs and run history (administrators).') : ''}
158
+ ${admin ? tile('/sql/monitor', 'activity', 'SQL Monitor', 'Sessions, running and blocked queries, wait events and Top SQL (administrators).') : ''}
157
159
  </div>`;
158
160
  return send(reply, s, shell(s, 'SQL Workshop utilities', [['SQL Workshop', `${BASE}/sql`], ['Utilities']], main, 'sql'));
159
161
  });
@@ -234,45 +236,11 @@ export async function sqlUtilityRoutes(app: FastifyInstance) {
234
236
  return send(reply, s, shell(s, 'About Database', crumbs('About Database'), main, 'sql'));
235
237
  });
236
238
 
239
+ // (sprint 102) the Database Monitor became SQL Workshop › SQL Monitor (sql-monitor.ts)
237
240
  app.get(`${BASE}/sql/utilities/monitor`, async (req: Req, reply) => {
238
241
  const s = await developer(req, reply);
239
242
  if (!s) return;
240
243
  if (!(await isAdmin(s.username))) return reply.code(403).send('Administrators only.');
241
- const data = await readOnly(owner, async (q) => ({
242
- sessions: await q(`select a.pid, a.usename as "user", a.application_name as app, a.client_addr::text as client, a.state,
243
- a.wait_event_type || coalesce(': ' || a.wait_event, '') as wait,
244
- to_char(a.backend_start, 'YYYY-MM-DD HH24:MI') as since,
245
- case when a.state = 'active' then round(extract(epoch from now() - a.query_start)::numeric, 1) end as running_s,
246
- case when a.state like 'idle in transaction%' then round(extract(epoch from now() - a.state_change)::numeric, 1) end as idle_tx_s,
247
- pg_blocking_pids(a.pid) as blocked_by, left(a.query, 500) as query
248
- from pg_stat_activity a
249
- where a.datname = current_database() and a.backend_type = 'client backend' and a.pid <> pg_backend_pid()
250
- order by a.state = 'active' desc, a.query_start nulls last limit 200`),
251
- totals: (await q(`select count(*)::int as sessions, count(*) filter (where state = 'active')::int as active,
252
- count(*) filter (where state like 'idle in transaction%')::int as idle_tx,
253
- count(*) filter (where cardinality(pg_blocking_pids(pid)) > 0)::int as blocked,
254
- (select setting::int from pg_settings where name = 'max_connections') as max
255
- from pg_stat_activity where datname = current_database() and backend_type = 'client backend'`))[0],
256
- }));
257
- const t = data.totals as Record<string, number>;
258
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities')}
259
- <div class="stat-grid u-mb1">
260
- ${[['Sessions', `${t.sessions} / ${t.max}`], ['Active', t.active], ['Idle in a transaction', t.idle_tx], ['Blocked', t.blocked]]
261
- .map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
262
- </div>
263
- ${region('Sessions', html`<p class="muted u-mt0">The client sessions of this database (pg_stat_activity), active ones first. A query's text is shown only for roles whose statistics the owner may read (<code>pg_read_all_stats</code>). Reload the page to refresh it.</p>
264
- <div class="table-wrap"><table class="report report-reflow">
265
- <thead><tr><th class="num">PID</th><th>User</th><th>Application</th><th>State</th><th>Wait</th><th class="num">Running (s)</th><th>Blocked by</th><th>Query</th></tr></thead>
266
- <tbody>${data.sessions.length ? data.sessions.map((x) => html`<tr>
267
- <td class="num" data-label="PID">${String(x.pid)}</td>
268
- <td data-label="User">${String(x.user ?? '')}</td>
269
- <td data-label="Application" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
270
- <td data-label="State">${x.idle_tx_s != null ? html`<span class="tag">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
271
- <td data-label="Wait">${String(x.wait ?? '')}</td>
272
- <td class="num" data-label="Running">${x.running_s != null ? String(x.running_s) : ''}</td>
273
- <td data-label="Blocked by">${(x.blocked_by as number[]).length ? html`<span class="tag">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
274
- <td data-label="Query" class="u-break"><code>${String(x.query ?? '')}</code></td></tr>`)
275
- : html`<tr><td colspan="8" class="empty">No other sessions.</td></tr>`}</tbody></table></div>`)}`;
276
- return send(reply, s, shell(s, 'Database Monitor', crumbs('Database Monitor'), main, 'sql'));
244
+ return reply.redirect(`${BASE}/sql/monitor`, 301);
277
245
  });
278
246
  }
@@ -21,6 +21,8 @@ import { objectActionRoutes } from './object-actions.ts';
21
21
  import { tableEditorRoutes } from './table-editor.ts';
22
22
  import { dataModelerRoutes } from './data-modeler.ts';
23
23
  import { autoRestBuilderRoutes } from './autorest.ts';
24
+ import { restModuleRoutes } from './rest-modules.ts';
25
+ import { changeLogRoutes } from './changelog.ts';
24
26
  import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
25
27
 
26
28
  // SQL Workshop: SQL commands and the object browser (owner connection; an isolated
@@ -133,4 +135,8 @@ export async function sqlRoutes(app: FastifyInstance) {
133
135
  await app.register(dataModelerRoutes);
134
136
  // (sprint 100) RESTful Services: AutoREST (autorest.ts)
135
137
  await app.register(autoRestBuilderRoutes);
138
+ // (sprint 104) RESTful Services: modules, templates and handlers (rest-modules.ts)
139
+ await app.register(restModuleRoutes);
140
+ // (sprint 106) the change log (changelog.ts)
141
+ await app.register(changeLogRoutes);
136
142
  }
@@ -5,7 +5,8 @@ import { clientIp, logActivity, type Session } from '../session.ts';
5
5
  import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
6
6
  import { SYSTEM_SCHEMAS } from './object-browser.ts';
7
7
  import { clearCompletions } from './code-editor.ts';
8
- import { usableSchemas, workshopDb } from './workspaces.ts';
8
+ import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
9
+ import { joinStatements, recordChanges } from '../changelog.ts';
9
10
  import { checkTable, emptyTable, ON_DELETE, readTable, tableSql, type ColumnDef, type TableDef } from '../tabledef.ts';
10
11
 
11
12
  // (sprint 95, Database Actions DA-8: Table Properties) Create a table, edit one, or start from one as a template, in
@@ -196,6 +197,8 @@ async function page(req: Req, reply: FastifyReply) {
196
197
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
197
198
  clearCompletions();
198
199
  if (!failed) {
200
+ // (sprint 106, DA-17) one change set in the change log
201
+ await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', defaultSchema: def.schema, statements: [joinStatements(ddl)] });
199
202
  flash(s, `${editing ? 'Changed' : 'Created'} ${def.schema}.${def.name}: ${ddl.length} statement${ddl.length === 1 ? '' : 's'}.`);
200
203
  const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
201
204
  return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
@@ -14,10 +14,32 @@ export type TopSql =
14
14
  | { ok: true; rows: { queryid: string; calls: number; total_ms: number; mean_ms: number; rows: number; share: number; query: string }[] }
15
15
  | { ok: false; reason: 'no_role' | 'not_installed' | 'not_loaded' | 'error'; message?: string };
16
16
 
17
+ /** (sprint 102) Whether pg_stat_statements can be read: created in this database and loaded by the server. */
18
+ export async function statementsState(): Promise<{ ok: true } | { ok: false; reason: 'not_installed' | 'not_loaded' | 'error'; message?: string }> {
19
+ if (!(await owner.one(`select 1 as ok from pg_extension where extname = 'pg_stat_statements'`))) return { ok: false, reason: 'not_installed' };
20
+ try {
21
+ await owner.query('select 1 from pg_stat_statements limit 1');
22
+ return { ok: true };
23
+ } catch (e) {
24
+ const message = (e as Error).message;
25
+ return { ok: false, reason: /shared_preload_libraries/.test(message) ? 'not_loaded' : 'error', message };
26
+ }
27
+ }
28
+
29
+ /** Why the statistics can't be shown, and how to turn them on. */
30
+ export function statementsNote(reason: 'not_installed' | 'not_loaded' | 'error', message?: string) {
31
+ return html`<div class="alert alert-error" role="alert">${reason === 'not_installed'
32
+ ? html`The <code>pg_stat_statements</code> extension isn't installed in this database. To turn it on: set <code>shared_preload_libraries = 'pg_stat_statements'</code> (postgresql.conf, or <code>alter system set shared_preload_libraries = 'pg_stat_statements'</code>), restart PostgreSQL, then as a superuser: <code>create extension pg_stat_statements;</code>`
33
+ : reason === 'not_loaded'
34
+ ? html`The server doesn't load <code>pg_stat_statements</code> yet. Set <code>shared_preload_libraries = 'pg_stat_statements'</code> (the development <code>docker-compose.yml</code> does) and restart PostgreSQL.`
35
+ : html`The statistics could not be read: ${message ?? ''}`}</div>`;
36
+ }
37
+
17
38
  /** The role's statements, slowest in total first (or by mean time, calls or rows). */
18
39
  export async function topSql(role: string | null, sort = 'total', limit = 50): Promise<TopSql> {
19
40
  if (!role) return { ok: false, reason: 'no_role' };
20
- if (!(await owner.one(`select 1 as ok from pg_extension where extname = 'pg_stat_statements'`))) return { ok: false, reason: 'not_installed' };
41
+ const state = await statementsState();
42
+ if (!state.ok) return state;
21
43
  try {
22
44
  const res = await owner.query(
23
45
  `select queryid::text, calls::int, round(total_exec_time::numeric, 1)::float8 as total_ms, round(mean_exec_time::numeric, 2)::float8 as mean_ms,
@@ -61,11 +83,7 @@ export async function topSqlRoutes(app: FastifyInstance) {
61
83
  : html`<p class="muted">No statements recorded for <code>${a.db_role}</code> yet. Use the application, then come back.</p>`
62
84
  : top.reason === 'no_role'
63
85
  ? html`<div class="alert alert-error" role="alert">This application runs as the owner role, so its statements can't be told apart. Give it a database role in Settings.</div>`
64
- : html`<div class="alert alert-error" role="alert">${top.reason === 'not_installed'
65
- ? html`The <code>pg_stat_statements</code> extension isn't installed. As a superuser: <code>create extension pg_stat_statements;</code>`
66
- : top.reason === 'not_loaded'
67
- ? html`The server doesn't load <code>pg_stat_statements</code> yet. Set <code>shared_preload_libraries = 'pg_stat_statements'</code> (the development <code>docker-compose.yml</code> does) and restart PostgreSQL.`
68
- : html`The statistics could not be read: ${top.message}`}</div>`;
86
+ : statementsNote(top.reason, top.message);
69
87
  const main = html`${appHeader(a, 'activity')}
70
88
  ${region('Top SQL', html`<p class="muted u-mt0">Statements run as the role <code>${a.db_role ?? '(owner)'}</code> since the statistics were last reset, from <code>pg_stat_statements</code>. Literals and binds show as <code>$1</code>, <code>$2</code>…; applications that share a role share this list. <a href="${BASE}/apps/${a.id}/activity">Back to Activity</a></p>
71
89
  ${body}`)}`;