pgkiln 0.47.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 (69) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/db/migrations/116_dump_jobs.sql +41 -0
  7. package/db/migrations/117_rest_handlers.sql +63 -0
  8. package/db/migrations/118_change_log.sql +54 -0
  9. package/docs/apex-feature-parity.md +11 -11
  10. package/docs/guide/01-installation.md +4 -0
  11. package/docs/guide/03-builder.md +267 -7
  12. package/docs/guide/08-security.md +29 -0
  13. package/docs/guide/12-development.md +70 -6
  14. package/docs/guide/13-rest-api.md +133 -10
  15. package/docs/guide/15-extensions.md +1 -1
  16. package/examples/hr/hr_62_rest_module.sql +39 -0
  17. package/package.json +1 -1
  18. package/public/app.css +1 -1
  19. package/public/builder-icons.svg +2 -0
  20. package/public/builder.css +442 -173
  21. package/public/builder.js +48 -0
  22. package/public/data-modeler.css +56 -0
  23. package/public/data-modeler.js +367 -0
  24. package/public/palette.js +133 -0
  25. package/public/sql-monitor.js +73 -0
  26. package/public/worksheet.css +49 -0
  27. package/public/worksheet.js +4 -0
  28. package/src/app.ts +10 -0
  29. package/src/automations.ts +6 -0
  30. package/src/binds.ts +2 -1
  31. package/src/builder/advisor.ts +24 -2
  32. package/src/builder/autorest.ts +363 -0
  33. package/src/builder/changelog.ts +240 -0
  34. package/src/builder/components.ts +1 -1
  35. package/src/builder/data-modeler.ts +382 -0
  36. package/src/builder/datapump.ts +364 -0
  37. package/src/builder/db-users.ts +472 -0
  38. package/src/builder/ddl-tools.ts +13 -4
  39. package/src/builder/find-workspace.ts +2 -2
  40. package/src/builder/launchpad.ts +140 -0
  41. package/src/builder/object-actions.ts +1 -0
  42. package/src/builder/object-browser.ts +7 -1
  43. package/src/builder/plan-view.ts +25 -0
  44. package/src/builder/rest-modules.ts +629 -0
  45. package/src/builder/rest.ts +1 -0
  46. package/src/builder/routes.ts +4 -2
  47. package/src/builder/scheduling.ts +495 -0
  48. package/src/builder/sql-charts.ts +203 -0
  49. package/src/builder/sql-monitor.ts +397 -0
  50. package/src/builder/sql-tools.ts +53 -0
  51. package/src/builder/sql-utilities.ts +7 -39
  52. package/src/builder/sql.ts +20 -9
  53. package/src/builder/table-editor.ts +4 -1
  54. package/src/builder/top-sql.ts +24 -6
  55. package/src/builder/ui.ts +25 -17
  56. package/src/builder/worksheet.ts +35 -28
  57. package/src/builder/workspaces.ts +4 -2
  58. package/src/changelog.ts +109 -0
  59. package/src/cli/replace.ts +3 -1
  60. package/src/cron-schedule.ts +282 -0
  61. package/src/datamodel.ts +324 -0
  62. package/src/datapump.ts +546 -0
  63. package/src/dbroles.ts +215 -0
  64. package/src/dumpscript.ts +407 -0
  65. package/src/pgcron.ts +225 -0
  66. package/src/runtime/autorest.ts +336 -0
  67. package/src/runtime/rest-handlers.ts +75 -0
  68. package/src/runtime/rest.ts +120 -26
  69. package/src/session.ts +2 -0
@@ -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: {