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
|
@@ -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
|
+
}
|
package/src/builder/search.ts
CHANGED
|
@@ -43,6 +43,20 @@ const asText = (v: unknown) => (v === null || v === undefined ? '' : Array.isArr
|
|
|
43
43
|
/** Every component of an application with its searchable fields. */
|
|
44
44
|
export async function appEntries(appId: number, query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }> = (sql, params) => owner.query(sql, params)): Promise<Entry[]> {
|
|
45
45
|
const pages = (await query('select * from meta.page where app_id = $1 order by page_no', [appId])).rows;
|
|
46
|
+
const rows = new Map<string, any[]>();
|
|
47
|
+
for (const [kind, spec] of Object.entries(COMPONENTS))
|
|
48
|
+
rows.set(kind, spec.scope === 'page'
|
|
49
|
+
? (await query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1 order by p.page_no, t.seq, t.id`, [appId])).rows
|
|
50
|
+
: (await query(`select * from ${spec.table} where app_id = $1 order by id`, [appId])).rows);
|
|
51
|
+
return entriesOf(appId, pages, rows);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* (sprint 147) The entries of pages and component rows however they were read: from the database (appEntries) or
|
|
56
|
+
* from an export's files (`pgkiln advise --offline`). A page row has `id` and `page_no`; a page component's row has
|
|
57
|
+
* `page_id`; rows are per kind of COMPONENTS, in order.
|
|
58
|
+
*/
|
|
59
|
+
export function entriesOf(appId: number, pages: any[], rows: Map<string, any[]>): Entry[] {
|
|
46
60
|
const pageById = new Map(pages.map((p) => [p.id, p]));
|
|
47
61
|
const out: Entry[] = pages.map((p) => ({
|
|
48
62
|
kind: 'page', id: p.id, label: `Page ${p.page_no}: ${p.name}`, kindLabel: 'Page', icon: 'file', pageNo: p.page_no,
|
|
@@ -50,10 +64,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
|
|
|
50
64
|
fields: PAGE_FIELDS.map((f) => ({ ...f, value: asText(p[f.name]) })),
|
|
51
65
|
}));
|
|
52
66
|
for (const [kind, spec] of Object.entries(COMPONENTS)) {
|
|
53
|
-
const
|
|
54
|
-
? (await query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1 order by p.page_no, t.seq, t.id`, [appId])).rows
|
|
55
|
-
: (await query(`select * from ${spec.table} where app_id = $1 order by id`, [appId])).rows;
|
|
56
|
-
for (const row of rows) {
|
|
67
|
+
for (const row of rows.get(kind) ?? []) {
|
|
57
68
|
const page = spec.scope === 'page' ? pageById.get(row.page_id) : null;
|
|
58
69
|
out.push({
|
|
59
70
|
kind, id: row.id, label: spec.summary(row), kindLabel: spec.label, icon: spec.icon, pageNo: page?.page_no ?? null,
|
|
@@ -35,7 +35,8 @@ interface Running {
|
|
|
35
35
|
version: string;
|
|
36
36
|
state: string;
|
|
37
37
|
current_step: string | null;
|
|
38
|
-
branches
|
|
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/cli/advise.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs';
|
|
|
9
9
|
import { join, relative } from 'node:path';
|
|
10
10
|
import { docToFiles, slug, type FileMap } from '../appfiles.ts';
|
|
11
11
|
import { exportDoc, importDoc, readSource, UsageError, type Db } from './apps.ts';
|
|
12
|
+
import type { Finding } from '../builder/advisor.ts';
|
|
12
13
|
|
|
13
14
|
export type Severity = 'error' | 'warning' | 'info';
|
|
14
15
|
export const SEVERITIES: Severity[] = ['error', 'warning', 'info'];
|
|
@@ -33,6 +34,8 @@ export interface AdviseResult {
|
|
|
33
34
|
dir: string | null;
|
|
34
35
|
checked: number;
|
|
35
36
|
findings: AdviseFinding[];
|
|
37
|
+
/** (sprint 147) checked from the files alone, without a database */
|
|
38
|
+
offline?: boolean;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
const pad = (n: number) => String(n).padStart(4, '0');
|
|
@@ -52,6 +55,39 @@ export function fileOf(files: string[], f: { pageNo: number | null; kind: string
|
|
|
52
55
|
return shared ?? yaml.find((p) => /^app\.(ya?ml|json)$/.test(p)) ?? null;
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/** An Advisor finding as the report's: where, the component's kind and page. */
|
|
59
|
+
function toAdvise(f: Finding): AdviseFinding {
|
|
60
|
+
const kind = f.entry?.kind ?? null;
|
|
61
|
+
const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
|
|
62
|
+
return {
|
|
63
|
+
severity: f.severity,
|
|
64
|
+
where: f.entry ? (kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : pageNo !== null ? `page ${pageNo}` : 'application',
|
|
65
|
+
field: f.field,
|
|
66
|
+
message: f.message,
|
|
67
|
+
kind,
|
|
68
|
+
label: f.entry?.label ?? '',
|
|
69
|
+
pageNo,
|
|
70
|
+
file: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* (sprint 147) Check an export's files alone, with no database: references, the login page and page groups, the
|
|
76
|
+
* pages' security defaults, workflow steps, process settings and JSON source profiles (adviseDoc). SQL is not
|
|
77
|
+
* planned and nothing is read from a catalog; the report says so.
|
|
78
|
+
*/
|
|
79
|
+
export async function adviseOffline(target: string): Promise<AdviseResult> {
|
|
80
|
+
if (!existsSync(target)) throw new UsageError(`${target} not found (--offline checks an export: a directory, a .zip or a JSON file)`);
|
|
81
|
+
const { adviseDoc } = await import('../builder/advisor.ts');
|
|
82
|
+
const source = await readSource(target);
|
|
83
|
+
const files = source.files ?? docToFiles(source.doc);
|
|
84
|
+
const r = await adviseDoc(source.doc);
|
|
85
|
+
const findings = r.findings.map(toAdvise);
|
|
86
|
+
const paths = [...files.keys()];
|
|
87
|
+
for (const f of findings) f.file = fileOf(paths, f);
|
|
88
|
+
return { alias: String(source.doc?.app?.alias ?? 'app'), dir: source.files ? target : null, checked: 0, findings, offline: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
55
91
|
/**
|
|
56
92
|
* Check an export (path) or an application in the database (alias). Nothing is ever committed: an export is
|
|
57
93
|
* imported under a temporary alias in a transaction that is rolled back.
|
|
@@ -67,20 +103,7 @@ export async function adviseTarget(db: Db, target: string): Promise<AdviseResult
|
|
|
67
103
|
const collect = async (appId: number) => {
|
|
68
104
|
const r = await advise(appId, db);
|
|
69
105
|
checked = r.checked;
|
|
70
|
-
for (const f of r.findings)
|
|
71
|
-
const kind = f.entry?.kind ?? null;
|
|
72
|
-
const pageNo = f.entry?.pageNo ?? (/^Page (\d+)\b/.exec(f.field) ? Number(/^Page (\d+)\b/.exec(f.field)![1]) : null);
|
|
73
|
-
findings.push({
|
|
74
|
-
severity: f.severity,
|
|
75
|
-
where: f.entry ? (kind === 'page' ? f.entry.label : `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.kindLabel.toLowerCase()} ${f.entry.label}`) : pageNo !== null ? `page ${pageNo}` : 'application',
|
|
76
|
-
field: f.field,
|
|
77
|
-
message: f.message,
|
|
78
|
-
kind,
|
|
79
|
-
label: f.entry?.label ?? '',
|
|
80
|
-
pageNo,
|
|
81
|
-
file: null,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
106
|
+
for (const f of r.findings) findings.push(toAdvise(f));
|
|
84
107
|
};
|
|
85
108
|
if (existsSync(target)) {
|
|
86
109
|
const source = await readSource(target);
|
|
@@ -133,13 +156,14 @@ export function formatText(r: AdviseResult, base: string | null = null) {
|
|
|
133
156
|
return r.findings.map((f) => {
|
|
134
157
|
const path = pathOf(r, f, base);
|
|
135
158
|
return `${f.severity.toUpperCase().padEnd(7)} ${f.where}${f.field ? ` (${f.field})` : ''}: ${f.message}${path ? `\n ${path}` : ''}\n`;
|
|
136
|
-
}).join('') + `${r.alias}: ${r.checked} SQL fragment(s) checked; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
|
|
159
|
+
}).join('') + `${r.alias}: ${r.offline ? 'checked offline (references and security; SQL not planned without a database)' : `${r.checked} SQL fragment(s) checked`}; ${count('error')} error(s), ${count('warning')} warning(s), ${count('info')} note(s).\n`;
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
export function formatJson(r: AdviseResult, base: string | null = null) {
|
|
140
163
|
return JSON.stringify({
|
|
141
164
|
alias: r.alias,
|
|
142
165
|
checked: r.checked,
|
|
166
|
+
offline: !!r.offline,
|
|
143
167
|
counts: Object.fromEntries(SEVERITIES.map((s) => [s, r.findings.filter((f) => f.severity === s).length])),
|
|
144
168
|
findings: r.findings.map((f) => ({ severity: f.severity, where: f.where, field: f.field, message: f.message, page: f.pageNo, kind: f.kind, file: pathOf(r, f, base) })),
|
|
145
169
|
}, null, 2) + '\n';
|
package/src/cli/main.ts
CHANGED
|
@@ -332,7 +332,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
332
332
|
},
|
|
333
333
|
|
|
334
334
|
advise: {
|
|
335
|
-
usage: 'pgkiln advise <path | alias> [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
|
|
335
|
+
usage: 'pgkiln advise <path | alias> [--offline] [--format text|json|sarif] [--fail-on error|warning|info|never] [-o <file>]',
|
|
336
336
|
summary: 'check an export or an application with the Advisor (for CI: text, JSON or SARIF)',
|
|
337
337
|
details:
|
|
338
338
|
'Runs the builder\'s Advisor: every SQL fragment is planned as the application\'s database role (unknown\n' +
|
|
@@ -342,10 +342,13 @@ const COMMANDS: Record<string, Command> = {
|
|
|
342
342
|
'A path (an export directory, a .zip or a JSON export) is imported into a transaction that is rolled back:\n' +
|
|
343
343
|
'nothing changes. It needs a database with pgkiln\'s migrations and the application\'s own tables (in CI, a\n' +
|
|
344
344
|
'PostgreSQL service, pgkiln migrate and your migrations). An alias checks the application in the database.\n\n' +
|
|
345
|
+
'With --offline a path is checked from its files alone, without a database: references, the login page, page\n' +
|
|
346
|
+
'groups, security defaults, workflow steps and JSON source profiles; SQL is not planned.\n\n' +
|
|
345
347
|
'SARIF points every finding at the file of its page or component in the export, so GitHub code scanning\n' +
|
|
346
348
|
'annotates the pull request. Exit code 1 when a finding is at or above --fail-on (default: error).',
|
|
347
|
-
options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' } },
|
|
349
|
+
options: { format: { type: 'string', short: 'f' }, 'fail-on': { type: 'string' }, output: { type: 'string', short: 'o' }, base: { type: 'string' }, offline: { type: 'boolean' } },
|
|
348
350
|
optionHelp: [
|
|
351
|
+
['--offline', 'check an export\'s files without a database (no SQL planning)'],
|
|
349
352
|
['-f, --format <format>', 'text (default), json or sarif'],
|
|
350
353
|
['--fail-on <level>', 'exit code 1 from this level: error (default), warning, info or never'],
|
|
351
354
|
['-o, --output <file>', 'write the report to a file (the summary still goes to standard error)'],
|
|
@@ -358,10 +361,10 @@ const COMMANDS: Record<string, Command> = {
|
|
|
358
361
|
if (!['text', 'json', 'sarif'].includes(format)) throw new UsageError(`--format: text, json or sarif, not ${format}`);
|
|
359
362
|
if (!['error', 'warning', 'info', 'never'].includes(failOn)) throw new UsageError(`--fail-on: error, warning, info or never, not ${failOn}`);
|
|
360
363
|
process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
|
|
361
|
-
const { adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
|
|
364
|
+
const { adviseOffline, adviseTarget, fails, formatJson, formatSarif, formatText } = await import('./advise.ts');
|
|
362
365
|
const { closePools } = await import('../db.ts');
|
|
363
366
|
try {
|
|
364
|
-
const r = await withDb((db) => adviseTarget(db, target));
|
|
367
|
+
const r = v.offline ? await adviseOffline(target) : await withDb((db) => adviseTarget(db, target));
|
|
365
368
|
const base = (v.base as string) ?? null;
|
|
366
369
|
const report = format === 'sarif' ? formatSarif(r, version(), base) : format === 'json' ? formatJson(r, base) : formatText(r, base);
|
|
367
370
|
if (v.output) {
|
package/src/cli/mcp.ts
CHANGED
|
@@ -438,15 +438,23 @@ export const TOOLS: Tool[] = [
|
|
|
438
438
|
'unrestricted page protection, long sessions, administrators without a second factor). Each finding names its file in the export.',
|
|
439
439
|
inputSchema: {
|
|
440
440
|
type: 'object',
|
|
441
|
-
properties: {
|
|
441
|
+
properties: {
|
|
442
|
+
...aliasProp,
|
|
443
|
+
path: { type: 'string', description: 'an export directory, .zip or .json instead of the application in the database' },
|
|
444
|
+
offline: { type: 'boolean', description: 'with path: check the files alone, without the database (references and security; no SQL planning)' },
|
|
445
|
+
},
|
|
442
446
|
},
|
|
443
447
|
annotations: { readOnlyHint: true },
|
|
444
448
|
async run(a) {
|
|
445
449
|
const path = str(a, 'path');
|
|
446
450
|
const alias = str(a, 'alias');
|
|
447
451
|
if (!path && !alias) throw new ToolError('give an alias or a path');
|
|
452
|
+
const { adviseOffline, adviseTarget, formatText } = await import('./advise.ts');
|
|
453
|
+
if (a.offline === true) {
|
|
454
|
+
if (!path) throw new ToolError('offline needs a path (an export)');
|
|
455
|
+
return cap(formatText(await adviseOffline(resolve(path))));
|
|
456
|
+
}
|
|
448
457
|
process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL;
|
|
449
|
-
const { adviseTarget, formatText } = await import('./advise.ts');
|
|
450
458
|
return cap(formatText(await withDb((db) => adviseTarget(db, path ? resolve(path) : alias!))));
|
|
451
459
|
},
|
|
452
460
|
},
|
package/src/json-sources.ts
CHANGED
|
@@ -242,6 +242,18 @@ export function proposeProfile(docs: unknown[], o: { key: string[]; view: string
|
|
|
242
242
|
export interface Grant { grantee: string; privileges: string[] }
|
|
243
243
|
|
|
244
244
|
const triggerArg = (o: object) => lit(JSON.stringify(o));
|
|
245
|
+
const argColumns = (cols: JsonColumn[]) => cols.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) }));
|
|
246
|
+
/** (sprint 146) the argument of a source's view trigger, meta.json_source_write: what the view writes back */
|
|
247
|
+
export const viewTriggerArg = (x: JsonSource) =>
|
|
248
|
+
({ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows, columns: argColumns(x.columns) });
|
|
249
|
+
/** (sprint 146) the argument of an array view's trigger, meta.json_array_write */
|
|
250
|
+
export const arrayTriggerArg = (x: JsonSource, a: JsonArray) =>
|
|
251
|
+
({ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION, columns: argColumns(a.columns) });
|
|
252
|
+
/** (sprint 146) a profile type as format_type() names it in the catalog */
|
|
253
|
+
export const CATALOG_TYPE: Record<JsonType, string> = {
|
|
254
|
+
text: 'text', integer: 'integer', bigint: 'bigint', numeric: 'numeric', boolean: 'boolean', date: 'date',
|
|
255
|
+
timestamp: 'timestamp without time zone', timestamptz: 'timestamp with time zone', jsonb: 'jsonb',
|
|
256
|
+
};
|
|
245
257
|
const grantsSql = (view: string, grants: Grant[]) =>
|
|
246
258
|
grants.filter((g) => g.privileges.length).map((g) => `grant ${g.privileges.join(', ')} on ${view} to ${g.grantee === 'PUBLIC' ? 'public' : pg.escapeIdentifier(g.grantee)}`);
|
|
247
259
|
|
|
@@ -260,20 +272,14 @@ export function jsonSourceDdl(x: JsonSource, o: { app: string; grants: Grant[];
|
|
|
260
272
|
const cols = x.columns.map((c) => `${columnSql(doc, c)} as ${ident(c.name)}`);
|
|
261
273
|
out.push(`create view ${view} with (security_invoker = true) as\nselect ${[keySel, ...cols].join(',\n ')}\n from ${table} t`);
|
|
262
274
|
out.push(`comment on view ${view} is ${lit(`JSON source ${x.name} of application ${o.app}: ${x.schema}.${x.table}.${x.column} (made by pgkiln; Shared Components → JSON sources makes it again)`)}`);
|
|
263
|
-
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg(
|
|
264
|
-
name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows,
|
|
265
|
-
columns: x.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
|
|
266
|
-
})})`);
|
|
275
|
+
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg(viewTriggerArg(x))})`);
|
|
267
276
|
out.push(...grantsSql(view, o.grants));
|
|
268
277
|
for (const { a, view: av } of arrays) {
|
|
269
278
|
const arr = `${doc} #> ${pathSql(a.path)}`;
|
|
270
279
|
const ecols = a.columns.map((c) => `${columnSql('e.v', c)} as ${ident(c.name)}`);
|
|
271
280
|
out.push(`create view ${av} with (security_invoker = true) as\nselect ${[keySel, `k.key as ${ROW_KEY}`, `k.idx + 1 as ${POSITION}`, ...ecols].join(',\n ')}\n from ${table} t\n cross join lateral meta.jsonb_element_keys(${arr}) k\n cross join lateral (select (${arr}) -> k.idx as v) e`);
|
|
272
281
|
out.push(`comment on view ${av} is ${lit(`JSON source ${x.name} of application ${o.app}: the array ${a.path.join('.')} of ${x.schema}.${x.table}.${x.column}, one row per element (made by pgkiln)`)}`);
|
|
273
|
-
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg(
|
|
274
|
-
name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION,
|
|
275
|
-
columns: a.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
|
|
276
|
-
})})`);
|
|
282
|
+
out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg(arrayTriggerArg(x, a))})`);
|
|
277
283
|
out.push(...grantsSql(av, o.grants));
|
|
278
284
|
}
|
|
279
285
|
return out;
|