pgkiln 0.78.0 → 0.80.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.
@@ -0,0 +1,215 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { applicationRoles, applyRemote, disconnect, dropRemote, fdwStatus, MAX_TABLES, remoteDbProblems, serverNameOf, type RemoteDb } from '../remote-db.ts';
6
+ import { clientIp, logActivity, type Session } from '../session.ts';
7
+ import { administrator, back, BASE, csrf, flash, input, region, send, shell, type Req } from './ui.ts';
8
+
9
+ // (sprint 150, AP-7 leftover; APEX: REST Enabled SQL services) Workspace utilities → Remote databases
10
+ // (administrators): another PostgreSQL database's tables as foreign tables in a local schema (src/remote-db.ts).
11
+ //
12
+ // GET /builder/remote-databases postgres_fdw's state, the remote databases, the form (?edit=<id>, ?new=1)
13
+ // POST /builder/remote-databases save and import (mode=test: import in a transaction that is rolled back)
14
+ // POST /builder/remote-databases/:rid/refresh import the tables again (new or changed remote tables)
15
+ // POST /builder/remote-databases/:rid/delete drop the local schema and the server
16
+ // POST /builder/remote-databases/extension create extension postgres_fdw (a superuser connection)
17
+
18
+ const PAGE = `${BASE}/remote-databases`;
19
+ const TITLE = 'Remote databases';
20
+ const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [TITLE]];
21
+ const rid = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
22
+ const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
23
+ const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v ? [v] : []);
24
+
25
+ interface Row extends RemoteDb { id: number; imported_at: Date | null; imported: number | null; error: string | null; updated_by: string }
26
+
27
+ const rows = async () => (await owner.query<Row>('select * from meta.remote_database order by name')).rows;
28
+
29
+ function fromBody(b: Record<string, unknown>): RemoteDb {
30
+ const text = (k: string) => String(b[k] ?? '').trim();
31
+ return {
32
+ name: text('name').toUpperCase(), description: text('description') || null, host: text('host'), port: Number(text('port') || '5432'),
33
+ dbname: text('dbname'), user_name: text('user_name'), remote_schema: text('remote_schema') || 'public', local_schema: text('local_schema').toLowerCase(),
34
+ tables: text('tables').split(/[,\n]/).map((t) => t.trim()).filter(Boolean), grant_roles: list(b.grant_roles), writable: b.writable === 'true',
35
+ };
36
+ }
37
+
38
+ function form(s: Session, x: Partial<Row>, roles: string[]) {
39
+ const editing = !!x.id;
40
+ return region(editing ? `Edit ${x.name}` : 'Add a remote database', html`<form method="post" action="${PAGE}" id="rdb-form">${csrf(s)}
41
+ ${editing ? html`<input type="hidden" name="id" value="${x.id}">` : ''}
42
+ <div class="form-grid">
43
+ ${input('name', 'Name', x.name ?? '', { help: 'A static id, e.g. CRM. The server is pgkiln_rdb_<name>.', required: true })}
44
+ ${input('description', 'Description', x.description ?? '')}
45
+ ${input('host', 'Host', x.host ?? '', { help: 'As the database server reaches it (it makes the connection, not pgkiln).', required: true })}
46
+ ${input('port', 'Port', String(x.port ?? 5432), { type: 'number' })}
47
+ ${input('dbname', 'Database', x.dbname ?? '', { required: true })}
48
+ ${input('user_name', 'User', x.user_name ?? '', { required: true, help: 'A user of the remote database: what it may read or change there is what the applications may.' })}
49
+ ${input('password', 'Password', '', { type: 'password', auto: 'new-password', help: editing ? 'Kept by PostgreSQL in the user mapping and never shown; type a new one to replace it.' : 'Kept by PostgreSQL in the user mapping (pgkiln never stores it).' })}
50
+ ${input('remote_schema', 'Remote schema', x.remote_schema ?? 'public')}
51
+ ${input('local_schema', 'Local schema', x.local_schema ?? '', { required: true, help: 'A new schema here for the foreign tables, e.g. crm: regions query crm.<table>.' })}
52
+ ${input('tables', 'Tables', (x.tables ?? []).join(', '), { help: `Remote tables and views to import, separated by commas (at most ${MAX_TABLES}); empty: all of the remote schema's.` })}
53
+ </div>
54
+ <fieldset class="prop-group u-mt1"><legend>Grant to the applications' database roles</legend>
55
+ ${roles.length
56
+ ? html`<div class="check-list">${roles.map((r) => html`<label><input type="checkbox" name="grant_roles" value="${r}"${(x.grant_roles ?? []).includes(r) ? raw(' checked') : ''}> ${r}</label>`)}</div>`
57
+ : html`<p class="muted">No application has a database role yet (Application settings → Database role).</p>`}
58
+ <label class="u-mt1"><input type="checkbox" name="writable" value="true"${x.writable ? raw(' checked') : ''}> Also INSERT, UPDATE and DELETE (else SELECT only)</label>
59
+ </fieldset>
60
+ <div class="buttons u-mt1">
61
+ <button class="btn btn-hot" name="mode" value="save">${editing ? 'Save and import again' : 'Add and import'}</button>
62
+ <button class="btn" name="mode" value="test">Test the connection</button>
63
+ <a class="btn" href="${PAGE}">Cancel</a>
64
+ </div></form>`);
65
+ }
66
+
67
+ export async function remoteDatabaseRoutes(app: FastifyInstance) {
68
+ app.get(PAGE, async (req: Req, reply) => {
69
+ const s = await administrator(req, reply, TITLE);
70
+ if (!s) return;
71
+ const q = (req.query ?? {}) as Record<string, string>;
72
+ const st = await fdwStatus(owner);
73
+ const all = await rows();
74
+ const roles = await applicationRoles(owner);
75
+ const edit = rid(q.edit) ? all.find((r) => r.id === rid(q.edit)) : undefined;
76
+ const main = html`<h1 class="u-mb1">${TITLE}</h1>
77
+ <p class="muted u-mt0">Another PostgreSQL database's tables as foreign tables (postgres_fdw) in a local schema: regions, reports, forms and lists
78
+ of values use them like the application's own tables, as its database role, with the grants given here.</p>
79
+ ${st.installed
80
+ ? html`<p class="muted">${icon('check')} postgres_fdw is installed.</p>`
81
+ : html`<div class="alert alert-info" role="status"><p><strong>postgres_fdw is not installed in this database.</strong></p>
82
+ ${st.available && st.canCreate
83
+ ? html`<form method="post" action="${PAGE}/extension">${csrf(s)}<button class="btn">Create extension postgres_fdw</button></form>`
84
+ : html`<p>${st.available ? 'A superuser runs' : 'The server needs PostgreSQL\'s contrib package, then a superuser runs'} <code>create extension postgres_fdw;</code>
85
+ and <code>grant usage on foreign data wrapper postgres_fdw to</code> pgkiln's owner role.</p>`}</div>`}
86
+ ${region('Remote databases', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Name</th><th>Connection</th><th>Local schema</th>
87
+ <th class="num">Tables</th><th>Granted to</th><th>Imported</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
88
+ ${all.length ? all.map((r) => html`<tr>
89
+ <td data-label="Name"><strong>${r.name}</strong>${r.description ? html`<br><small class="muted">${r.description}</small>` : ''}</td>
90
+ <td data-label="Connection"><code>${r.user_name}@${r.host}:${r.port}/${r.dbname}</code> (${r.remote_schema})</td>
91
+ <td data-label="Local schema"><code>${r.local_schema}</code></td>
92
+ <td class="num" data-label="Tables">${r.imported ?? '—'}</td>
93
+ <td data-label="Granted to">${r.grant_roles.join(', ') || '—'}${r.writable ? ' (read and write)' : ''}</td>
94
+ <td data-label="Imported">${when(r.imported_at)}${r.error ? html`<br><span class="wf-problem">${r.error}</span>` : ''}</td>
95
+ <td data-label="Actions"><a class="btn btn-sm" href="${PAGE}?edit=${r.id}#rdb-form">Edit</a>
96
+ <form method="post" action="${`${PAGE}/${r.id}/refresh`}" class="inline">${csrf(s)}<button class="btn btn-sm">Import again</button></form>
97
+ <form method="post" action="${`${PAGE}/${r.id}/delete`}" class="inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${`Delete ${r.name}? Its schema ${r.local_schema} and its foreign tables are dropped.`}">Delete</button></form></td>
98
+ </tr>`) : html`<tr><td colspan="7" class="empty">No remote databases yet.</td></tr>`}
99
+ </tbody></table></div>`)}
100
+ ${st.installed ? form(s, edit ?? {}, roles) : ''}`;
101
+ return send(reply, s, shell(s, TITLE, crumbs, main));
102
+ });
103
+
104
+ app.post(PAGE, async (req: Req, reply) => {
105
+ const s = await administrator(req, reply, TITLE);
106
+ if (!s) return;
107
+ const b = (req.body ?? {}) as Record<string, unknown>;
108
+ const x = fromBody(b);
109
+ const editId = rid(b.id);
110
+ const password = typeof b.password === 'string' && b.password ? b.password : null;
111
+ const retry = editId ? `${PAGE}?edit=${editId}#rdb-form` : `${PAGE}#rdb-form`;
112
+ const problems = remoteDbProblems(x, await applicationRoles(owner));
113
+ if (password && password.length > 1000) problems.push('The password has at most 1000 characters.');
114
+ if (problems.length) {
115
+ flash(s, problems.join(' '), 'error');
116
+ return back(reply, s, retry);
117
+ }
118
+ const test = b.mode === 'test';
119
+ try {
120
+ const count = await owner.tx(async (c) => {
121
+ const old = editId ? (await c.query<Row>('select * from meta.remote_database where id = $1 for update', [editId])).rows[0] : undefined;
122
+ if (editId && !old) throw new Error('No such remote database.');
123
+ // a renamed one, or a new local schema: the old server and schema go first
124
+ if (old && (old.name !== x.name || old.local_schema !== x.local_schema)) {
125
+ if (!password) throw new Error('Renaming it, or moving it to another local schema, needs the password again.');
126
+ await dropRemote(c, old);
127
+ }
128
+ const n = await applyRemote(c, x, password, old && old.name === x.name && old.local_schema === x.local_schema ? old.grant_roles : []);
129
+ if (test) throw Object.assign(new Error('rolled back'), { tested: n });
130
+ if (old)
131
+ await c.query(
132
+ `update meta.remote_database set name = $2, description = $3, host = $4, port = $5, dbname = $6, user_name = $7, remote_schema = $8, local_schema = $9,
133
+ tables = $10, grant_roles = $11, writable = $12, server_name = $13, imported_at = now(), imported = $14, error = null, updated_by = $15, updated_at = now()
134
+ where id = $1`,
135
+ [editId, x.name, x.description, x.host, x.port, x.dbname, x.user_name, x.remote_schema, x.local_schema, x.tables, x.grant_roles, x.writable, serverNameOf(x.name), n, s.username]);
136
+ else
137
+ await c.query(
138
+ `insert into meta.remote_database (name, description, host, port, dbname, user_name, remote_schema, local_schema, tables, grant_roles, writable, server_name,
139
+ imported_at, imported, created_by, updated_by)
140
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13, $14, $14)`,
141
+ [x.name, x.description, x.host, x.port, x.dbname, x.user_name, x.remote_schema, x.local_schema, x.tables, x.grant_roles, x.writable, serverNameOf(x.name), n, s.username]);
142
+ return n;
143
+ });
144
+ await disconnect(owner);
145
+ await logActivity({ username: s.username, event: 'workspace', ip: clientIp(req),
146
+ detail: `remote database ${x.name} ${editId ? 'changed' : 'added'}: ${x.user_name}@${x.host}:${x.port}/${x.dbname} into ${x.local_schema} (${count} tables)`.slice(0, 1000) });
147
+ flash(s, `${x.name}: ${count} table(s) imported into ${x.local_schema}.`);
148
+ return back(reply, s, PAGE);
149
+ } catch (e) {
150
+ const tested = (e as { tested?: number }).tested;
151
+ if (tested !== undefined) {
152
+ flash(s, `The connection works: ${tested} table(s) would be imported into ${x.local_schema}. Nothing was saved.`);
153
+ } else {
154
+ const msg = (e as Error).message.includes('remote_database_name_key') ? `There is a remote database ${x.name} already.`
155
+ : (e as Error).message.includes('remote_database_local_schema_key') ? `Another remote database uses schema ${x.local_schema}.` : (e as Error).message;
156
+ flash(s, `${test ? 'The test failed' : 'Nothing was changed'}: ${msg}`, 'error');
157
+ }
158
+ return back(reply, s, retry);
159
+ }
160
+ });
161
+
162
+ app.post(`${PAGE}/:rid/refresh`, async (req: Req, reply) => {
163
+ const s = await administrator(req, reply, TITLE);
164
+ if (!s) return;
165
+ const r = (await rows()).find((x) => x.id === rid(req.params.rid));
166
+ if (!r) {
167
+ flash(s, 'No such remote database.', 'error');
168
+ return back(reply, s, PAGE);
169
+ }
170
+ try {
171
+ const n = await owner.tx(async (c) => applyRemote(c, r, null, r.grant_roles));
172
+ await owner.query('update meta.remote_database set imported_at = now(), imported = $2, error = null, updated_by = $3, updated_at = now() where id = $1', [r.id, n, s.username]);
173
+ await logActivity({ username: s.username, event: 'workspace', ip: clientIp(req), detail: `remote database ${r.name} imported again (${n} tables)` });
174
+ flash(s, `${r.name}: ${n} table(s) imported into ${r.local_schema}.`);
175
+ } catch (e) {
176
+ await owner.query('update meta.remote_database set error = $2 where id = $1', [r.id, (e as Error).message.slice(0, 1000)]);
177
+ flash(s, `${r.name}: nothing was changed: ${(e as Error).message}`, 'error');
178
+ }
179
+ return back(reply, s, PAGE);
180
+ });
181
+
182
+ app.post(`${PAGE}/:rid/delete`, async (req: Req, reply) => {
183
+ const s = await administrator(req, reply, TITLE);
184
+ if (!s) return;
185
+ const r = (await rows()).find((x) => x.id === rid(req.params.rid));
186
+ if (!r) {
187
+ flash(s, 'No such remote database.', 'error');
188
+ return back(reply, s, PAGE);
189
+ }
190
+ try {
191
+ await owner.tx(async (c) => {
192
+ await dropRemote(c, r);
193
+ await c.query('delete from meta.remote_database where id = $1', [r.id]);
194
+ });
195
+ await logActivity({ username: s.username, event: 'workspace', ip: clientIp(req), detail: `remote database ${r.name} deleted (schema ${r.local_schema})` });
196
+ flash(s, `${r.name} deleted with its schema ${r.local_schema}.`);
197
+ } catch (e) {
198
+ flash(s, `${r.name} was not deleted: ${(e as Error).message}`, 'error');
199
+ }
200
+ return back(reply, s, PAGE);
201
+ });
202
+
203
+ app.post(`${PAGE}/extension`, async (req: Req, reply) => {
204
+ const s = await administrator(req, reply, TITLE);
205
+ if (!s) return;
206
+ try {
207
+ await owner.query('create extension if not exists postgres_fdw');
208
+ await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: 'create extension if not exists postgres_fdw' });
209
+ flash(s, 'The postgres_fdw extension is installed.');
210
+ } catch (e) {
211
+ flash(s, `create extension postgres_fdw failed: ${(e as Error).message}`, 'error');
212
+ }
213
+ return back(reply, s, PAGE);
214
+ });
215
+ }
@@ -43,6 +43,20 @@ const asText = (v: unknown) => (v === null || v === undefined ? '' : Array.isArr
43
43
  /** Every component of an application with its searchable fields. */
44
44
  export async function appEntries(appId: number, query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> = (sql, params) => owner.query(sql, params)): Promise<Entry[]> {
45
45
  const pages = (await query('select * from meta.page where app_id = $1 order by page_no', [appId])).rows;
46
+ const rows = new Map<string, any[]>();
47
+ for (const [kind, spec] of Object.entries(COMPONENTS))
48
+ rows.set(kind, spec.scope === 'page'
49
+ ? (await query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1 order by p.page_no, t.seq, t.id`, [appId])).rows
50
+ : (await query(`select * from ${spec.table} where app_id = $1 order by id`, [appId])).rows);
51
+ return entriesOf(appId, pages, rows);
52
+ }
53
+
54
+ /**
55
+ * (sprint 147) The entries of pages and component rows however they were read: from the database (appEntries) or
56
+ * from an export's files (`pgkiln advise --offline`). A page row has `id` and `page_no`; a page component's row has
57
+ * `page_id`; rows are per kind of COMPONENTS, in order.
58
+ */
59
+ export function entriesOf(appId: number, pages: any[], rows: Map<string, any[]>): Entry[] {
46
60
  const pageById = new Map(pages.map((p) => [p.id, p]));
47
61
  const out: Entry[] = pages.map((p) => ({
48
62
  kind: 'page', id: p.id, label: `Page ${p.page_no}: ${p.name}`, kindLabel: 'Page', icon: 'file', pageNo: p.page_no,
@@ -50,10 +64,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
50
64
  fields: PAGE_FIELDS.map((f) => ({ ...f, value: asText(p[f.name]) })),
51
65
  }));
52
66
  for (const [kind, spec] of Object.entries(COMPONENTS)) {
53
- const rows = spec.scope === 'page'
54
- ? (await query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1 order by p.page_no, t.seq, t.id`, [appId])).rows
55
- : (await query(`select * from ${spec.table} where app_id = $1 order by id`, [appId])).rows;
56
- for (const row of rows) {
67
+ for (const row of rows.get(kind) ?? []) {
57
68
  const page = spec.scope === 'page' ? pageById.get(row.page_id) : null;
58
69
  out.push({
59
70
  kind, id: row.id, label: spec.summary(row), kindLabel: spec.label, icon: spec.icon, pageNo: page?.page_no ?? null,
@@ -35,7 +35,8 @@ interface Running {
35
35
  version: string;
36
36
  state: string;
37
37
  current_step: string | null;
38
- branches: boolean;
38
+ /** (144) the steps its open parallel branches are at */
39
+ branch_steps: string[];
39
40
  }
40
41
 
41
42
  const ID = /^\d{1,9}$/;
@@ -101,7 +102,8 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
101
102
  const running = (
102
103
  await owner.query<Running>(
103
104
  `select w.id::text, w.title, coalesce(w.version, '1') as version, w.state, w.current_step,
104
- exists (select 1 from meta.workflow_branch b where b.workflow_id = w.id and b.state in ('active', 'waiting', 'faulted')) as branches
105
+ array(select b.current_step from meta.workflow_branch b where b.workflow_id = w.id and b.state in ('active', 'waiting', 'faulted')
106
+ and b.current_step is not null order by b.id) as branch_steps
105
107
  from meta.workflow w where w.definition_id = $1 and w.state in ('active', 'waiting', 'faulted') order by w.id`,
106
108
  [d.id],
107
109
  )
@@ -117,16 +119,17 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
117
119
  const targetSteps = (target?.steps ?? []).filter((x) => x && typeof x.name === 'string' && x.type !== 'draft');
118
120
  const asked = mappingOf(q);
119
121
  // each step a chosen instance is at: the mapping asked for, or the step of the same name
120
- const current = [...new Set(chosen.map((w) => w.current_step).filter((x): x is string => !!x && STEP.test(x)))].sort();
122
+ // (144) and each step its open parallel branches are at
123
+ const current = [...new Set(chosen.flatMap((w) => [w.current_step, ...w.branch_steps]).filter((x): x is string => !!x && STEP.test(x)))].sort();
121
124
  const mapping = Object.fromEntries(current.map((st) => [st, st in asked ? asked[st] : targetSteps.some((x) => x.name === st) ? st : '']));
122
125
 
123
- let preview: { workflow_id: string; from_version: string | null; from_step: string | null; to_step: string | null; problem: string | null }[] | null = null;
126
+ let preview: { workflow_id: string; branch: string | null; from_version: string | null; from_step: string | null; to_step: string | null; problem: string | null }[] | null = null;
124
127
  let previewError: string | null = null;
125
128
  if (q.preview && to) {
126
129
  if (!chosen.length) previewError = 'Choose the instances to migrate.';
127
130
  else {
128
131
  try {
129
- preview = (await owner.query('select workflow_id::text, from_version, from_step, to_step, problem from meta.migrate_workflows($1, $2, $3::bigint[], $4, false, $5)',
132
+ preview = (await owner.query('select workflow_id::text, branch, from_version, from_step, to_step, problem from meta.migrate_workflows($1, $2, $3::bigint[], $4, false, $5)',
130
133
  [d.id, to, chosen.map((w) => w.id), mapping, s.username])).rows;
131
134
  } catch (e) {
132
135
  previewError = (e as Error).message;
@@ -134,6 +137,7 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
134
137
  }
135
138
  }
136
139
  const ready = !!preview && preview.every((r) => !r.problem);
140
+ const instances = preview ? new Set(preview.map((r) => r.workflow_id)).size : 0;
137
141
  const self = `${url(d)}/migrate`;
138
142
  const hidden = html`<input type="hidden" name="to" value="${to ?? ''}">${chosen.map((w) => html`<input type="hidden" name="wf" value="${w.id}">`)}${Object.entries(mapping).map(([k, v]) => html`<input type="hidden" name="${`m.${k}`}" value="${v}">`)}`;
139
143
  const body = !running.length
@@ -144,7 +148,8 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
144
148
  : html`${region('Migrate running instances', html`<form method="get" action="${self}">
145
149
  <input type="hidden" name="preview" value="1">
146
150
  <p class="muted u-mt0">Move running instances to a newer version: each goes on at the step of the new version its current step is mapped to,
147
- keeping its variables and the task it waits for. Every instance must have a mapping, or nothing moves. Instances inside parallel branches move once their branches have joined.</p>
151
+ keeping its variables and the task it waits for. An instance inside parallel branches moves as a whole: its main path at its join and each open
152
+ branch at its own step, each mapped (a join stays a join). Every position must have a mapping, or nothing moves.</p>
148
153
  <div class="form-grid">
149
154
  <div class="field"><label class="label" for="wf-to">To version</label>
150
155
  <select id="wf-to" name="to">${targets.map((v) => html`<option value="${v}"${v === to ? raw(' selected') : ''}>${v}${v === d.version ? ' (active)' : ' (inactive)'}</option>`)}</select>
@@ -155,7 +160,7 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
155
160
  ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Migrate</th><th class="num">Id</th><th>Title</th><th>Version</th><th>State</th><th>At</th></tr></thead><tbody>
156
161
  ${older.map((w) => html`<tr><td data-label="Migrate"><input type="checkbox" name="wf" value="${w.id}" id="${`wf-${w.id}`}"${picked.includes(w.id) ? raw(' checked') : ''} aria-label="${`Migrate instance ${w.id}`}"></td>
157
162
  <td class="num" data-label="Id"><label for="${`wf-${w.id}`}">${w.id}</label></td><td data-label="Title">${w.title}</td><td data-label="Version">${w.version}</td>
158
- <td data-label="State">${w.state}</td><td data-label="At">${w.branches ? 'parallel branches' : (w.current_step ?? '—')}</td></tr>`)}
163
+ <td data-label="State">${w.state}</td><td data-label="At">${w.current_step ?? '—'}${w.branch_steps.length ? html`<br><small class="muted">branches at ${w.branch_steps.join(', ')}</small>` : ''}</td></tr>`)}
159
164
  </tbody></table></div>`
160
165
  : html`<p class="muted">No running instance is older than version ${to}.</p>`}
161
166
  </fieldset>
@@ -169,14 +174,14 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
169
174
  </form>`)}
170
175
  ${previewError ? html`<div class="alert alert-error u-mt1" role="alert">${previewError}</div>` : ''}
171
176
  ${preview
172
- ? region('Preview', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">Id</th><th>From</th><th>To</th><th>Result</th></tr></thead><tbody>
173
- ${preview.map((r) => html`<tr><td class="num" data-label="Id">${r.workflow_id}</td><td data-label="From">${r.from_version ? `${r.from_version}: ${r.from_step ?? '—'}` : '—'}</td>
177
+ ? region('Preview', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">Id</th><th>Path</th><th>From</th><th>To</th><th>Result</th></tr></thead><tbody>
178
+ ${preview.map((r) => html`<tr><td class="num" data-label="Id">${r.workflow_id}</td><td data-label="Path">${r.branch ? `branch ${r.branch}` : 'main'}</td><td data-label="From">${r.from_version ? `${r.from_version}: ${r.from_step ?? '—'}` : '—'}</td>
174
179
  <td data-label="To">${r.to_step ? `${to}: ${r.to_step}` : '—'}</td><td data-label="Result">${r.problem ? html`<span class="wf-problem">${r.problem}</span>` : html`<span class="tag tag-info">ready</span>`}</td></tr>`)}
175
180
  </tbody></table></div>
176
181
  ${ready
177
182
  ? html`<form method="post" action="${self}" class="u-mt1">${csrf(s)}${hidden}
178
- <button class="btn btn-hot" data-confirm="${`Migrate ${preview.length} instance(s) to version ${to}?`}">Migrate ${preview.length} instance(s)</button></form>`
179
- : html`<p class="wf-problem">Map a step for every instance (or leave the others out) and preview again: nothing is migrated while one can't move.</p>`}`)
183
+ <button class="btn btn-hot" data-confirm="${`Migrate ${instances} instance(s) to version ${to}?`}">Migrate ${instances} instance(s)</button></form>`
184
+ : html`<p class="wf-problem">Map a step for every instance and branch (or leave the instances out) and preview again: nothing is migrated while one can't move.</p>`}`)
180
185
  : ''}
181
186
  ${target ? region(`Version ${to}`, html`<div class="wf-wrap">${workflowDiagram(target.steps, { id: 'target' })}</div>`) : ''}`;
182
187
  const main = html`${appHeader(a, 'shared', s)}<div class="ide-body">${body}</div>`;
@@ -198,8 +203,9 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
198
203
  if (!to) throw new Error('Choose the version to migrate to.');
199
204
  const rows = await owner.tx(async (c) =>
200
205
  (await c.query('select workflow_id::text, from_step, to_step from meta.migrate_workflows($1, $2, $3::bigint[], $4, true, $5)', [d.id, to, ids, mapping, s.username])).rows);
201
- logActivity({ appId: d.app_id, username: s.username, event: 'workflow', ip: clientIp(req), detail: `${d.name}: ${rows.length} instance(s) migrated to version ${to}` });
202
- flash(s, `${rows.length} instance(s) of ${d.name} migrated to version ${to}.`);
206
+ const moved = new Set(rows.map((r) => r.workflow_id)).size;
207
+ logActivity({ appId: d.app_id, username: s.username, event: 'workflow', ip: clientIp(req), detail: `${d.name}: ${moved} instance(s) migrated to version ${to}` });
208
+ flash(s, `${moved} instance(s) of ${d.name} migrated to version ${to}.`);
203
209
  return back(reply, s, `${BASE}/apps/${d.app_id}/shared?c=workflow_definition-${d.id}`);
204
210
  } catch (e) {
205
211
  flash(s, (e as Error).message, 'error');
package/src/cli/advise.ts CHANGED
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs';
9
9
  import { join, relative } from 'node:path';
10
10
  import { docToFiles, slug, type FileMap } from '../appfiles.ts';
11
11
  import { exportDoc, importDoc, readSource, UsageError, type Db } from './apps.ts';
12
+ import type { Finding } from '../builder/advisor.ts';
12
13
 
13
14
  export type Severity = 'error' | 'warning' | 'info';
14
15
  export const SEVERITIES: Severity[] = ['error', 'warning', 'info'];
@@ -33,6 +34,8 @@ export interface AdviseResult {
33
34
  dir: string | null;
34
35
  checked: number;
35
36
  findings: AdviseFinding[];
37
+ /** (sprint 147) checked from the files alone, without a database */
38
+ offline?: boolean;
36
39
  }
37
40
 
38
41
  const pad = (n: number) => String(n).padStart(4, '0');
@@ -52,6 +55,39 @@ export function fileOf(files: string[], f: { pageNo: number | null; kind: string
52
55
  return shared ?? yaml.find((p) => /^app\.(ya?ml|json)$/.test(p)) ?? null;
53
56
  }
54
57
 
58
+ /** An Advisor finding as the report's: where, the component's kind and page. */
59
+ function toAdvise(f: Finding): AdviseFinding {
60
+ const kind = f.entry?.kind ?? null;
61
+ const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
62
+ return {
63
+ severity: f.severity,
64
+ where: f.entry ? (kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : pageNo !== null ? `page ${pageNo}` : 'application',
65
+ field: f.field,
66
+ message: f.message,
67
+ kind,
68
+ label: f.entry?.label ?? '',
69
+ pageNo,
70
+ file: null,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * (sprint 147) Check an export's files alone, with no database: references, the login page and page groups, the
76
+ * pages' security defaults, workflow steps, process settings and JSON source profiles (adviseDoc). SQL is not
77
+ * planned and nothing is read from a catalog; the report says so.
78
+ */
79
+ export async function adviseOffline(target: string): Promise<AdviseResult> {
80
+ if (!existsSync(target)) throw new UsageError(`${target} not found (--offline checks an export: a directory, a .zip or a JSON file)`);
81
+ const { adviseDoc } = await import('../builder/advisor.ts');
82
+ const source = await readSource(target);
83
+ const files = source.files ?? docToFiles(source.doc);
84
+ const r = await adviseDoc(source.doc);
85
+ const findings = r.findings.map(toAdvise);
86
+ const paths = [...files.keys()];
87
+ for (const f of findings) f.file = fileOf(paths, f);
88
+ return { alias: String(source.doc?.app?.alias ?? 'app'), dir: source.files ? target : null, checked: 0, findings, offline: true };
89
+ }
90
+
55
91
  /**
56
92
  * Check an export (path) or an application in the database (alias). Nothing is ever committed: an export is
57
93
  * imported under a temporary alias in a transaction that is rolled back.
@@ -67,20 +103,7 @@ export async function adviseTarget(db: Db, target: string): Promise<AdviseResult
67
103
  const collect = async (appId: number) => {
68
104
  const r = await advise(appId, db);
69
105
  checked = r.checked;
70
- for (const f of r.findings) {
71
- const kind = f.entry?.kind ?? null;
72
- const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
73
- findings.push({
74
- severity: f.severity,
75
- where: f.entry ? (kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : pageNo !== null ? `page ${pageNo}` : 'application',
76
- field: f.field,
77
- message: f.message,
78
- kind,
79
- label: f.entry?.label ?? '',
80
- pageNo,
81
- file: null,
82
- });
83
- }
106
+ for (const f of r.findings) findings.push(toAdvise(f));
84
107
  };
85
108
  if (existsSync(target)) {
86
109
  const source = await readSource(target);
@@ -133,13 +156,14 @@ export function formatText(r: AdviseResult, base: string | null = null) {
133
156
  return r.findings.map((f) => {
134
157
  const path = pathOf(r, f, base);
135
158
  return `${f.severity.toUpperCase().padEnd(7)} ${f.where}${f.field ? ` (${f.field})` : ''}: ${f.message}${path ? `\n ${path}` : ''}\n`;
136
- }).join('') + `${r.alias}: ${r.checked} SQL fragment(s) checked; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
159
+ }).join('') + `${r.alias}: ${r.offline ? 'checked offline (references and security; SQL not planned without a database)' : `${r.checked} SQL fragment(s) checked`}; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
137
160
  }
138
161
 
139
162
  export function formatJson(r: AdviseResult, base: string | null = null) {
140
163
  return JSON.stringify({
141
164
  alias: r.alias,
142
165
  checked: r.checked,
166
+ offline: !!r.offline,
143
167
  counts: Object.fromEntries(SEVERITIES.map((s) => [s, r.findings.filter((f) => f.severity === s).length])),
144
168
  findings: r.findings.map((f) => ({ severity: f.severity, where: f.where, field: f.field, message: f.message, page: f.pageNo, kind: f.kind, file: pathOf(r, f, base) })),
145
169
  }, null, 2) + '\n';
package/src/cli/main.ts CHANGED
@@ -332,7 +332,7 @@ const COMMANDS: Record<string, Command> = {
332
332
  },
333
333
 
334
334
  advise: {
335
- usage: 'pgkiln advise <path | alias> [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
335
+ usage: 'pgkiln advise <path | alias> [--offline] [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
336
336
  summary: 'check an export or an application with the Advisor (for CI: text, JSON or SARIF)',
337
337
  details:
338
338
  'Runs the builder\'s Advisor: every SQL fragment is planned as the application\'s database role (unknown\n' +
@@ -342,10 +342,13 @@ const COMMANDS: Record<string, Command> = {
342
342
  'A path (an export directory, a .zip or a JSON export) is imported into a transaction that is rolled back:\n' +
343
343
  'nothing changes. It needs a database with pgkiln\'s migrations and the application\'s own tables (in CI, a\n' +
344
344
  'PostgreSQL service, pgkiln migrate and your migrations). An alias checks the application in the database.\n\n' +
345
+ 'With --offline a path is checked from its files alone, without a database: references, the login page, page\n' +
346
+ 'groups, security defaults, workflow steps and JSON source profiles; SQL is not planned.\n\n' +
345
347
  'SARIF points every finding at the file of its page or component in the export, so GitHub code scanning\n' +
346
348
  'annotates the pull request. Exit code 1 when a finding is at or above --fail-on (default: error).',
347
- options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' } },
349
+ options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' }, offline: { type: 'boolean' } },
348
350
  optionHelp: [
351
+ ['--offline', 'check an export\'s files without a database (no SQL planning)'],
349
352
  ['-f, --format <format>', 'text (default), json or sarif'],
350
353
  ['--fail-on <level>', 'exit code 1 from this level: error (default), warning, info or never'],
351
354
  ['-o, --output <file>', 'write the report to a file (the summary still goes to standard error)'],
@@ -358,10 +361,10 @@ const COMMANDS: Record<string, Command> = {
358
361
  if (!['text', 'json', 'sarif'].includes(format)) throw new UsageError(`--format: text, json or sarif, not ${format}`);
359
362
  if (!['error', 'warning', 'info', 'never'].includes(failOn)) throw new UsageError(`--fail-on: error, warning, info or never, not ${failOn}`);
360
363
  process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
361
- const { adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
364
+ const { adviseOffline, adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
362
365
  const { closePools } = await import('../db.ts');
363
366
  try {
364
- const r = await withDb((db) => adviseTarget(db, target));
367
+ const r = v.offline ? await adviseOffline(target) : await withDb((db) => adviseTarget(db, target));
365
368
  const base = (v.base as string) ?? null;
366
369
  const report = format === 'sarif' ? formatSarif(r, version(), base) : format === 'json' ? formatJson(r, base) : formatText(r, base);
367
370
  if (v.output) {
package/src/cli/mcp.ts CHANGED
@@ -438,15 +438,23 @@ export const TOOLS: Tool[] = [
438
438
  'unrestricted page protection, long sessions, administrators without a second factor). Each finding names its file in the export.',
439
439
  inputSchema: {
440
440
  type: 'object',
441
- properties: { ...aliasProp, path: { type: 'string', description: 'an export directory, .zip or .json instead of the application in the database' } },
441
+ properties: {
442
+ ...aliasProp,
443
+ path: { type: 'string', description: 'an export directory, .zip or .json instead of the application in the database' },
444
+ offline: { type: 'boolean', description: 'with path: check the files alone, without the database (references and security; no SQL planning)' },
445
+ },
442
446
  },
443
447
  annotations: { readOnlyHint: true },
444
448
  async run(a) {
445
449
  const path = str(a, 'path');
446
450
  const alias = str(a, 'alias');
447
451
  if (!path && !alias) throw new ToolError('give an alias or a path');
452
+ const { adviseOffline, adviseTarget, formatText } = await import('./advise.ts');
453
+ if (a.offline === true) {
454
+ if (!path) throw new ToolError('offline needs a path (an export)');
455
+ return cap(formatText(await adviseOffline(resolve(path))));
456
+ }
448
457
  process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
449
- const { adviseTarget, formatText } = await import('./advise.ts');
450
458
  return cap(formatText(await withDb((db) => adviseTarget(db, path ? resolve(path) : alias!))));
451
459
  },
452
460
  },
@@ -242,6 +242,18 @@ export function proposeProfile(docs: unknown[], o: { key: string[]; view: string
242
242
  export interface Grant { grantee: string; privileges: string[] }
243
243
 
244
244
  const triggerArg = (o: object) => lit(JSON.stringify(o));
245
+ const argColumns = (cols: JsonColumn[]) => cols.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) }));
246
+ /** (sprint 146) the argument of a source's view trigger, meta.json_source_write: what the view writes back */
247
+ export const viewTriggerArg = (x: JsonSource) =>
248
+ ({ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows, columns: argColumns(x.columns) });
249
+ /** (sprint 146) the argument of an array view's trigger, meta.json_array_write */
250
+ export const arrayTriggerArg = (x: JsonSource, a: JsonArray) =>
251
+ ({ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION, columns: argColumns(a.columns) });
252
+ /** (sprint 146) a profile type as format_type() names it in the catalog */
253
+ export const CATALOG_TYPE: Record<JsonType, string> = {
254
+ text: 'text', integer: 'integer', bigint: 'bigint', numeric: 'numeric', boolean: 'boolean', date: 'date',
255
+ timestamp: 'timestamp without time zone', timestamptz: 'timestamp with time zone', jsonb: 'jsonb',
256
+ };
245
257
  const grantsSql = (view: string, grants: Grant[]) =>
246
258
  grants.filter((g) => g.privileges.length).map((g) => `grant ${g.privileges.join(', ')} on ${view} to ${g.grantee === 'PUBLIC' ? 'public' : pg.escapeIdentifier(g.grantee)}`);
247
259
 
@@ -260,20 +272,14 @@ export function jsonSourceDdl(x: JsonSource, o: { app: string; grants: Grant[];
260
272
  const cols = x.columns.map((c) => `${columnSql(doc, c)} as ${ident(c.name)}`);
261
273
  out.push(`create view ${view} with (security_invoker = true) as\nselect ${[keySel, ...cols].join(',\n ')}\n from ${table} t`);
262
274
  out.push(`comment on view ${view} is ${lit(`JSON source ${x.name} of application ${o.app}: ${x.schema}.${x.table}.${x.column} (made by pgkiln; Shared Components → JSON sources makes it again)`)}`);
263
- out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg({
264
- name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows,
265
- columns: x.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
266
- })})`);
275
+ out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg(viewTriggerArg(x))})`);
267
276
  out.push(...grantsSql(view, o.grants));
268
277
  for (const { a, view: av } of arrays) {
269
278
  const arr = `${doc} #> ${pathSql(a.path)}`;
270
279
  const ecols = a.columns.map((c) => `${columnSql('e.v', c)} as ${ident(c.name)}`);
271
280
  out.push(`create view ${av} with (security_invoker = true) as\nselect ${[keySel, `k.key as ${ROW_KEY}`, `k.idx + 1 as ${POSITION}`, ...ecols].join(',\n ')}\n from ${table} t\n cross join lateral meta.jsonb_element_keys(${arr}) k\n cross join lateral (select (${arr}) -> k.idx as v) e`);
272
281
  out.push(`comment on view ${av} is ${lit(`JSON source ${x.name} of application ${o.app}: the array ${a.path.join('.')} of ${x.schema}.${x.table}.${x.column}, one row per element (made by pgkiln)`)}`);
273
- out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg({
274
- name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION,
275
- columns: a.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
276
- })})`);
282
+ out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg(arrayTriggerArg(x, a))})`);
277
283
  out.push(...grantsSql(av, o.grants));
278
284
  }
279
285
  return out;