pgkiln 0.48.0 → 0.50.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 +3 -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 +9 -9
- package/docs/guide/01-installation.md +7 -0
- package/docs/guide/03-builder.md +227 -10
- package/docs/guide/08-security.md +11 -0
- package/docs/guide/12-development.md +64 -13
- 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 +478 -173
- package/public/builder.js +48 -0
- package/public/code-editor.css +32 -79
- package/public/code-editor.js +58 -3
- package/public/data-modeler.css +17 -14
- package/public/sql-monitor.js +73 -0
- package/public/worksheet.css +14 -7
- package/public/worksheet.js +119 -3
- 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/data-modeler.ts +1 -1
- package/src/builder/datapump.ts +364 -0
- package/src/builder/db-users.ts +2 -2
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/object-browser.ts +4 -4
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/quicksql.ts +1 -1
- 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/scripts.ts +1 -1
- package/src/builder/sql-charts.ts +1 -1
- 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 +6 -3
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +6 -4
- package/src/builder/worksheet.ts +173 -41
- 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/src/session.ts +5 -0
- package/src/sqlworksheet.ts +9 -6
- package/src/worksheet-txn.ts +270 -0
package/public/worksheet.js
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// - (sprint 93) worksheets as tabs kept in this browser (unsaved text survives a reload), saved as SQL Scripts
|
|
14
14
|
// (Ctrl+S), the Files list; bind and substitution values in a dialog; Format (Ctrl+F7), Convert case (Ctrl+B),
|
|
15
15
|
// find and replace (Ctrl+F, Ctrl+H), go to line (Ctrl+G), comment lines (Ctrl+/), Help (F1)
|
|
16
|
+
// - (sprint 107) transaction mode per worksheet tab: Autocommit / Transaction, Commit and Rollback, a badge with the
|
|
17
|
+
// server's real state (fetched again after a reload and a tab switch, and when the idle timeout is due); leaving the
|
|
18
|
+
// page with an open transaction asks first, closing the tab or switching to Autocommit rolls it back
|
|
16
19
|
// Strict CSP: no inline styles; sizes are set through the CSSOM.
|
|
17
20
|
(() => {
|
|
18
21
|
'use strict';
|
|
@@ -266,8 +269,8 @@
|
|
|
266
269
|
wrap.className = 'ws-wtab';
|
|
267
270
|
const b = document.createElement('button');
|
|
268
271
|
b.type = 'button';
|
|
269
|
-
|
|
270
|
-
b.setAttribute('aria-
|
|
272
|
+
// (sprint 108) buttons in a group, the open one aria-current: a tablist may hold only tabs, not the close buttons
|
|
273
|
+
if (t === cur) b.setAttribute('aria-current', 'true');
|
|
271
274
|
b.dataset.wsWtab = t.key;
|
|
272
275
|
b.textContent = `${t.name}${dirty(t) ? '*' : ''}`;
|
|
273
276
|
b.title = t.scriptId ? `Script “${t.name}”${dirty(t) ? ' (unsaved changes)' : ''}` : 'Not saved as a script';
|
|
@@ -288,11 +291,15 @@
|
|
|
288
291
|
ta.value = t.text;
|
|
289
292
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
290
293
|
drawTabs();
|
|
294
|
+
txnTabChanged();
|
|
291
295
|
ta.focus();
|
|
292
296
|
};
|
|
293
297
|
const newTab = () => switchTo(add({ key: newKey(), name: untitled(), scriptId: null, text: '', saved: '' }));
|
|
294
298
|
const closeTab = (t) => {
|
|
295
|
-
|
|
299
|
+
const txnOpen = t === cur ? txnBox.dataset.state !== 'none' : !!t.txnOpen;
|
|
300
|
+
if (txnOpen && !window.confirm(`“${t.name}” has an open transaction. Close it and roll the transaction back?`)) return;
|
|
301
|
+
if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(`Close “${t.name}” without saving?`)) return;
|
|
302
|
+
if (t.txn) txnPost(t.key, 'rollback'); // the server knows best whether one is open
|
|
296
303
|
const i = book.tabs.indexOf(t);
|
|
297
304
|
book.tabs.splice(i, 1);
|
|
298
305
|
if (t === cur) {
|
|
@@ -340,6 +347,111 @@
|
|
|
340
347
|
form.querySelector('.ws-nav-switch').addEventListener('click', (e) => { const b = e.target.closest('[data-ws-navtab]'); if (b) navTab(b.dataset.wsNavtab); });
|
|
341
348
|
navTab(location.hash === '#ws-files' ? 'files' : 'objects');
|
|
342
349
|
|
|
350
|
+
// ------------------------------------------------------------ (sprint 107) transaction mode
|
|
351
|
+
function txnTabChanged() {
|
|
352
|
+
modeSel.value = cur.txn ? 'on' : '';
|
|
353
|
+
txnApply({ state: 'none', statements: 0, startedMs: null, note: null });
|
|
354
|
+
txnFetch();
|
|
355
|
+
}
|
|
356
|
+
const txnBox = form.querySelector('[data-ws-txn]');
|
|
357
|
+
const modeSel = txnBox.querySelector('[data-ws-txn-mode]');
|
|
358
|
+
const badge = txnBox.querySelector('[data-ws-txn-badge]');
|
|
359
|
+
const commitBtn = txnBox.querySelector('[data-ws-txn-commit]');
|
|
360
|
+
const rollbackBtn = txnBox.querySelector('[data-ws-txn-rollback]');
|
|
361
|
+
const commitNote = form.querySelector('[data-ws-commit-note]');
|
|
362
|
+
const idleSeconds = Number(txnBox.dataset.idle) || 300;
|
|
363
|
+
const idleWords = idleSeconds % 60 ? `${idleSeconds} seconds` : `${idleSeconds / 60} minute${idleSeconds === 60 ? '' : 's'}`;
|
|
364
|
+
let txnInfo = { state: txnBox.dataset.state, statements: Number(txnBox.dataset.statements) || 0, startedMs: Number(txnBox.dataset.started) || null,
|
|
365
|
+
lastMs: Number(txnBox.dataset.last) || null, note: txnBox.dataset.note || null };
|
|
366
|
+
let clockSkew = Date.now() - (Number(txnBox.dataset.now) || Date.now());
|
|
367
|
+
let txnTimer = 0;
|
|
368
|
+
const badgeText = (i) => {
|
|
369
|
+
if (i.state === 'none') return i.note || 'No open transaction';
|
|
370
|
+
const min = i.startedMs ? Math.floor((Date.now() - clockSkew - i.startedMs) / 60000) : 0;
|
|
371
|
+
return `${i.state === 'aborted' ? 'Transaction failed' : 'Transaction open'} · ${i.statements} statement${i.statements === 1 ? '' : 's'} · ${min < 1 ? 'under a minute' : `${min} min`}`;
|
|
372
|
+
};
|
|
373
|
+
const txnApply = (i) => {
|
|
374
|
+
txnInfo = i;
|
|
375
|
+
const on = modeSel.value === 'on';
|
|
376
|
+
txnBox.dataset.state = i.state;
|
|
377
|
+
badge.dataset.state = i.state;
|
|
378
|
+
badge.textContent = badgeText(i);
|
|
379
|
+
badge.hidden = !on && i.state === 'none' && !i.note;
|
|
380
|
+
commitBtn.hidden = rollbackBtn.hidden = !on && i.state === 'none';
|
|
381
|
+
commitBtn.disabled = i.state !== 'open';
|
|
382
|
+
rollbackBtn.disabled = i.state === 'none';
|
|
383
|
+
commitNote.textContent = on
|
|
384
|
+
? `The statements stay in one transaction until Commit or Rollback; it is rolled back after ${idleWords} without a run.`
|
|
385
|
+
: 'Each statement commits on its own.';
|
|
386
|
+
cur.txn = on;
|
|
387
|
+
cur.txnOpen = i.state !== 'none';
|
|
388
|
+
persist();
|
|
389
|
+
clearTimeout(txnTimer);
|
|
390
|
+
if (i.state !== 'none') {
|
|
391
|
+
// the age once a minute; the server again when the idle timeout is due
|
|
392
|
+
const due = i.lastMs ? i.lastMs + idleSeconds * 1000 + 2000 - (Date.now() - clockSkew) : 60000;
|
|
393
|
+
txnTimer = setTimeout(() => (due <= 60000 ? txnFetch() : txnApply(txnInfo)), Math.max(1000, Math.min(due, 60000)));
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const fromServer = (j) => {
|
|
397
|
+
clockSkew = Date.now() - j.now;
|
|
398
|
+
txnApply({ state: j.state, statements: j.statements, startedMs: j.startedMs, lastMs: j.lastMs, note: j.note });
|
|
399
|
+
};
|
|
400
|
+
async function txnFetch() {
|
|
401
|
+
const key = cur.key;
|
|
402
|
+
try {
|
|
403
|
+
const res = await fetch(`/builder/sql/worksheet/txn?tab=${encodeURIComponent(key)}`, { credentials: 'same-origin', headers: { accept: 'application/json' } });
|
|
404
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
405
|
+
const j = await res.json();
|
|
406
|
+
if (key !== cur.key) return; // switched meanwhile
|
|
407
|
+
if (j.state !== 'none') modeSel.value = 'on';
|
|
408
|
+
fromServer(j);
|
|
409
|
+
} catch {
|
|
410
|
+
badge.textContent = 'Transaction state unknown';
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function txnPost(key, action) {
|
|
414
|
+
try {
|
|
415
|
+
const res = await fetch('/builder/sql/worksheet/txn', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json' },
|
|
416
|
+
body: new URLSearchParams({ __csrf: form.elements.__csrf.value, tab: key, action }) });
|
|
417
|
+
const j = await res.json();
|
|
418
|
+
if (key === cur.key) {
|
|
419
|
+
fromServer(j);
|
|
420
|
+
if (j.message) announce(j.message);
|
|
421
|
+
}
|
|
422
|
+
return j;
|
|
423
|
+
} catch {
|
|
424
|
+
announce('The transaction could not be ended: reload the page.');
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
modeSel.addEventListener('change', async () => {
|
|
429
|
+
if (modeSel.value !== 'on' && txnInfo.state !== 'none') {
|
|
430
|
+
if (!window.confirm('Switching to Autocommit rolls back the open transaction. Roll it back?')) { modeSel.value = 'on'; return; }
|
|
431
|
+
await txnPost(cur.key, 'rollback');
|
|
432
|
+
modeSel.value = '';
|
|
433
|
+
}
|
|
434
|
+
txnApply(txnInfo);
|
|
435
|
+
});
|
|
436
|
+
// a page post (a run, Commit, Rollback) is not leaving: the transaction stays
|
|
437
|
+
let submitting = false;
|
|
438
|
+
form.addEventListener('submit', () => {
|
|
439
|
+
submitting = true;
|
|
440
|
+
setTimeout(() => { submitting = false; }, 3000); // a download doesn't leave the page
|
|
441
|
+
});
|
|
442
|
+
window.addEventListener('pageshow', () => { submitting = false; });
|
|
443
|
+
window.addEventListener('beforeunload', (e) => {
|
|
444
|
+
if (submitting || txnInfo.state === 'none') return;
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
e.returnValue = ''; // the browser asks "Leave site?"; an idle transaction is rolled back after the timeout
|
|
447
|
+
});
|
|
448
|
+
if (!form.dataset.tab || form.dataset.tab !== cur.key) txnTabChanged();
|
|
449
|
+
else {
|
|
450
|
+
if (txnInfo.state !== 'none') modeSel.value = 'on';
|
|
451
|
+
else if (!form.querySelector('[data-ws-txn-mode] option[selected]')) modeSel.value = cur.txn ? 'on' : '';
|
|
452
|
+
txnApply(txnInfo);
|
|
453
|
+
}
|
|
454
|
+
|
|
343
455
|
// ------------------------------------------------------------ (sprint 97) a chart's settings draw it again
|
|
344
456
|
form.querySelector('.ch-controls')?.addEventListener('change', () => run('chart'));
|
|
345
457
|
|
|
@@ -513,6 +625,7 @@
|
|
|
513
625
|
const size = (key, px) => {
|
|
514
626
|
const v = key === 'nav' ? Math.max(160, Math.min(px, window.innerWidth * 0.5)) : Math.max(80, Math.min(px, window.innerHeight * 0.75));
|
|
515
627
|
form.style.setProperty(key === 'nav' ? '--ws-nav' : '--ws-out', `${Math.round(v)}px`);
|
|
628
|
+
form.querySelector(`[data-ws-split="${key}"]`)?.setAttribute('aria-valuenow', String(Math.round(v)));
|
|
516
629
|
store.set(key, String(Math.round(v)));
|
|
517
630
|
return v;
|
|
518
631
|
};
|
|
@@ -520,6 +633,9 @@
|
|
|
520
633
|
for (const sp of form.querySelectorAll('[data-ws-split]')) {
|
|
521
634
|
const key = sp.dataset.wsSplit;
|
|
522
635
|
const current = () => (key === 'nav' ? form.querySelector('.ws-nav').getBoundingClientRect().width : out.getBoundingClientRect().height);
|
|
636
|
+
// (sprint 108) a focusable separator states its position (in pixels)
|
|
637
|
+
sp.setAttribute('aria-valuemin', '0');
|
|
638
|
+
if (!sp.hasAttribute('aria-valuenow')) sp.setAttribute('aria-valuenow', String(Math.round(current())));
|
|
523
639
|
sp.addEventListener('pointerdown', (e) => {
|
|
524
640
|
e.preventDefault();
|
|
525
641
|
sp.setPointerCapture(e.pointerId);
|
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" data-code-view="sql" tabindex="0" aria-label="SQL">${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: {
|
|
@@ -355,7 +355,7 @@ export async function dataModelerRoutes(app: FastifyInstance) {
|
|
|
355
355
|
return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}.sql"`).type('application/sql; charset=utf-8').send(script);
|
|
356
356
|
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
|
|
357
357
|
${region(`DDL of ${d.name}`, html`<p class="muted u-mt0">The CREATE statements of the diagram's ${m.tables.length} objects in schema ${d.schema_name}: tables, their keys, indexes, triggers, row level security, policies, comments and grants, and views. Review before running.</p>
|
|
358
|
-
<pre class="source" tabindex="0" aria-label="DDL">${script}</pre>
|
|
358
|
+
<pre class="source" data-code-view="sql" tabindex="0" aria-label="DDL">${script}</pre>
|
|
359
359
|
<form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${script}">
|
|
360
360
|
<div class="buttons"><a class="btn" href="${M}/${d.id}">${bicon('back')} Back to the diagram</a><a class="btn" href="${M}/${d.id}/ddl?download=1">${icon('download')} Download</a>
|
|
361
361
|
<button class="btn btn-hot">${icon('play')} Open in the SQL Worksheet</button></div></form>`)}`;
|