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/db/migrations/144_workflow_migrate_branches.sql +186 -0
- package/db/migrations/145_remote_databases.sql +41 -0
- package/docs/apex-feature-parity.md +5 -5
- package/docs/guide/03-builder.md +32 -0
- package/docs/guide/04-pages-and-regions.md +8 -2
- package/docs/guide/06-processing.md +13 -2
- package/docs/guide/15-extensions.md +1 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +83 -0
- package/src/ai/chat.ts +32 -5
- package/src/app.ts +2 -0
- package/src/builder/home.ts +1 -0
- package/src/builder/remote-databases.ts +215 -0
- package/src/builder/workflow-migrate.ts +19 -13
- package/src/remote-db.ts +145 -0
- package/src/runtime/assistant.ts +54 -17
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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);
|
package/src/builder/home.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 ${
|
|
179
|
-
: html`<p class="wf-problem">Map a step for every instance (or leave the
|
|
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
|
-
|
|
202
|
-
|
|
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/remote-db.ts
ADDED
|
@@ -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
|
+
}
|