pgkiln 0.79.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.
package/src/ai/chat.ts CHANGED
@@ -52,6 +52,12 @@ export interface ChatRequest {
52
52
  effort?: 'low' | 'medium' | 'high' | null;
53
53
  /** (121) an agent's output limit per call (never above the service's maximum) */
54
54
  maxTokens?: number | null;
55
+ /**
56
+ * (sprint 148, WS-4) streamed answers: the text as the model writes it, in pieces (also the text of a round that
57
+ * then calls tools), and each tool before it runs. Without them a call waits for the whole answer, as before.
58
+ */
59
+ onText?: (delta: string) => void;
60
+ onTool?: (name: string) => void;
55
61
  }
56
62
 
57
63
  export interface ChatResult {
@@ -122,7 +128,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
122
128
  const msg = await metered(s, limits, info, async () => {
123
129
  let m: Anthropic.Beta.BetaMessage;
124
130
  try {
125
- m = await client.beta.messages.stream({
131
+ const stream = client.beta.messages.stream({
126
132
  model: s.model,
127
133
  max_tokens: outputLimit(s, req),
128
134
  system: req.system,
@@ -130,7 +136,9 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
130
136
  ...(tools.length ? { tools } : {}),
131
137
  ...((req.effort ?? s.effort) ? { output_config: { effort: (req.effort ?? s.effort)! } } : {}),
132
138
  ...(s.refusal_fallback ? { betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' as const } : {}),
133
- }).finalMessage();
139
+ });
140
+ if (req.onText) stream.on('text', (delta) => req.onText!(delta));
141
+ m = await stream.finalMessage();
134
142
  } catch (e) {
135
143
  throw claudeError(e);
136
144
  }
@@ -150,6 +158,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
150
158
  if (round >= req.maxRounds) throw new AiError('output', TOO_MANY_ROUNDS);
151
159
  const results: Anthropic.Beta.BetaToolResultBlockParam[] = [];
152
160
  for (const call of calls) {
161
+ req.onTool?.(call.name);
153
162
  const out = await req.runTool(call.name, (call.input ?? {}) as Record<string, unknown>);
154
163
  used.push({ name: call.name, ok: !out.isError });
155
164
  results.push({ type: 'tool_result', tool_use_id: call.id, content: clip(out.content), ...(out.isError ? { is_error: true } : {}) });
@@ -169,15 +178,32 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
169
178
  const res = await metered(s, limits, info, async () => {
170
179
  let r: OpenAI.Chat.ChatCompletion;
171
180
  try {
172
- r = await client.chat.completions.create({
181
+ const params = {
173
182
  model: s.model,
174
183
  // the system prompt is sent with each request, not kept in the history
175
- messages: [{ role: 'system', content: req.system }, ...messages],
184
+ messages: [{ role: 'system' as const, content: req.system }, ...messages],
176
185
  max_completion_tokens: outputLimit(s, req),
177
186
  ...(tools.length ? { tools } : {}),
178
187
  // (121) an agent's reasoning effort; models without reasoning refuse it, so none is sent without one
179
188
  ...(req.effort ? { reasoning_effort: req.effort } : {}),
180
- });
189
+ };
190
+ if (req.onText) {
191
+ // (sprint 148) streamed: the content as it comes; the usage in the last chunk
192
+ // an OpenAI-compatible endpoint that refuses streaming (400) gets the plain request once
193
+ let streamed: OpenAI.Chat.ChatCompletion | null = null;
194
+ let sent = false;
195
+ try {
196
+ const stream = client.chat.completions.stream({ ...params, stream_options: { include_usage: true } });
197
+ stream.on('content', (delta) => {
198
+ sent = true;
199
+ req.onText!(delta);
200
+ });
201
+ streamed = await stream.finalChatCompletion();
202
+ } catch (e) {
203
+ if (sent || (e as { status?: number }).status !== 400) throw e;
204
+ }
205
+ r = streamed ?? (await client.chat.completions.create(params));
206
+ } else r = await client.chat.completions.create(params);
181
207
  } catch (e) {
182
208
  throw openAiError(e);
183
209
  }
@@ -198,6 +224,7 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
198
224
  for (const call of calls) {
199
225
  let input: Record<string, unknown>;
200
226
  let out: ToolOutcome;
227
+ req.onTool?.(call.function.name);
201
228
  try {
202
229
  input = JSON.parse(call.function.arguments || '{}');
203
230
  out = input && typeof input === 'object' && !Array.isArray(input) ? await req.runTool(call.function.name, input) : { content: 'The arguments are not a JSON object.', isError: true };
package/src/app.ts CHANGED
@@ -99,6 +99,7 @@ import { mailQueueRoutes } from './builder/mail-queue.ts';
99
99
  import { networkAccessRoutes } from './builder/network-access.ts';
100
100
  import { workspaceMailRoutes } from './builder/workspace-mail.ts';
101
101
  import { workspaceResourceRoutes } from './builder/workspace-resources.ts';
102
+ import { remoteDatabaseRoutes } from './builder/remote-databases.ts';
102
103
  import { runtimeScope } from './db.ts';
103
104
  // (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
104
105
  import { startTelemetry } from './telemetry/export.ts';
@@ -288,6 +289,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
288
289
  await app.register(networkAccessRoutes);
289
290
  await app.register(workspaceMailRoutes);
290
291
  await app.register(workspaceResourceRoutes);
292
+ await app.register(remoteDatabaseRoutes);
291
293
  // (sprint 138, AP-5) an application's Utilities
292
294
  await app.register(utilityRoutes);
293
295
  await app.register(dependencyRoutes);
@@ -288,6 +288,7 @@ export async function homeRoutes(app: FastifyInstance) {
288
288
  ${card(`${BASE}/backups`, icon('archive'), 'Manage backups', 'The backups of every application of this workspace; back them all up at once.')}
289
289
  ${card(`${BASE}/workspace-files`, icon('file'), 'Static workspace files', 'CSS, JavaScript, images and fonts every application of this workspace can load (#WORKSPACE_FILES#).')}
290
290
  ${card(`${BASE}/remote-servers`, icon('server'), 'Remote servers', 'Named base URLs for REST data sources, catalogs and AI services: change one value to move them all.')}
291
+ ${(await isAdmin(s.username)) ? card(`${BASE}/remote-databases`, icon('database'), 'Remote databases', 'Another PostgreSQL database\'s tables as foreign tables (postgres_fdw) the applications query like their own (administrators).') : ''}
291
292
  ${card(`${BASE}/workspace-credentials`, icon('key'), 'Web credentials', 'Keys and OAuth2 clients the applications of this workspace use by name (secrets encrypted, write-only).')}
292
293
  ${card(`${BASE}/rest-catalogs`, icon('globe'), 'REST Source Catalogs', 'Web services described once for the workspace (e.g. from an OpenAPI document); applications make REST data sources from them.')}
293
294
  ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), 'Export and import', 'This workspace\'s components as one file: settings, developers, groups, links, catalogs, scripts, blueprints (workspace administrators).') : ''}
@@ -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
+ }
@@ -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');
@@ -0,0 +1,145 @@
1
+ import pg from 'pg';
2
+
3
+ // (sprint 150, AP-7 leftover; APEX: REST Enabled SQL, regions on another database) Remote databases: another
4
+ // PostgreSQL database's tables as foreign tables (postgres_fdw) in a local schema of their own, which applications
5
+ // query like their own tables (as their database role, in their transaction, with the grants given here). See
6
+ // migration 145 for the table; Workspace utilities → Remote databases (src/builder/remote-databases.ts) for the page.
7
+ //
8
+ // Everything runs on the owner connection in the caller's transaction:
9
+ // create (or alter) server pgkiln_rdb_<name> foreign data wrapper postgres_fdw options (host, port, dbname)
10
+ // create (or alter) user mapping for public … options (user, password) -- the password only lives here
11
+ // create schema <local> (marked as this remote database's by its comment), drop its foreign tables,
12
+ // import foreign schema <remote> [limit to (…)] from server … into <local>
13
+ // revoke from public; grant usage and select (with writable: insert, update, delete) to the chosen app roles
14
+ // Names reach SQL quoted (escapeIdentifier) or as literals (escapeLiteral), after the checks below.
15
+
16
+ export interface RemoteDb {
17
+ id?: number;
18
+ name: string;
19
+ description: string | null;
20
+ host: string;
21
+ port: number;
22
+ dbname: string;
23
+ user_name: string;
24
+ remote_schema: string;
25
+ local_schema: string;
26
+ tables: string[];
27
+ grant_roles: string[];
28
+ writable: boolean;
29
+ }
30
+
31
+ type Q = { query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult<any>> };
32
+
33
+ const id = pg.escapeIdentifier;
34
+ const lit = pg.escapeLiteral;
35
+ export const NAME = /^[A-Z][A-Z0-9_]{0,29}$/;
36
+ const LOCAL = /^[a-z_][a-z0-9_]{0,62}$/;
37
+ // a host name, an IPv4 or an IPv6 address (no spaces, quotes or option syntax)
38
+ const HOST = /^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\[?[0-9A-Fa-f:.]{2,45}\]?)$/;
39
+ // a remote name (database, user, schema, table): any printable text without control characters, at most 63
40
+ const REMOTE = /^[^\x00-\x1f\x7f]{1,63}$/;
41
+ const RESERVED = /^(pg_.*|information_schema|meta|public|pgkiln.*)$/;
42
+ export const MAX_TABLES = 200;
43
+
44
+ export const serverNameOf = (name: string) => `pgkiln_rdb_${name.toLowerCase()}`;
45
+ const marker = (name: string) => `pgkiln remote database ${name}`;
46
+
47
+ /** What is wrong with a remote database's settings (empty: nothing). `roles`: the roles that may be granted. */
48
+ export function remoteDbProblems(x: RemoteDb, roles: string[]): string[] {
49
+ const p: string[] = [];
50
+ if (!NAME.test(x.name)) p.push('The name is an upper case static id (letters, digits, _; at most 30), e.g. CRM.');
51
+ if (!HOST.test(x.host)) p.push('The host is a host name or an IP address.');
52
+ if (!Number.isInteger(x.port) || x.port < 1 || x.port > 65535) p.push('The port is a number from 1 to 65535.');
53
+ if (!REMOTE.test(x.dbname)) p.push('The database is a name of 1 to 63 characters.');
54
+ if (!REMOTE.test(x.user_name)) p.push('The user is a name of 1 to 63 characters.');
55
+ if (!REMOTE.test(x.remote_schema)) p.push('The remote schema is a name of 1 to 63 characters.');
56
+ if (!LOCAL.test(x.local_schema) || RESERVED.test(x.local_schema)) p.push('The local schema is a new lower case name (not public, meta or pg_…), e.g. crm.');
57
+ if (x.tables.length > MAX_TABLES) p.push(`At most ${MAX_TABLES} tables.`);
58
+ for (const t of x.tables) if (!REMOTE.test(t)) p.push(`The table name "${t.slice(0, 70)}" is not a name of 1 to 63 characters.`);
59
+ for (const r of x.grant_roles) if (!roles.includes(r)) p.push(`${r} is not the database role of an application.`);
60
+ if (x.description && x.description.length > 1000) p.push('The description has at most 1000 characters.');
61
+ return p;
62
+ }
63
+
64
+ export interface FdwStatus { installed: boolean; available: boolean; canCreate: boolean }
65
+
66
+ /** Whether postgres_fdw is installed in this database (and whether the connection could create it). */
67
+ export async function fdwStatus(db: Q): Promise<FdwStatus> {
68
+ const r = (await db.query(
69
+ `select exists (select 1 from pg_catalog.pg_extension where extname = 'postgres_fdw') as installed,
70
+ exists (select 1 from pg_catalog.pg_available_extensions where name = 'postgres_fdw') as available,
71
+ (select rolsuper from pg_catalog.pg_roles where rolname = current_user) as superuser`)).rows[0];
72
+ return { installed: r.installed, available: r.available, canCreate: !!r.superuser };
73
+ }
74
+
75
+ /** The database roles of the applications (the roles a remote database's tables may be granted to). */
76
+ export async function applicationRoles(db: Q): Promise<string[]> {
77
+ return (await db.query(
78
+ `select distinct a.db_role as r from meta.app a where a.db_role is not null
79
+ and exists (select 1 from pg_catalog.pg_roles x where x.rolname = a.db_role) order by 1`)).rows.map((x) => x.r);
80
+ }
81
+
82
+ /**
83
+ * Make (or change) the server, user mapping and local schema, and import the tables again: the caller's transaction
84
+ * (nothing stays when it fails or is rolled back). `password`: null keeps the mapping's password (it must exist
85
+ * then); `before`: the roles granted until now (their grants go first). Returns the number of foreign tables.
86
+ */
87
+ export async function applyRemote(c: Q, x: RemoteDb, password: string | null, before: string[] = []): Promise<number> {
88
+ if (!(await fdwStatus(c)).installed) throw new Error('The postgres_fdw extension is not installed in this database (Workspace utilities → Remote databases).');
89
+ const server = serverNameOf(x.name);
90
+ const exists = (await c.query('select 1 from pg_catalog.pg_foreign_server where srvname = $1', [server])).rowCount;
91
+ const opts: [string, string][] = [['host', x.host], ['port', String(x.port)], ['dbname', x.dbname]];
92
+ if (exists) await c.query(`alter server ${id(server)} options (${opts.map(([k, v]) => `set ${k} ${lit(v)}`).join(', ')})`);
93
+ else await c.query(`create server ${id(server)} foreign data wrapper postgres_fdw options (${opts.map(([k, v]) => `${k} ${lit(v)}`).join(', ')})`);
94
+ const mapped = (await c.query(`select 1 from pg_catalog.pg_user_mappings where srvname = $1 and usename = 'public'`, [server])).rowCount;
95
+ if (!mapped) {
96
+ if (!password) throw new Error('A password is needed the first time (postgres_fdw connects with the remote user and its password).');
97
+ await c.query(`create user mapping for public server ${id(server)} options (user ${lit(x.user_name)}, password ${lit(password)})`);
98
+ } else await c.query(`alter user mapping for public server ${id(server)} options (set user ${lit(x.user_name)}${password ? `, set password ${lit(password)}` : ''})`);
99
+
100
+ // the local schema: new, or this remote database's own
101
+ const schema = (await c.query(
102
+ `select pg_catalog.obj_description(n.oid, 'pg_namespace') as comment from pg_catalog.pg_namespace n where n.nspname = $1`, [x.local_schema])).rows[0];
103
+ if (schema && schema.comment !== marker(x.name))
104
+ throw new Error(`Schema ${x.local_schema} exists and is not remote database ${x.name}'s: choose another local schema.`);
105
+ if (!schema) {
106
+ await c.query(`create schema ${id(x.local_schema)}`);
107
+ await c.query(`comment on schema ${id(x.local_schema)} is ${lit(marker(x.name))}`);
108
+ }
109
+ // the foreign tables again: drop the old ones (a view that uses one stops this, and says which)
110
+ const old = (await c.query(
111
+ `select c.relname as t from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace
112
+ where n.nspname = $1 and c.relkind = 'f'`, [x.local_schema])).rows;
113
+ for (const o of old) await c.query(`drop foreign table ${id(x.local_schema)}.${id(o.t)}`);
114
+ await c.query(`import foreign schema ${id(x.remote_schema)}${x.tables.length ? ` limit to (${x.tables.map(id).join(', ')})` : ''}
115
+ from server ${id(server)} into ${id(x.local_schema)}`);
116
+ const count = Number((await c.query(
117
+ `select count(*) as n from pg_catalog.pg_class c join pg_catalog.pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relkind = 'f'`,
118
+ [x.local_schema])).rows[0].n);
119
+ // grants: only the chosen application roles, nobody else
120
+ await c.query(`revoke all on all tables in schema ${id(x.local_schema)} from public`);
121
+ await c.query(`revoke all on schema ${id(x.local_schema)} from public`);
122
+ for (const r of new Set([...before, ...x.grant_roles])) {
123
+ if (!(await c.query('select 1 from pg_catalog.pg_roles where rolname = $1', [r])).rowCount) continue;
124
+ await c.query(`revoke all on all tables in schema ${id(x.local_schema)} from ${id(r)}`);
125
+ await c.query(`revoke all on schema ${id(x.local_schema)} from ${id(r)}`);
126
+ }
127
+ for (const r of x.grant_roles) {
128
+ await c.query(`grant usage on schema ${id(x.local_schema)} to ${id(r)}`);
129
+ await c.query(`grant ${x.writable ? 'select, insert, update, delete' : 'select'} on all tables in schema ${id(x.local_schema)} to ${id(r)}`);
130
+ }
131
+ return count;
132
+ }
133
+
134
+ /** Drop the local schema (when it is this remote database's) and the server with its user mapping. */
135
+ export async function dropRemote(c: Q, x: Pick<RemoteDb, 'name' | 'local_schema'>) {
136
+ const schema = (await c.query(
137
+ `select pg_catalog.obj_description(n.oid, 'pg_namespace') as comment from pg_catalog.pg_namespace n where n.nspname = $1`, [x.local_schema])).rows[0];
138
+ if (schema && schema.comment === marker(x.name)) await c.query(`drop schema ${id(x.local_schema)} cascade`);
139
+ await c.query(`drop server if exists ${id(serverNameOf(x.name))} cascade`);
140
+ }
141
+
142
+ /** postgres_fdw keeps connections per session: drop this connection's, so the next query uses new settings. */
143
+ export async function disconnect(c: Q) {
144
+ await c.query(`select postgres_fdw_disconnect_all()`).catch(() => undefined);
145
+ }