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.
- 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 +7 -7
- package/docs/guide/03-builder.md +37 -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/docs/guide/18-cli.md +19 -2
- package/docs/guide/19-rest-data-sources.md +2 -1
- package/docs/guide/20-ai-agents.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/advisor.ts +305 -149
- package/src/builder/home.ts +1 -0
- package/src/builder/remote-databases.ts +215 -0
- package/src/builder/search.ts +15 -4
- package/src/builder/workflow-migrate.ts +19 -13
- package/src/cli/advise.ts +39 -15
- package/src/cli/main.ts +7 -4
- package/src/cli/mcp.ts +10 -2
- package/src/json-sources.ts +14 -8
- package/src/remote-db.ts +145 -0
- package/src/runtime/assistant.ts +54 -17
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
|
+
}
|
package/src/runtime/assistant.ts
CHANGED
|
@@ -382,7 +382,7 @@ export async function renderAssistant(ctx: PageContext, r: Region): Promise<Raw>
|
|
|
382
382
|
}
|
|
383
383
|
const welcome = c.welcome ? substitute(ctx.locale.tr(c.welcome), ctx, (v) => v) : '';
|
|
384
384
|
const usable = mayUse(ctx, r);
|
|
385
|
-
return html`<div class="assistant" data-assistant="${r.id}">
|
|
385
|
+
return html`<div class="assistant" data-assistant="${r.id}" data-you="${t('assistant.you')}" data-who="${t('assistant.assistant')}" data-used="${t('assistant.used', { tools: '{tools}' })}">
|
|
386
386
|
<ol class="assistant-log" aria-label="${t('assistant.conversation')}" aria-live="polite">
|
|
387
387
|
${welcome ? html`<li class="assistant-msg assistant-msg-assistant"><span class="assistant-who">${t('assistant.assistant')}</span><div class="assistant-text">${formatAnswer(welcome)}</div></li>` : ''}
|
|
388
388
|
${turns.map((x) => html`<li class="assistant-msg assistant-msg-${x.role === 'user' ? 'user' : 'assistant'}">
|
|
@@ -427,7 +427,10 @@ async function contextData(ctx: PageContext, c: AssistantConfig, question: strin
|
|
|
427
427
|
return parts;
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
-
|
|
430
|
+
/** (sprint 148, WS-4) where a streamed answer goes while the model writes it */
|
|
431
|
+
interface Live { text(delta: string): void; tool(name: string): void }
|
|
432
|
+
|
|
433
|
+
async function ask(ctx: PageContext, r: Region, message: string, live?: Live) {
|
|
431
434
|
const t = ctx.locale.t;
|
|
432
435
|
const c = configOf(r);
|
|
433
436
|
const agent = c.agent ? await agentFor(ctx.app.id, c.agent) : null;
|
|
@@ -482,6 +485,7 @@ async function ask(ctx: PageContext, r: Region, message: string) {
|
|
|
482
485
|
maxRounds: agent ? (agent.agent.max_steps ?? DEFAULT_STEPS) : (c.max_rounds ?? DEFAULT_ROUNDS),
|
|
483
486
|
runTool: runLogged,
|
|
484
487
|
...(agent ? { effort: agent.agent.effort ?? null, maxTokens: agent.agent.max_tokens ?? null } : {}),
|
|
488
|
+
...(live ? { onText: live.text, onTool: live.tool } : {}),
|
|
485
489
|
}, { appId: ctx.app.id, pageNo: ctx.page.page_no, user: ctx.user, source: 'assistant', debug: (l, x) => dbg(ctx, l, 'ai', x) });
|
|
486
490
|
dbg(ctx, 9, 'ai', () => `answer: ${res.text}`);
|
|
487
491
|
const turns: Turn[] = [...(conv?.turns ?? []), { role: 'user', text: message },
|
|
@@ -499,8 +503,11 @@ async function ask(ctx: PageContext, r: Region, message: string) {
|
|
|
499
503
|
if (extras.actions.length) ctx.session.state[`__AIACT${r.id}`] = JSON.stringify(extras.actions);
|
|
500
504
|
}
|
|
501
505
|
|
|
506
|
+
/** (sprint 148) the browser asked for the answer as it is written (app.js sends this with JavaScript) */
|
|
507
|
+
const wantsStream = (req: Req) => String(req.headers.accept ?? '').includes('text/event-stream');
|
|
508
|
+
|
|
502
509
|
export async function assistantRoutes(app: FastifyInstance) {
|
|
503
|
-
const action = async (req: Req, reply: FastifyReply, run: (ctx: PageContext, r: Region) => Promise<void
|
|
510
|
+
const action = async (req: Req, reply: FastifyReply, run: (ctx: PageContext, r: Region, live?: Live) => Promise<void>, stream = false) => {
|
|
504
511
|
const ctx = await loadContext(req, reply);
|
|
505
512
|
if (!ctx) return;
|
|
506
513
|
const body = req.body ?? {};
|
|
@@ -512,30 +519,60 @@ export async function assistantRoutes(app: FastifyInstance) {
|
|
|
512
519
|
const r = ctx.page.regions.find((x) => x.id === Number(req.params.id) && x.type === 'ai_assistant');
|
|
513
520
|
if (!r) return refuse(`ai assistant: region ${req.params.id} on page ${ctx.page.page_no}`);
|
|
514
521
|
if (!mayUse(ctx, r)) return refuse(`ai assistant: region ${r.id}: not signed in`);
|
|
522
|
+
const q = typeof body.params === 'string' ? new URLSearchParams(body.params.slice(0, 4000)).toString() : '';
|
|
523
|
+
const url = `${ctx.base}/${ctx.page.page_no}${q ? `?${q}` : ''}#R${r.id}`;
|
|
524
|
+
// (sprint 148, WS-4) a streamed answer: server-sent events with the text as the model writes it (JSON strings,
|
|
525
|
+
// shown as text by app.js), each tool before it runs, then "done" with the page to load: it shows the saved
|
|
526
|
+
// answer, its links and actions, or the error, exactly as the redirect below does without JavaScript.
|
|
527
|
+
let live: Live | undefined;
|
|
528
|
+
let out: FastifyReply['raw'] | null = null;
|
|
529
|
+
if (stream) {
|
|
530
|
+
reply.hijack();
|
|
531
|
+
out = reply.raw;
|
|
532
|
+
out.writeHead(200, {
|
|
533
|
+
'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', 'x-accel-buffering': 'no',
|
|
534
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'same-origin',
|
|
535
|
+
});
|
|
536
|
+
const raw = out;
|
|
537
|
+
const send = (event: string, data: unknown) => {
|
|
538
|
+
if (!raw.writableEnded && !raw.destroyed) raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
539
|
+
};
|
|
540
|
+
live = { text: (d) => send('text', d), tool: (n) => send('tool', n) };
|
|
541
|
+
}
|
|
542
|
+
let forbidden = false;
|
|
515
543
|
try {
|
|
516
|
-
await run(ctx, r);
|
|
544
|
+
await run(ctx, r, live);
|
|
517
545
|
} catch (e) {
|
|
518
|
-
if (e instanceof Forbidden)
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
546
|
+
if (e instanceof Forbidden) {
|
|
547
|
+
if (!out) return refuse(`ai assistant: region ${r.id} on page ${ctx.page.page_no}`);
|
|
548
|
+
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail: `ai assistant: region ${r.id} on page ${ctx.page.page_no}` });
|
|
549
|
+
ctx.session.state.__FLASH_ERROR = ctx.locale.t('error.access_denied');
|
|
550
|
+
forbidden = true;
|
|
551
|
+
} else {
|
|
552
|
+
const c = configOf(r);
|
|
553
|
+
const msg = e instanceof InputError ? e.message
|
|
554
|
+
: e instanceof AiError ? (c.error_message && e.kind !== 'limit' ? ctx.locale.tr(c.error_message) : e.message)
|
|
555
|
+
: await publicError(ctx, e, 'AI assistant');
|
|
556
|
+
if (e instanceof AiError) dbg(ctx, 1, 'ai', `AI assistant: ${e.kind}: ${e.message}`);
|
|
557
|
+
ctx.session.state.__FLASH_ERROR = msg;
|
|
558
|
+
if (typeof body.message === 'string') ctx.session.state[`__AIDRAFT${r.id}`] = body.message.slice(0, MAX_MESSAGE_CHARS);
|
|
559
|
+
}
|
|
526
560
|
}
|
|
527
561
|
await saveState(ctx.session);
|
|
528
|
-
|
|
529
|
-
|
|
562
|
+
if (out) {
|
|
563
|
+
if (!out.writableEnded && !out.destroyed) out.end(`event: done\ndata: ${JSON.stringify({ url, ...(forbidden ? { forbidden: true } : {}) })}\n\n`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
return reply.redirect(url, 303);
|
|
530
567
|
};
|
|
531
568
|
|
|
532
569
|
app.post('/a/:alias/:page/assistant/:id/send', async (req: Req, reply) =>
|
|
533
|
-
action(req, reply, async (ctx, r) => {
|
|
570
|
+
action(req, reply, async (ctx, r, live) => {
|
|
534
571
|
const message = typeof req.body?.message === 'string' ? req.body.message.replace(/\r\n?/g, '\n').trim() : '';
|
|
535
572
|
if (!message) throw new InputError(ctx.locale.t('assistant.empty'));
|
|
536
573
|
if (message.length > MAX_MESSAGE_CHARS) throw new InputError(ctx.locale.t('assistant.too_long', { max: String(MAX_MESSAGE_CHARS) }));
|
|
537
|
-
await ask(ctx, r, message);
|
|
538
|
-
}),
|
|
574
|
+
await ask(ctx, r, message, live);
|
|
575
|
+
}, wantsStream(req)),
|
|
539
576
|
);
|
|
540
577
|
|
|
541
578
|
app.post('/a/:alias/:page/assistant/:id/clear', async (req: Req, reply) =>
|