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.
- package/SECURITY.md +2 -2
- package/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +7 -7
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +179 -5
- package/docs/guide/08-security.md +11 -0
- package/docs/guide/12-development.md +44 -6
- package/docs/guide/13-rest-api.md +59 -1
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +420 -173
- package/public/builder.js +48 -0
- package/public/sql-monitor.js +73 -0
- package/src/app.ts +6 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +3 -2
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/datapump.ts +364 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +6 -2
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +6 -0
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +6 -4
- package/src/builder/worksheet.ts +5 -21
- package/src/changelog.ts +109 -0
- package/src/cron-schedule.ts +282 -0
- package/src/datapump.ts +546 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +116 -22
package/public/builder.js
CHANGED
|
@@ -1234,3 +1234,51 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1234
1234
|
// the button is for pages without JavaScript
|
|
1235
1235
|
picker.form.querySelector('button')?.setAttribute('hidden', '');
|
|
1236
1236
|
})();
|
|
1237
|
+
|
|
1238
|
+
// (sprint 101, DA-12) SQL Workshop › Scheduling: the schedule builder's five fields and the common schedules fill the
|
|
1239
|
+
// schedule; the preview (in words, next five runs) follows as you type. Without JavaScript the Use / Preview buttons
|
|
1240
|
+
// post the form and it comes back with the same.
|
|
1241
|
+
(() => {
|
|
1242
|
+
const form = document.querySelector('form[data-cron-form]');
|
|
1243
|
+
const input = form?.querySelector('[data-cron-schedule]');
|
|
1244
|
+
const preview = form?.querySelector('[data-cron-preview]');
|
|
1245
|
+
if (!form || !input || !preview) return;
|
|
1246
|
+
const fields = [...form.querySelectorAll('[data-cron-field]')];
|
|
1247
|
+
let timer = 0;
|
|
1248
|
+
let seq = 0;
|
|
1249
|
+
const refresh = () => {
|
|
1250
|
+
clearTimeout(timer);
|
|
1251
|
+
timer = setTimeout(async () => {
|
|
1252
|
+
const mine = ++seq;
|
|
1253
|
+
try {
|
|
1254
|
+
const res = await fetch(`${preview.dataset.cronPreview}?schedule=${encodeURIComponent(input.value)}`, { headers: { accept: 'text/html' }, credentials: 'same-origin' });
|
|
1255
|
+
if (!res.ok || mine !== seq) return;
|
|
1256
|
+
const doc = new DOMParser().parseFromString(`<div>${await res.text()}</div>`, 'text/html');
|
|
1257
|
+
preview.replaceChildren(...doc.body.firstElementChild.childNodes);
|
|
1258
|
+
} catch {
|
|
1259
|
+
/* the Preview button still works */
|
|
1260
|
+
}
|
|
1261
|
+
}, 250);
|
|
1262
|
+
};
|
|
1263
|
+
const split = () => {
|
|
1264
|
+
const parts = input.value.trim().split(/\s+/);
|
|
1265
|
+
if (parts.length === 5) fields.forEach((f, i) => (f.value = parts[i] === '*' ? '' : parts[i]));
|
|
1266
|
+
};
|
|
1267
|
+
input.addEventListener('input', () => {
|
|
1268
|
+
split();
|
|
1269
|
+
refresh();
|
|
1270
|
+
});
|
|
1271
|
+
for (const f of fields) {
|
|
1272
|
+
f.addEventListener('input', () => {
|
|
1273
|
+
input.value = fields.map((x) => x.value.trim() || '*').join(' ');
|
|
1274
|
+
refresh();
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
form.querySelector('[data-cron-preset]')?.addEventListener('change', (e) => {
|
|
1278
|
+
if (!e.target.value) return;
|
|
1279
|
+
input.value = e.target.value;
|
|
1280
|
+
split();
|
|
1281
|
+
refresh();
|
|
1282
|
+
});
|
|
1283
|
+
for (const b of form.querySelectorAll('[data-cron-nojs]')) b.hidden = true;
|
|
1284
|
+
})();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// pgkiln builder: the SQL Workshop's SQL Monitor (sprint 102, src/builder/sql-monitor.ts).
|
|
2
|
+
//
|
|
3
|
+
// Progressive enhancement of the Sessions view: the live part ([data-sm-live]: tiles, sessions, wait events,
|
|
4
|
+
// progress) is fetched again (?fragment=1) every 5, 15 or 60 seconds and swapped in place, with a Pause button.
|
|
5
|
+
// The interval and the pause are remembered in localStorage (pgkiln.sqlMonitor). No refresh while the tab is
|
|
6
|
+
// hidden or a session's confirmation is open; a failed or redirected fetch (signed out) stops it. Without
|
|
7
|
+
// JavaScript the page's Refresh link reloads it.
|
|
8
|
+
(() => {
|
|
9
|
+
'use strict';
|
|
10
|
+
const live = document.querySelector('[data-sm-live]');
|
|
11
|
+
const controls = document.querySelector('[data-sm-controls]');
|
|
12
|
+
if (!live || !controls) return;
|
|
13
|
+
const select = controls.querySelector('[data-sm-interval]');
|
|
14
|
+
const pause = controls.querySelector('[data-sm-pause]');
|
|
15
|
+
const KEY = 'pgkiln.sqlMonitor';
|
|
16
|
+
const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null') || {}; } catch { return {}; } };
|
|
17
|
+
const save = (v) => { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch { /* private mode */ } };
|
|
18
|
+
const pref = load();
|
|
19
|
+
const allowed = [...select.options].map((o) => Number(o.value));
|
|
20
|
+
let seconds = allowed.includes(Number(pref.seconds)) ? Number(pref.seconds) : allowed[0];
|
|
21
|
+
let paused = pref.paused === true;
|
|
22
|
+
let timer = 0;
|
|
23
|
+
let busy = false;
|
|
24
|
+
select.value = String(seconds);
|
|
25
|
+
controls.hidden = false;
|
|
26
|
+
|
|
27
|
+
const show = () => {
|
|
28
|
+
pause.setAttribute('aria-pressed', paused ? 'true' : 'false');
|
|
29
|
+
pause.textContent = paused ? 'Resume' : 'Pause';
|
|
30
|
+
live.classList.toggle('is-paused', paused);
|
|
31
|
+
};
|
|
32
|
+
const schedule = () => {
|
|
33
|
+
window.clearTimeout(timer);
|
|
34
|
+
if (!paused) timer = window.setTimeout(refresh, seconds * 1000);
|
|
35
|
+
};
|
|
36
|
+
async function refresh() {
|
|
37
|
+
if (busy || paused) return;
|
|
38
|
+
if (document.hidden) return schedule();
|
|
39
|
+
busy = true;
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(live.dataset.smLive, { credentials: 'same-origin', headers: { accept: 'text/html' }, cache: 'no-store' });
|
|
42
|
+
if (!res.ok || res.redirected) throw new Error(String(res.status));
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
// keep focus on a button that survives the swap (by its label)
|
|
45
|
+
const focused = document.activeElement && live.contains(document.activeElement) ? document.activeElement.getAttribute('aria-label') : null;
|
|
46
|
+
live.innerHTML = text;
|
|
47
|
+
if (focused) live.querySelector(`[aria-label="${CSS.escape(focused)}"]`)?.focus();
|
|
48
|
+
busy = false;
|
|
49
|
+
schedule();
|
|
50
|
+
} catch {
|
|
51
|
+
busy = false;
|
|
52
|
+
paused = true;
|
|
53
|
+
show();
|
|
54
|
+
const note = live.querySelector('[data-sm-updated]');
|
|
55
|
+
if (note) note.textContent = 'Refresh stopped: the monitor could not be reached (signed out?). Reload the page.';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
select.addEventListener('change', () => {
|
|
59
|
+
seconds = Number(select.value);
|
|
60
|
+
save({ seconds, paused });
|
|
61
|
+
schedule();
|
|
62
|
+
});
|
|
63
|
+
pause.addEventListener('click', () => {
|
|
64
|
+
paused = !paused;
|
|
65
|
+
save({ seconds, paused });
|
|
66
|
+
show();
|
|
67
|
+
if (paused) window.clearTimeout(timer);
|
|
68
|
+
else refresh();
|
|
69
|
+
});
|
|
70
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && !paused) refresh(); });
|
|
71
|
+
show();
|
|
72
|
+
schedule();
|
|
73
|
+
})();
|
package/src/app.ts
CHANGED
|
@@ -38,6 +38,9 @@ import { layoutRoutes } from './builder/layouts.ts';
|
|
|
38
38
|
import { dataLoadRoutes } from './builder/dataload.ts';
|
|
39
39
|
import { builderRoutes } from './builder/routes.ts';
|
|
40
40
|
import { dbUserRoutes } from './builder/db-users.ts';
|
|
41
|
+
import { dataPumpRoutes } from './builder/datapump.ts';
|
|
42
|
+
import { schedulingRoutes } from './builder/scheduling.ts';
|
|
43
|
+
import { sqlMonitorRoutes } from './builder/sql-monitor.ts';
|
|
41
44
|
import { usersRoutes } from './builder/users.ts';
|
|
42
45
|
import { codeEditorRoutes } from './builder/code-editor.ts';
|
|
43
46
|
import { sampleDataRoutes } from './builder/sampledata.ts';
|
|
@@ -203,6 +206,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
203
206
|
await app.register(sqlRoutes);
|
|
204
207
|
await app.register(usersRoutes);
|
|
205
208
|
await app.register(dbUserRoutes);
|
|
209
|
+
await app.register(dataPumpRoutes);
|
|
210
|
+
await app.register(schedulingRoutes);
|
|
211
|
+
await app.register(sqlMonitorRoutes); // (sprint 102) SQL Workshop › SQL Monitor
|
|
206
212
|
await app.register(dataLoadRoutes);
|
|
207
213
|
await app.register(layoutRoutes);
|
|
208
214
|
await app.register(automationRoutes);
|
package/src/automations.ts
CHANGED
|
@@ -330,6 +330,12 @@ export function startScheduler() {
|
|
|
330
330
|
} catch (e) {
|
|
331
331
|
console.error('AI requests:', (e as Error).message);
|
|
332
332
|
}
|
|
333
|
+
try {
|
|
334
|
+
// (116) schema export / import: interrupted jobs, files past PGKILN_DUMP_KEEP_DAYS (src/datapump.ts)
|
|
335
|
+
await (await import('./datapump.ts')).dumpTick();
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error('schema export / import:', (e as Error).message);
|
|
338
|
+
}
|
|
333
339
|
try {
|
|
334
340
|
// debug messages past their retention (at most once an hour; src/debug.ts)
|
|
335
341
|
await (await import('./debug.ts')).purgeDebug();
|
package/src/binds.ts
CHANGED
|
@@ -128,7 +128,8 @@ export function splitStatements(sql: string): string[] {
|
|
|
128
128
|
return out.map((x) => x.replace(/^(\s*(--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/))*\s*/, '').trim()).filter(Boolean);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
/** Replace every :NAME bind (outside strings, comments and casts) with replace(NAME). */
|
|
132
|
+
export function substitute(sql: string, replace: (name: string) => string): string {
|
|
132
133
|
let out = '';
|
|
133
134
|
let i = 0;
|
|
134
135
|
const n = sql.length;
|
package/src/builder/advisor.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { applyBinds, splitStatements } from '../binds.ts';
|
|
4
|
-
import { owner, type Db } from '../db.ts';
|
|
4
|
+
import { owner, runtime, type Db } from '../db.ts';
|
|
5
5
|
import { devDbForApp } from '../workspace-db.ts';
|
|
6
6
|
import { html, type Raw } from '../html.ts';
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
8
8
|
import { templateProblem } from '../runtime/document.ts';
|
|
9
9
|
import { invokeStepReferences, stepProblems, stepWarnings } from '../workflow.ts';
|
|
10
10
|
import { handlerProblems } from '../runtime/rest.ts';
|
|
11
|
+
import { plpgsqlBlock } from '../runtime/rest-handlers.ts';
|
|
11
12
|
import { processProblems } from '../runtime/processes.ts';
|
|
12
13
|
import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
|
|
13
14
|
import { configColumns, searchConfigProblems } from '../runtime/search.ts';
|
|
@@ -249,7 +250,15 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
249
250
|
for (const h of Array.isArray(row.handlers) ? row.handlers : []) {
|
|
250
251
|
if (typeof h?.source !== 'string' || !h.source.trim()) continue;
|
|
251
252
|
checked++;
|
|
252
|
-
|
|
253
|
+
// (117) a PL/pgSQL handler is compiled as the DO block it runs as
|
|
254
|
+
let block: string | null = null;
|
|
255
|
+
if (h.type === 'plpgsql') try {
|
|
256
|
+
block = plpgsqlBlock(h.source);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
findings.push({ severity: 'error', message: (e as Error).message, entry, field: `${h.method} ${h.path}` });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const problem = await checkSql(c, block ?? h.source, h.type === 'sql' || block ? 'statements' : 'select');
|
|
253
262
|
if (problem) findings.push({ ...problem, entry, field: `${h.method} ${h.path}` });
|
|
254
263
|
}
|
|
255
264
|
}
|
|
@@ -388,6 +397,19 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
388
397
|
}
|
|
389
398
|
}
|
|
390
399
|
|
|
400
|
+
// ---- (117) REST modules refuse (403) to run as a superuser, a role that bypasses RLS or a member of pgkiln's
|
|
401
|
+
// owner: say so here before the endpoints fail. Without a db_role the runtime connection's own user is the role.
|
|
402
|
+
if (all.some((x) => x.kind === 'rest_module')) {
|
|
403
|
+
const role = await (app.db_role ? owner : runtime).one(
|
|
404
|
+
`select r.rolname, r.rolsuper or r.rolbypassrls or pg_has_role(r.oid, n.nspowner, 'MEMBER') as bad
|
|
405
|
+
from pg_roles r, pg_namespace n where r.rolname = coalesce($1, current_user) and n.nspname = 'meta'`,
|
|
406
|
+
[app.db_role ?? null],
|
|
407
|
+
);
|
|
408
|
+
if (!role || role.bad)
|
|
409
|
+
findings.push({ severity: 'error', entry: null, field: 'REST modules',
|
|
410
|
+
message: `The handlers would run as ${role?.rolname ?? app.db_role}, a role that bypasses row level security or owns pgkiln, so every REST module endpoint answers 403. Give the application its own database role (Settings → Security), or set RUNTIME_DATABASE_URL to the least-privilege runtime role.` });
|
|
411
|
+
}
|
|
412
|
+
|
|
391
413
|
// ---- PL/pgSQL functions of the app's schemas, with plpgsql_check when installed
|
|
392
414
|
const plpgsqlCheck = !!(await one(`select 1 as ok from pg_extension where extname = 'plpgsql_check'`));
|
|
393
415
|
if (plpgsqlCheck && app.db_role) {
|
package/src/builder/autorest.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { publicUrl } from '../sso.ts';
|
|
|
9
9
|
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
10
10
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
11
11
|
import { splitRoles } from './users.ts';
|
|
12
|
+
import { restNav } from './rest-modules.ts';
|
|
12
13
|
import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
|
|
13
14
|
|
|
14
15
|
// (sprint 100, Database Actions DA-11 part 1) SQL Workshop › RESTful Services: AutoREST. A developer publishes a
|
|
@@ -166,8 +167,8 @@ async function listPage(s: Session, q: Record<string, unknown>, problems: string
|
|
|
166
167
|
<div class="buttons"><button class="btn btn-hot"${usable.length ? '' : raw(' disabled')}>${icon('bolt')} Enable</button></div>
|
|
167
168
|
${usable.length ? '' : html`<p class="muted">No application of this workspace has a database role to run as.</p>`}
|
|
168
169
|
</form>`;
|
|
169
|
-
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}
|
|
170
|
-
<p class="muted">RESTful Services: publish a table or view as REST endpoints (AutoREST). Rows come paged as JSON, with filters; a primary key gives single-row GET, PUT and DELETE. Requests run as the publishing application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply. For tailored endpoints with your own SQL,
|
|
170
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}${restNav('autorest')}
|
|
171
|
+
<p class="muted">RESTful Services: publish a table or view as REST endpoints (AutoREST). Rows come paged as JSON, with filters; a primary key gives single-row GET, PUT and DELETE. Requests run as the publishing application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply. For tailored endpoints with your own SQL or PL/pgSQL, create a <a href="${BASE}/sql/rest/modules">module</a>.</p>
|
|
171
172
|
${region('Published objects', table)}
|
|
172
173
|
${region('Enable a table or view', form)}`;
|
|
173
174
|
return shell(s, 'RESTful Services', [['SQL Workshop', `${BASE}/sql`], ['RESTful Services']], main, 'sql');
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { filesToZip } from '../appfiles.ts';
|
|
4
|
+
import { flywayFile, flywayName, liquibaseChangelog, plainScript, recordChanges, type ChangeSet } from '../changelog.ts';
|
|
5
|
+
import { owner } from '../db.ts';
|
|
6
|
+
import { changeScript, readSchema, userSchemas } from '../ddl.ts';
|
|
7
|
+
import { html, raw } from '../html.ts';
|
|
8
|
+
import { icon } from '../icons.ts';
|
|
9
|
+
import { splitStatements } from '../binds.ts';
|
|
10
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
11
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
12
|
+
import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 106, Database Actions DA-17; migration 118) SQL Workshop › Change Log: the change sets recorded from the
|
|
15
|
+
// worksheet, Table Properties and Schema Comparison (src/changelog.ts), per schema of the developer's workspace:
|
|
16
|
+
// number, name, author, time, where from, the SQL; whether each is in the latest export of an application of the
|
|
17
|
+
// workspace (made before it) and whether it was written to a migration file. Downloads (POST, they mark the change
|
|
18
|
+
// sets exported): plain SQL, a Liquibase formatted SQL changelog, Flyway files (V0001__add_bonus.sql; several in a
|
|
19
|
+
// zip). Schema Comparison's "Apply and record" runs its change script here, in one transaction, and records it.
|
|
20
|
+
|
|
21
|
+
export const CHANGES = `${BASE}/sql/changes`;
|
|
22
|
+
const NAME = /^[a-z0-9][a-z0-9_]{0,59}$/;
|
|
23
|
+
const FORMATS = ['sql', 'liquibase', 'flyway'] as const;
|
|
24
|
+
type Format = (typeof FORMATS)[number];
|
|
25
|
+
|
|
26
|
+
const asFormat = (v: unknown): Format => (FORMATS.includes(v as Format) ? (v as Format) : 'sql');
|
|
27
|
+
|
|
28
|
+
/** The workspace's change sets of a schema (or one by id). */
|
|
29
|
+
async function changeSets(s: Session, where: { schema?: string; id?: number }): Promise<ChangeSet[]> {
|
|
30
|
+
const params: unknown[] = [currentWorkspace(s)];
|
|
31
|
+
let cond = '';
|
|
32
|
+
if (where.schema !== undefined) cond += ` and schema_name = $${params.push(where.schema)}`;
|
|
33
|
+
if (where.id !== undefined) cond += ` and id = $${params.push(where.id)}`;
|
|
34
|
+
return (await owner.query<ChangeSet>(
|
|
35
|
+
`select id::int, workspace_id, schema_name, seq, name, author, source, sql, object_name, created_at::text, exported_at::text
|
|
36
|
+
from meta.change_set where workspace_id = $1${cond} order by schema_name, seq`, params)).rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Schemas with change sets in the workspace, with their counts. */
|
|
40
|
+
async function schemasWithChanges(s: Session) {
|
|
41
|
+
return (await owner.query<{ schema_name: string; n: number; last: string }>(
|
|
42
|
+
`select schema_name, count(*)::int as n, max(created_at)::text as last from meta.change_set where workspace_id = $1 group by 1 order by 1`,
|
|
43
|
+
[currentWorkspace(s)])).rows;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The latest builder export of each application of the workspace (activity log 'app_export'). */
|
|
47
|
+
async function latestExports(s: Session) {
|
|
48
|
+
return (await owner.query<{ id: number; name: string; alias: string; at: string }>(
|
|
49
|
+
`select a.id, a.name, a.alias, max(l.at)::text as at from meta.app a join meta.activity_log l on l.app_id = a.id and l.event = 'app_export'
|
|
50
|
+
where meta.app_workspace(a.id) = $1 group by a.id order by max(l.at) desc`, [currentWorkspace(s)])).rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const when = (t: string | null) => (t ? t.slice(0, 16).replace('T', ' ') : '');
|
|
54
|
+
const SOURCE_LABELS: Record<string, string> = { worksheet: 'SQL Worksheet', table: 'Table Properties', compare: 'Schema Comparison' };
|
|
55
|
+
|
|
56
|
+
async function listPage(s: Session, q: Record<string, unknown>) {
|
|
57
|
+
const schemas = await schemasWithChanges(s);
|
|
58
|
+
const schema = schemas.some((x) => x.schema_name === q.schema) ? String(q.schema) : (schemas[0]?.schema_name ?? '');
|
|
59
|
+
const exportsList = await latestExports(s);
|
|
60
|
+
const exp = exportsList.find((e) => String(e.id) === String(q.app ?? '')) ?? exportsList[0];
|
|
61
|
+
const list = schema ? await changeSets(s, { schema }) : [];
|
|
62
|
+
const inExport = (c: ChangeSet) => !!exp && c.created_at <= exp.at;
|
|
63
|
+
const pending = list.filter((c) => !c.exported_at).length;
|
|
64
|
+
const nav = schemas.length
|
|
65
|
+
? html`<nav class="settings-tabs" aria-label="Schemas">${schemas.map((x) => html`<a href="${CHANGES}?${new URLSearchParams({ schema: x.schema_name, ...(exp ? { app: String(exp.id) } : {}) })}"${x.schema_name === schema ? raw(' aria-current="page"') : ''}>${x.schema_name} <span class="muted small">${x.n}</span></a>`)}</nav>`
|
|
66
|
+
: '';
|
|
67
|
+
const table = list.length
|
|
68
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>#</th><th>Change set</th><th>Author</th><th>Recorded</th><th>From</th><th>Latest app export</th><th>Migration file</th></tr></thead><tbody>
|
|
69
|
+
${list.map((c) => html`<tr>
|
|
70
|
+
<td data-label="#">${c.seq}</td>
|
|
71
|
+
<td data-label="Change set" class="wrap-anywhere"><details><summary><code>${flywayName(c)}</code>${c.object_name ? html` <span class="muted small">${c.object_name}</span>` : ''}</summary>
|
|
72
|
+
<pre class="source wrap-pre">${c.sql}</pre>
|
|
73
|
+
<form method="post" action="${CHANGES}/${c.id}" class="search u-mwnone">${csrf(s)}
|
|
74
|
+
<div class="field"><label class="label" for="f_name_${c.id}">Name</label><input id="f_name_${c.id}" name="name" value="${c.name}" pattern="[a-z0-9][a-z0-9_]{0,59}" required></div>
|
|
75
|
+
<button class="btn btn-sm u-selfend">Rename</button></form>
|
|
76
|
+
<div class="buttons">
|
|
77
|
+
<form method="post" action="${CHANGES}/${c.id}/download" class="u-inline">${csrf(s)}<button class="btn btn-sm">${icon('download')} ${flywayName(c)}</button></form>
|
|
78
|
+
<form method="post" action="${CHANGES}/${c.id}/delete" class="u-inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete change set ${c.seq} from the log? The database is not changed, and its number is not given again.">${icon('trash')} Delete</button></form>
|
|
79
|
+
</div></details></td>
|
|
80
|
+
<td data-label="Author">${c.author}</td>
|
|
81
|
+
<td data-label="Recorded">${when(c.created_at)}</td>
|
|
82
|
+
<td data-label="From">${SOURCE_LABELS[c.source] ?? c.source}</td>
|
|
83
|
+
<td data-label="Latest app export">${exp ? (inExport(c) ? html`<span class="tag">✓ in it</span>` : html`<span class="tag tag-warn">after it</span>`) : html`<span class="muted">—</span>`}</td>
|
|
84
|
+
<td data-label="Migration file">${c.exported_at ? `written ${when(c.exported_at)}` : html`<span class="muted">not yet</span>`}</td></tr>`)}
|
|
85
|
+
</tbody></table></div>`
|
|
86
|
+
: html`<p class="muted">No change sets yet. DDL you run in the SQL Worksheet, apply in Table Properties or apply from Schema Comparison is recorded here.</p>`;
|
|
87
|
+
const exportPick = exportsList.length
|
|
88
|
+
? html`<form method="get" action="${CHANGES}" class="search u-mwnone u-mb1"><input type="hidden" name="schema" value="${schema}">
|
|
89
|
+
<div class="field"><label class="label" for="f_app">Compare with the latest export of</label><select id="f_app" name="app">${exportsList.map((e) =>
|
|
90
|
+
html`<option value="${e.id}"${e === exp ? raw(' selected') : ''}>${e.name} (${when(e.at)})</option>`)}</select></div>
|
|
91
|
+
<button class="btn u-selfend">Show</button></form>
|
|
92
|
+
<p class="muted small u-mt0">✓ in it: recorded before the application was last exported, so a deployment of that export expects the change. <em>After it</em>: still to be shipped with the next export.</p>`
|
|
93
|
+
: html`<p class="muted small">No application of this workspace was exported from the builder yet (Application › Export): the change sets can't be compared with an export.</p>`;
|
|
94
|
+
const downloads = list.length
|
|
95
|
+
? html`<form method="post" action="${CHANGES}/download" class="u-mt1">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
|
|
96
|
+
<div class="form-grid">
|
|
97
|
+
<div class="field"><label class="label" for="f_format">Format</label><select id="f_format" name="format">
|
|
98
|
+
<option value="flyway">Flyway: V0001__name.sql files (a zip for several)</option>
|
|
99
|
+
<option value="liquibase">Liquibase: formatted SQL changelog</option>
|
|
100
|
+
<option value="sql">Plain SQL script</option></select></div>
|
|
101
|
+
<div class="field"><label class="label" for="f_which">Change sets</label><select id="f_which" name="which">
|
|
102
|
+
<option value="pending">Not yet written to a file (${pending})</option>
|
|
103
|
+
<option value="all">All of ${schema} (${list.length})</option>
|
|
104
|
+
${exp ? html`<option value="after_export">After the latest export of ${exp.name} (${list.filter((c) => !inExport(c)).length})</option>` : ''}</select></div>
|
|
105
|
+
</div>
|
|
106
|
+
${exp ? html`<input type="hidden" name="app" value="${exp.id}">` : ''}
|
|
107
|
+
<div class="buttons"><button class="btn btn-hot">${icon('download')} Download migration</button></div></form>`
|
|
108
|
+
: '';
|
|
109
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('changes')}
|
|
110
|
+
<p class="muted">The change log (Liquibase, Flyway): DDL run from the SQL Worksheet, Table Properties and Schema Comparison is kept as change sets per schema, with who ran it and when. Download them as a migration for another database: Flyway versioned files, a Liquibase formatted SQL changelog or a plain script. Only statements that succeeded are kept; data changes (INSERT, UPDATE, DELETE) are not.</p>
|
|
111
|
+
${nav}
|
|
112
|
+
${schema ? region(`Change sets of ${schema}`, html`${exportPick}${table}${downloads}`) : region('Change sets', table)}`;
|
|
113
|
+
return shell(s, 'Change Log', [['SQL Workshop', `${BASE}/sql`], ['Change Log']], main, 'sql');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function oneOr404(req: Req, reply: FastifyReply, s: Session) {
|
|
117
|
+
const id = Number(req.params.id);
|
|
118
|
+
const c = Number.isInteger(id) && id > 0 && id < 2 ** 53 ? (await changeSets(s, { id }))[0] : undefined;
|
|
119
|
+
if (!c) reply.code(404).send('Not found');
|
|
120
|
+
return c;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const markExported = (ids: number[]) => owner.query('update meta.change_set set exported_at = now() where id = any($1::bigint[])', [ids]);
|
|
124
|
+
|
|
125
|
+
/** The download of change sets in a format: one file, or a zip of Flyway files. */
|
|
126
|
+
function fileOf(list: ChangeSet[], format: Format, schema: string): { name: string; type: string; body: string | Buffer } {
|
|
127
|
+
if (format === 'flyway') {
|
|
128
|
+
if (list.length === 1) return { name: flywayName(list[0]), type: 'application/sql; charset=utf-8', body: flywayFile(list[0]) };
|
|
129
|
+
return { name: `${schema}-migrations.zip`, type: 'application/zip', body: Buffer.from(filesToZip(new Map(list.map((c) => [flywayName(c), Buffer.from(flywayFile(c))])), `${schema}-migrations`)) };
|
|
130
|
+
}
|
|
131
|
+
if (format === 'liquibase') return { name: `${schema}.changelog.sql`, type: 'application/sql; charset=utf-8', body: liquibaseChangelog(list) };
|
|
132
|
+
return { name: `${schema}-changes.sql`, type: 'application/sql; charset=utf-8', body: plainScript(list) };
|
|
133
|
+
}
|
|
134
|
+
const safeName = (n: string) => n.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
135
|
+
|
|
136
|
+
export async function changeLogRoutes(app: FastifyInstance) {
|
|
137
|
+
app.get(CHANGES, async (req: Req, reply) => {
|
|
138
|
+
const s = await developer(req, reply);
|
|
139
|
+
if (!s) return;
|
|
140
|
+
return send(reply, s, await listPage(s, req.query ?? {}));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
app.post(`${CHANGES}/download`, async (req: Req, reply) => {
|
|
144
|
+
const s = await developer(req, reply);
|
|
145
|
+
if (!s) return;
|
|
146
|
+
const b = req.body ?? {};
|
|
147
|
+
const schema = String(b.schema ?? '');
|
|
148
|
+
let list = await changeSets(s, { schema });
|
|
149
|
+
if (!list.length) return reply.code(404).send('Not found');
|
|
150
|
+
if (b.which === 'pending') list = list.filter((c) => !c.exported_at);
|
|
151
|
+
else if (b.which === 'after_export') {
|
|
152
|
+
const exp = (await latestExports(s)).find((e) => String(e.id) === String(b.app ?? ''));
|
|
153
|
+
if (exp) list = list.filter((c) => c.created_at > exp.at);
|
|
154
|
+
}
|
|
155
|
+
if (!list.length) {
|
|
156
|
+
flash(s, 'No change sets to write: every one is in an earlier file. Choose "All".', 'error');
|
|
157
|
+
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema })}`);
|
|
158
|
+
}
|
|
159
|
+
const format = asFormat(b.format);
|
|
160
|
+
const f = fileOf(list, format, schema);
|
|
161
|
+
await markExported(list.map((c) => c.id));
|
|
162
|
+
await logActivity({ username: s.username, event: 'change_log_export', ip: clientIp(req), detail: `${schema}: ${list.length} change set(s) as ${format}` });
|
|
163
|
+
return reply.header('content-disposition', `attachment; filename="${safeName(f.name)}"`).type(f.type).send(f.body);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
app.post(`${CHANGES}/:id/download`, async (req: Req, reply) => {
|
|
167
|
+
const s = await developer(req, reply);
|
|
168
|
+
if (!s) return;
|
|
169
|
+
const c = await oneOr404(req, reply, s);
|
|
170
|
+
if (!c) return;
|
|
171
|
+
await markExported([c.id]);
|
|
172
|
+
return reply.header('content-disposition', `attachment; filename="${safeName(flywayName(c))}"`).type('application/sql; charset=utf-8').send(flywayFile(c));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
app.post(`${CHANGES}/:id`, async (req: Req, reply) => {
|
|
176
|
+
const s = await developer(req, reply);
|
|
177
|
+
if (!s) return;
|
|
178
|
+
const c = await oneOr404(req, reply, s);
|
|
179
|
+
if (!c) return;
|
|
180
|
+
const name = String(req.body?.name ?? '').trim().toLowerCase();
|
|
181
|
+
if (!NAME.test(name)) flash(s, 'A name is lower case letters, digits and _ (at most 60), starting with a letter or digit.', 'error');
|
|
182
|
+
else {
|
|
183
|
+
await owner.query('update meta.change_set set name = $2 where id = $1', [c.id, name]);
|
|
184
|
+
flash(s, `Change set ${c.seq} is now ${flywayName({ seq: c.seq, name })}.`);
|
|
185
|
+
}
|
|
186
|
+
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema: c.schema_name })}`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.post(`${CHANGES}/:id/delete`, async (req: Req, reply) => {
|
|
190
|
+
const s = await developer(req, reply);
|
|
191
|
+
if (!s) return;
|
|
192
|
+
const c = await oneOr404(req, reply, s);
|
|
193
|
+
if (!c) return;
|
|
194
|
+
await owner.query('delete from meta.change_set where id = $1', [c.id]);
|
|
195
|
+
await logActivity({ username: s.username, event: 'change_log_delete', ip: clientIp(req), detail: `${c.schema_name} #${c.seq} ${c.name}` });
|
|
196
|
+
flash(s, `Change set ${c.seq} removed from the log; the database is unchanged.`);
|
|
197
|
+
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema: c.schema_name })}`);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Schema Comparison › Apply and record: the change script that makes the target like the source, run in one
|
|
201
|
+
// transaction on the SQL Workshop's connection, recorded as a change set of the target
|
|
202
|
+
app.post(`${BASE}/sql/compare/apply`, async (req: Req, reply) => {
|
|
203
|
+
const s = await developer(req, reply);
|
|
204
|
+
if (!s) return;
|
|
205
|
+
const db = await workshopDb(s);
|
|
206
|
+
const schemas = await userSchemas(db, { usable: db !== owner });
|
|
207
|
+
const source = String(req.body?.source ?? '');
|
|
208
|
+
const target = String(req.body?.target ?? '');
|
|
209
|
+
if (!schemas.includes(source) || !schemas.includes(target) || source === target) return reply.code(404).send('Not found');
|
|
210
|
+
const [a, b] = await Promise.all([readSchema(db.pool, source), readSchema(db.pool, target)]);
|
|
211
|
+
const script = changeScript(source, target, a, b);
|
|
212
|
+
const statements = splitStatements(script);
|
|
213
|
+
const back2 = `${BASE}/sql/compare?${new URLSearchParams({ source, target })}`;
|
|
214
|
+
if (!statements.length) {
|
|
215
|
+
flash(s, `${target} is already like ${source}: nothing to apply.`);
|
|
216
|
+
return back(reply, s, back2);
|
|
217
|
+
}
|
|
218
|
+
const c = await db.pool.connect();
|
|
219
|
+
let failed = '';
|
|
220
|
+
try {
|
|
221
|
+
await c.query('begin');
|
|
222
|
+
await c.query(`select set_config('search_path', $1, true)`, [`${pg.escapeIdentifier(target)}, public`]);
|
|
223
|
+
for (const x of statements) await c.query(x);
|
|
224
|
+
await c.query('commit');
|
|
225
|
+
} catch (e) {
|
|
226
|
+
failed = (e as Error).message;
|
|
227
|
+
await c.query('rollback').catch(() => {});
|
|
228
|
+
} finally {
|
|
229
|
+
c.release(true);
|
|
230
|
+
}
|
|
231
|
+
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: `compare apply ${source} → ${target}: ${failed ? `failed: ${failed}` : `${statements.length} statement(s)`}`.slice(0, 2000) });
|
|
232
|
+
if (failed) {
|
|
233
|
+
flash(s, `Nothing was changed: the change script ran in one transaction, which was rolled back. ${failed}`, 'error');
|
|
234
|
+
return back(reply, s, back2);
|
|
235
|
+
}
|
|
236
|
+
await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'compare', statements, defaultSchema: target, name: `like_${source}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 60) });
|
|
237
|
+
flash(s, `${target} is now like ${source}: ${statements.length} statement(s), recorded in the change log.`);
|
|
238
|
+
return back(reply, s, `${CHANGES}?${new URLSearchParams({ schema: target })}`);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -567,7 +567,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
567
567
|
{ name: 'description', label: 'Description', kind: 'text', wide: true, group: 'Identification' },
|
|
568
568
|
{ name: 'enabled', label: 'Enabled', kind: 'bool', group: 'Identification' },
|
|
569
569
|
{ name: 'handlers', label: 'Handlers (JSON)', kind: 'json', wide: true, group: 'Handlers',
|
|
570
|
-
help: '[{"method": "GET", "path": "orders", "type": "collection", "source": "select id, customer, total from sales.orders order by id"}, {"method": "GET", "path": "orders/:id", "type": "item", "source": "select * from sales.orders where id = :ID::int"}, {"method": "POST", "path": "orders", "type": "sql", "source": "select sales.create_order(:CUSTOMER, :TOTAL::numeric) as id", "roles": ["sales"]}] · type: collection (paged: ?limit, ?offset), item (404 when no row), sql (the first row of the last statement). Binds: path parameters, query parameters and JSON body fields, upper case; :BODY is the whole body. "auth": "public" for endpoints without a token. The SQL runs as the application\'s role, with meta.app_user() the caller.' },
|
|
570
|
+
help: '[{"method": "GET", "path": "orders", "type": "collection", "source": "select id, customer, total from sales.orders order by id"}, {"method": "GET", "path": "orders/:id", "type": "item", "source": "select * from sales.orders where id = :ID::int"}, {"method": "POST", "path": "orders", "type": "sql", "source": "select sales.create_order(:CUSTOMER, :TOTAL::numeric) as id", "roles": ["sales"]}] · type: collection (paged: ?limit, ?offset), item (404 when no row), sql (the first row of the last statement), plpgsql (a PL/pgSQL block answering with meta.rest_reply(body, status)), media (a row\'s content_type and content). SQL Workshop › RESTful Services › Modules edits them as templates and handlers. Binds: path parameters, query parameters and JSON body fields, upper case; :BODY is the whole body. "auth": "public" for endpoints without a token. The SQL runs as the application\'s role, with meta.app_user() the caller.' },
|
|
571
571
|
],
|
|
572
572
|
},
|
|
573
573
|
workflow_definition: {
|