pgkiln 0.69.0 → 0.71.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/136_vector_search.sql +252 -0
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +10 -8
- package/docs/guide/03-builder.md +176 -4
- package/docs/guide/04-pages-and-regions.md +45 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +34 -1
- package/docs/guide/12-development.md +53 -3
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +48 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +13 -0
- package/src/builder/advisor.ts +43 -3
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +24 -4
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +38 -0
- package/src/builder/i18n/en.ts +38 -0
- package/src/builder/i18n/es.ts +38 -0
- package/src/builder/i18n/fr.ts +38 -0
- package/src/builder/i18n/it.ts +38 -0
- package/src/builder/i18n/ja.ts +38 -0
- package/src/builder/i18n/ko.ts +38 -0
- package/src/builder/i18n/nl.ts +38 -0
- package/src/builder/i18n/pt.ts +38 -0
- package/src/builder/i18n/zh-cn.ts +38 -0
- package/src/builder/i18n/zh-tw.ts +38 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/routes.ts +13 -2
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +11 -1
- package/src/history.ts +155 -0
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { featureStates } from './features.ts';
|
|
6
|
+
import { appOr404 } from './forms.ts';
|
|
7
|
+
import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
8
|
+
import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
|
|
9
|
+
|
|
10
|
+
// (sprint 137, AP-3) Create Page like APEX's: a grid of page types with small previews (tab Component), the
|
|
11
|
+
// features (tab Feature, features.ts) and Create page as copy (tab Copy), at /builder/apps/:id/create-page.
|
|
12
|
+
// A type that needs a table asks for one first (?type=…), then goes to the options step (wizards.ts); the
|
|
13
|
+
// others go to the options step at once. Blank page and Create pages with AI stay as they were. Plain links and
|
|
14
|
+
// forms: no JavaScript needed. The previews are inline SVG drawn with classes (public/builder.css), no styles.
|
|
15
|
+
|
|
16
|
+
/** The grid's order (APEX's names), each with the step it starts with. */
|
|
17
|
+
const GRID: { key: string; label: string; help: string; table: boolean }[] = [
|
|
18
|
+
{ key: 'blank', label: 'Blank page', help: 'An empty page to build in the Page Designer.', table: false },
|
|
19
|
+
...['calendar', 'cards', 'chart', 'comments', 'content_row', 'dashboard', 'data_load', 'facets', 'form', 'grid', 'report', 'map',
|
|
20
|
+
'master_detail', 'media_list', 'report_form', 'search', 'smart_filters', 'timeline', 'tree', 'tasks', 'wizard', 'workflows'].map((k) => {
|
|
21
|
+
const d = PAGE_KINDS.find((x) => x[0] === k)!;
|
|
22
|
+
return { key: k, label: d[1], help: d[2], table: !NO_TABLE.has(k) };
|
|
23
|
+
}),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- previews (80 × 50, inline SVG, classes only)
|
|
27
|
+
const rect = (x: number, y: number, w: number, h: number, cls = 'pv-line', rx = 1.5) => `<rect class="${cls}" x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}"/>`;
|
|
28
|
+
const circle = (cx: number, cy: number, r: number, cls = 'pv-soft') => `<circle class="${cls}" cx="${cx}" cy="${cy}" r="${r}"/>`;
|
|
29
|
+
const rows = (y0: number, n: number, x = 6, w = 68, gap = 7) => Array.from({ length: n }, (_, i) => rect(x, y0 + i * gap, w, 3)).join('');
|
|
30
|
+
const table = `${rect(6, 6, 68, 6, 'pv-soft')}${rows(16, 5)}`;
|
|
31
|
+
const listRows = (n: number, avatar: boolean, right = false) => Array.from({ length: n }, (_, i) => {
|
|
32
|
+
const y = 7 + i * 13;
|
|
33
|
+
return `${avatar ? circle(11, y + 4, 4.5) : ''}${rect(avatar ? 20 : 6, y, 30, 3.5, 'pv-acc')}${rect(avatar ? 20 : 6, y + 5.5, 40, 2.5)}${right ? rect(62, y + 1, 12, 5, 'pv-soft', 2.5) : ''}`;
|
|
34
|
+
}).join('');
|
|
35
|
+
const PREVIEWS: Record<string, string> = {
|
|
36
|
+
blank: rect(6, 6, 68, 38, 'pv-frame', 3),
|
|
37
|
+
calendar: `${rect(6, 6, 68, 6, 'pv-soft')}${Array.from({ length: 15 }, (_, i) => rect(6 + (i % 5) * 14, 15 + Math.floor(i / 5) * 10, 12, 8, i === 7 ? 'pv-acc' : 'pv-line')).join('')}`,
|
|
38
|
+
cards: Array.from({ length: 4 }, (_, i) => `${rect(6 + (i % 2) * 35, 6 + Math.floor(i / 2) * 20, 33, 18, 'pv-frame', 2)}${rect(9 + (i % 2) * 35, 9 + Math.floor(i / 2) * 20, 18, 3, 'pv-acc')}${rect(9 + (i % 2) * 35, 15 + Math.floor(i / 2) * 20, 24, 2.5)}`).join(''),
|
|
39
|
+
chart: [14, 26, 20, 32, 10].map((h, i) => rect(10 + i * 13, 44 - h, 9, h, i === 3 ? 'pv-acc' : 'pv-soft', 1)).join('') + rect(6, 44, 68, 1.5),
|
|
40
|
+
comments: listRows(3, true),
|
|
41
|
+
content_row: listRows(3, true, true),
|
|
42
|
+
dashboard: Array.from({ length: 4 }, (_, i) => {
|
|
43
|
+
const x = 6 + (i % 2) * 35;
|
|
44
|
+
const y = 6 + Math.floor(i / 2) * 20;
|
|
45
|
+
return `${rect(x, y, 33, 18, 'pv-frame', 2)}${[6, 10, 7, 12].map((h, j) => rect(x + 4 + j * 7, y + 15 - h, 5, h, j === i ? 'pv-acc' : 'pv-soft', 0.8)).join('')}`;
|
|
46
|
+
}).join(''),
|
|
47
|
+
data_load: `${rect(18, 26, 44, 18, 'pv-frame', 2)}${rect(37, 10, 6, 18, 'pv-acc', 1)}<path class="pv-acc" d="M30 14 L40 4 L50 14 Z"/>${rect(24, 32, 32, 2.5)}${rect(24, 37, 24, 2.5)}`,
|
|
48
|
+
facets: `${rect(6, 6, 18, 38, 'pv-soft', 2)}${[10, 16, 22, 28].map((y) => rect(9, y, 12, 2.5)).join('')}${rect(28, 6, 46, 6, 'pv-soft')}${rows(16, 5, 28, 46)}`,
|
|
49
|
+
form: [8, 20, 32].map((y) => `${rect(8, y, 16, 3)}${rect(28, y - 2, 44, 7, 'pv-frame', 1.5)}`).join('') + rect(56, 42, 16, 5, 'pv-acc', 2),
|
|
50
|
+
grid: `${table}${[25, 45].map((x) => rect(x, 13, 0.8, 34, 'pv-line', 0)).join('')}${rect(25.8, 22, 19.2, 6, 'pv-frame', 0)}`,
|
|
51
|
+
report: `${rect(6, 3, 30, 5, 'pv-frame', 1.5)}${rect(6, 11, 68, 6, 'pv-soft')}${rows(21, 4)}`,
|
|
52
|
+
map: `${rect(6, 6, 68, 38, 'pv-soft', 3)}<path class="pv-stroke" d="M6 30 C 24 20, 40 40, 74 18"/>${circle(48, 20, 5, 'pv-acc')}${circle(48, 20, 2, 'pv-bg')}`,
|
|
53
|
+
master_detail: `${rect(6, 4, 68, 5, 'pv-soft')}${rows(12, 2)}${rect(6, 12, 68, 4, 'pv-acc')}${rect(6, 28, 68, 5, 'pv-soft')}${rows(36, 2)}`,
|
|
54
|
+
media_list: Array.from({ length: 3 }, (_, i) => `${rect(6, 6 + i * 13, 9, 9, 'pv-soft', 2)}${rect(19, 7 + i * 13, 30, 3.5, 'pv-acc')}${rect(19, 12.5 + i * 13, 40, 2.5)}${rect(62, 8 + i * 13, 12, 5, 'pv-soft', 2.5)}`).join(''),
|
|
55
|
+
report_form: `${rect(6, 6, 42, 6, 'pv-soft')}${rows(16, 4, 6, 42)}${rect(52, 10, 22, 32, 'pv-frame', 2)}${[15, 22, 29].map((y) => rect(55, y, 16, 3)).join('')}${rect(59, 35, 12, 4, 'pv-acc', 1.5)}`,
|
|
56
|
+
search: `${rect(6, 6, 68, 8, 'pv-frame', 4)}${circle(12, 10, 2.2, 'pv-acc')}${[18, 30].map((y) => `${rect(6, y, 26, 3.5, 'pv-acc')}${rect(6, y + 5, 60, 2.5)}`).join('')}`,
|
|
57
|
+
smart_filters: `${rect(6, 3, 68, 7, 'pv-frame', 3.5)}${rect(8, 12, 16, 4, 'pv-soft', 2)}${rect(26, 12, 14, 4, 'pv-soft', 2)}${rect(6, 19, 68, 5, 'pv-soft')}${rows(27, 3)}`,
|
|
58
|
+
timeline: `${rect(13, 4, 1.5, 42, 'pv-line', 0)}${[8, 22, 36].map((y, i) => `${circle(13.75, y, 3, i === 0 ? 'pv-acc' : 'pv-soft')}${rect(21, y - 3, 28, 3, 'pv-acc')}${rect(21, y + 2, 46, 2.5)}`).join('')}`,
|
|
59
|
+
tree: `${rect(6, 5, 24, 4, 'pv-acc')}<path class="pv-stroke" d="M10 9 V38 M10 18 H18 M10 28 H18 M10 38 H18 M22 32 V38 M22 38 H28"/>${rect(20, 16, 22, 3.5)}${rect(20, 26, 26, 3.5)}${rect(30, 36, 20, 3.5)}`,
|
|
60
|
+
tasks: [8, 20, 32].map((y, i) => `${rect(8, y, 7, 7, i === 0 ? 'pv-acc' : 'pv-frame', 1.5)}${rect(19, y, 34, 3)}${rect(19, y + 4.5, 22, 2.5)}${rect(60, y + 1, 14, 5, 'pv-soft', 2.5)}`).join(''),
|
|
61
|
+
wizard: `<path class="pv-stroke" d="M16 9 H64"/>${[16, 40, 64].map((x, i) => circle(x, 9, 4, i === 0 ? 'pv-acc' : 'pv-soft')).join('')}${[20, 30].map((y) => `${rect(10, y, 14, 3)}${rect(28, y - 2, 42, 7, 'pv-frame', 1.5)}`).join('')}${rect(54, 41, 16, 5, 'pv-acc', 2)}`,
|
|
62
|
+
workflows: `${rect(4, 19, 18, 12, 'pv-acc', 2)}${rect(31, 8, 18, 12, 'pv-soft', 2)}${rect(31, 30, 18, 12, 'pv-soft', 2)}${rect(58, 19, 18, 12, 'pv-frame', 2)}<path class="pv-stroke" d="M22 25 L31 14 M22 25 L31 36 M49 14 L58 25 M49 36 L58 25"/>`,
|
|
63
|
+
ai: `<path class="pv-acc" d="M40 6 L44 20 L58 25 L44 30 L40 44 L36 30 L22 25 L36 20 Z"/>${circle(62, 12, 3, 'pv-soft')}${circle(18, 38, 2.5, 'pv-soft')}`,
|
|
64
|
+
};
|
|
65
|
+
/** A page type's small picture, decorative (the label says what it is). */
|
|
66
|
+
export const preview = (key: string): Raw =>
|
|
67
|
+
raw(`<svg class="cp-svg" viewBox="0 0 80 50" aria-hidden="true" focusable="false">${PREVIEWS[key] ?? PREVIEWS.blank}</svg>`);
|
|
68
|
+
|
|
69
|
+
const TABS: [string, string][] = [['component', 'Component'], ['feature', 'Feature'], ['copy', 'Copy']];
|
|
70
|
+
|
|
71
|
+
export async function createPageRoutes(app: FastifyInstance) {
|
|
72
|
+
app.get(`${BASE}/apps/:id/create-page`, async (req: Req, reply) => {
|
|
73
|
+
const s = await developer(req, reply);
|
|
74
|
+
if (!s) return;
|
|
75
|
+
const a = await appOr404(req.params.id);
|
|
76
|
+
if (!a) return reply.code(404).send('Not found');
|
|
77
|
+
const q = req.query as Record<string, string | undefined>;
|
|
78
|
+
const tab = TABS.some(([t]) => t === q.tab) ? q.tab! : 'component';
|
|
79
|
+
const type = GRID.find((g) => g.key === q.type);
|
|
80
|
+
const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
|
|
81
|
+
const nextPage = async () => {
|
|
82
|
+
const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
|
|
83
|
+
let n = Math.max(0, ...[...used].filter((x) => x < 9000)) + 1;
|
|
84
|
+
while (used.has(n)) n++;
|
|
85
|
+
return n;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// a page type that needs a table: choose it first (APEX: the data source step)
|
|
89
|
+
if (type) {
|
|
90
|
+
let body: Raw;
|
|
91
|
+
if (type.key === 'blank') {
|
|
92
|
+
body = html`<form method="post" action="${BASE}/apps/${a.id}/pages">${csrf(s)}
|
|
93
|
+
<div class="form-grid">
|
|
94
|
+
${input('page_no', 'Page number', await nextPage(), { type: 'number', required: true })}
|
|
95
|
+
${input('name', 'Name', '', { required: true })}
|
|
96
|
+
${select('mode', 'Page mode', 'normal', [['normal', 'Normal page'], ['modal', 'Modal dialog']])}
|
|
97
|
+
${input('parent_page', 'Breadcrumb parent', a.home_page ?? '', { type: 'number' })}
|
|
98
|
+
</div>
|
|
99
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Create page</button></div>
|
|
100
|
+
</form>`;
|
|
101
|
+
} else {
|
|
102
|
+
const tables = await wizardTables(a.db_role);
|
|
103
|
+
body = html`<form method="get" action="${BASE}/apps/${a.id}/wizard">
|
|
104
|
+
<input type="hidden" name="kind" value="${type.key}">
|
|
105
|
+
<div class="form-grid">
|
|
106
|
+
${select('table', 'Table or view', '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
|
|
107
|
+
'The next step proposes the columns, keys and foreign keys from the database catalog.')}
|
|
108
|
+
</div>
|
|
109
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Next</button></div>
|
|
110
|
+
</form>`;
|
|
111
|
+
}
|
|
112
|
+
const main = html`${appHeader(a, 'pages', s)}
|
|
113
|
+
${region(type.label, html`<div class="cp-step"><span class="cp-preview cp-preview-lg">${preview(type.key)}</span><p class="muted">${type.help}</p></div>${body}`)}`;
|
|
114
|
+
return send(reply, s, shell(s, `Create page · ${a.name}`, [...crumbs, [type.label]], main));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="Create page">${TABS.map(([t, l]) =>
|
|
118
|
+
html`<a href="${BASE}/apps/${a.id}/create-page?tab=${t}"${t === tab ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
|
|
119
|
+
let body: Raw;
|
|
120
|
+
if (tab === 'component') {
|
|
121
|
+
const href = (g: (typeof GRID)[number]) =>
|
|
122
|
+
g.key !== 'blank' && !g.table ? `${BASE}/apps/${a.id}/wizard?kind=${g.key}` : `${BASE}/apps/${a.id}/create-page?type=${g.key}`;
|
|
123
|
+
body = html`<p class="muted u-mt0">Choose a page type. Types on a table ask for it next; every type proposes its settings and opens the new page in the Page Designer.</p>
|
|
124
|
+
<ul class="cp-grid">${GRID.map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
|
|
125
|
+
<li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>Create pages with AI</b><small>Describe the pages in your own words; check the proposals before they are made.</small></a></li></ul>`;
|
|
126
|
+
} else if (tab === 'feature') {
|
|
127
|
+
const states = await featureStates(a.id);
|
|
128
|
+
body = html`<p class="muted u-mt0">Features add pages and settings to the application. Each lists what it adds before it creates anything; an installed feature is greyed out.</p>
|
|
129
|
+
<ul class="cp-grid">${states.map(({ f, installed, blocked }) => html`<li><a class="cp-type${installed || blocked ? ' cp-done' : ''}" href="${BASE}/apps/${a.id}/features/${f.key}">
|
|
130
|
+
<span class="cp-feature-icon">${icon(f.icon)}</span><b>${f.label}</b><small>${f.help}</small>
|
|
131
|
+
${installed ? html`<span class="tag">Installed</span>` : blocked ? html`<span class="tag">Not available</span>` : ''}</a></li>`)}</ul>`;
|
|
132
|
+
} else {
|
|
133
|
+
body = await copyStep1(a, s.username!);
|
|
134
|
+
}
|
|
135
|
+
const main = html`${appHeader(a, 'pages', s)}${region('Create page', html`${tabs}${body}`)}`;
|
|
136
|
+
return send(reply, s, shell(s, `Create page · ${a.name}`, crumbs, main));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------- Create page as copy
|
|
140
|
+
// step 2 (?from=): the page to copy; step 3 (?from=&page=): number, name and the shared components; POST: copy
|
|
141
|
+
app.get(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
|
|
142
|
+
const s = await developer(req, reply);
|
|
143
|
+
if (!s) return;
|
|
144
|
+
const a = await appOr404(req.params.id);
|
|
145
|
+
if (!a) return reply.code(404).send('Not found');
|
|
146
|
+
const q = req.query as Record<string, string | undefined>;
|
|
147
|
+
const from = await sourceApp(a.id, q.from);
|
|
148
|
+
if (!from) {
|
|
149
|
+
flash(s, 'Choose an application of this workspace.', 'error');
|
|
150
|
+
return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
|
|
151
|
+
}
|
|
152
|
+
const pages = (await owner.query<{ id: number; page_no: number; name: string }>('select id, page_no, name from meta.page where app_id = $1 order by page_no', [from.id])).rows;
|
|
153
|
+
const page = pages.find((p) => String(p.page_no) === q.page);
|
|
154
|
+
const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=copy`], ['Copy']];
|
|
155
|
+
let body: Raw;
|
|
156
|
+
if (!page) {
|
|
157
|
+
body = html`<form method="get" action="${BASE}/apps/${a.id}/copy-page"><input type="hidden" name="from" value="${from.id}">
|
|
158
|
+
<div class="form-grid">${select('page', `Page of ${from.name}`, '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
|
|
159
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">Back</a><button class="btn btn-hot">Next</button></div></form>`;
|
|
160
|
+
} else {
|
|
161
|
+
const refs = (await owner.query<{ kind: string; name: string; in_target: boolean }>('select * from meta.page_shared_refs($1, $2)', [page.id, a.id])).rows;
|
|
162
|
+
const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
|
|
163
|
+
let no = used.has(page.page_no) ? Math.max(0, ...[...used].filter((x) => x < 9000)) + 1 : page.page_no;
|
|
164
|
+
while (used.has(no)) no++;
|
|
165
|
+
const same = from.id === a.id;
|
|
166
|
+
const options = async (kind: string) => (await owner.query<{ n: string }>(SHARED_NAMES[kind], [a.id])).rows.map((r) => r.n);
|
|
167
|
+
const mapRows = await Promise.all(refs.map(async (r) => {
|
|
168
|
+
const names = await options(r.kind);
|
|
169
|
+
const opts: [string, string][] = [...(same || r.in_target ? [] : [['', 'Copy it into this application'] as [string, string]]), ...names.map((n): [string, string] => [n, `Use ${n}`])];
|
|
170
|
+
return html`<tr><td>${KIND_LABELS[r.kind] ?? r.kind}</td><td><code>${r.name}</code></td><td>
|
|
171
|
+
<select name="map:${r.kind}:${r.name}" aria-label="${KIND_LABELS[r.kind] ?? r.kind} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
|
|
172
|
+
}));
|
|
173
|
+
body = html`<p class="muted u-mt0">Page ${page.page_no} <b>${page.name}</b> of ${from.name}: its regions, items, buttons, dynamic actions, validations, processes, computations and branches. Items named P${page.page_no}_… are renamed to the new number, also where the page's code refers to them.</p>
|
|
174
|
+
<form method="post" action="${BASE}/apps/${a.id}/copy-page">${csrf(s)}
|
|
175
|
+
<input type="hidden" name="from" value="${from.id}"><input type="hidden" name="page" value="${page.page_no}">
|
|
176
|
+
<div class="form-grid">
|
|
177
|
+
${input('new_page', 'New page number', no, { type: 'number', required: true })}
|
|
178
|
+
${input('name', 'Name', page.name, { required: true })}
|
|
179
|
+
</div>
|
|
180
|
+
${refs.length
|
|
181
|
+
? html`<h3>Shared components</h3><p class="muted small">The page refers to these by name. ${same ? 'In the same application they stay as they are, or point to another of the same kind.' : 'Use one of this application\'s, or copy it from the other application.'}</p>
|
|
182
|
+
<div class="table-wrap"><table class="report"><thead><tr><th>Kind</th><th>Name</th><th>In this application</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
|
|
183
|
+
: html`<p class="muted">The page uses no shared components.</p>`}
|
|
184
|
+
${same ? '' : html`<p class="muted small">Its SQL runs as this application's database role <code>${a.db_role ?? '(none)'}</code>: grant it what ${from.name}'s role may read and change.</p>`}
|
|
185
|
+
<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">Back</a><button class="btn btn-hot">Copy page</button></div>
|
|
186
|
+
</form>`;
|
|
187
|
+
}
|
|
188
|
+
const main = html`${appHeader(a, 'pages', s)}${region('Create page as copy', body)}`;
|
|
189
|
+
return send(reply, s, shell(s, `Copy page · ${a.name}`, crumbs, main));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.post(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
|
|
193
|
+
const s = await developer(req, reply);
|
|
194
|
+
if (!s) return;
|
|
195
|
+
const a = await appOr404(req.params.id);
|
|
196
|
+
if (!a) return reply.code(404).send('Not found');
|
|
197
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
198
|
+
const from = await sourceApp(a.id, String(b.from ?? ''));
|
|
199
|
+
const retry = `${BASE}/apps/${a.id}/copy-page?from=${encodeURIComponent(String(b.from ?? ''))}&page=${encodeURIComponent(String(b.page ?? ''))}`;
|
|
200
|
+
if (!from) {
|
|
201
|
+
flash(s, 'Choose an application of this workspace.', 'error');
|
|
202
|
+
return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const page = /^\d{1,6}$/.test(String(b.page ?? '')) ? Number(b.page) : NaN;
|
|
206
|
+
const no = /^\d{1,6}$/.test(String(b.new_page ?? '').trim()) ? Number(String(b.new_page).trim()) : NaN;
|
|
207
|
+
if (!(no >= 1)) throw new Error('Enter a page number.');
|
|
208
|
+
const name = String(b.name ?? '').trim().slice(0, 200);
|
|
209
|
+
if (!name) throw new Error('Enter a name.');
|
|
210
|
+
// map:<kind>:<NAME> → a name of the target's (empty: copy); only known kinds pass
|
|
211
|
+
const map: Record<string, string> = {};
|
|
212
|
+
for (const [k, v] of Object.entries(b)) {
|
|
213
|
+
const m = /^map:([a-z_]+):(.{1,100})$/.exec(k);
|
|
214
|
+
if (m && Object.hasOwn(SHARED_NAMES, m[1]) && typeof v === 'string') map[`${m[1]}:${m[2]}`] = v.slice(0, 100);
|
|
215
|
+
}
|
|
216
|
+
const id = await owner.tx(async (c) => (await c.query('select meta.copy_page($1, $2, $3, $4, $5, $6::jsonb) as id', [from.id, page, a.id, no, name, JSON.stringify(map)])).rows[0].id as number);
|
|
217
|
+
flash(s, `Page ${no} "${name}" created as a copy of page ${page}${from.id === a.id ? '' : ` of ${from.name}`}.`);
|
|
218
|
+
return back(reply, s, `${BASE}/pages/${id}`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
flash(s, (e as Error).message, 'error');
|
|
221
|
+
return back(reply, s, retry);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const KIND_LABELS: Record<string, string> = {
|
|
227
|
+
authz_scheme: 'Authorization scheme', build_option: 'Build option', lov: 'List of values', list: 'List',
|
|
228
|
+
template_component: 'Template component', search_config: 'Search configuration', data_load_def: 'Data load definition',
|
|
229
|
+
};
|
|
230
|
+
/** The target application's names of each kind (the choices of the mapping). */
|
|
231
|
+
const SHARED_NAMES: Record<string, string> = {
|
|
232
|
+
authz_scheme: 'select name as n from meta.authz_scheme where app_id = $1 order by 1',
|
|
233
|
+
build_option: 'select name as n from meta.build_option where app_id = $1 order by 1',
|
|
234
|
+
lov: 'select name as n from meta.lov where app_id = $1 order by 1',
|
|
235
|
+
list: 'select name as n from meta.list where app_id = $1 order by 1',
|
|
236
|
+
template_component: 'select static_id as n from meta.template_component where app_id = $1 order by 1',
|
|
237
|
+
search_config: 'select name as n from meta.search_config where app_id = $1 order by 1',
|
|
238
|
+
data_load_def: 'select name as n from meta.data_load_def where app_id = $1 order by 1',
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** An application of the same workspace as the target (the target itself too), or null. */
|
|
242
|
+
async function sourceApp(targetId: number, from: string | undefined) {
|
|
243
|
+
if (!/^\d{1,9}$/.test(String(from ?? ''))) return null;
|
|
244
|
+
return (await owner.one<{ id: number; name: string; alias: string }>(
|
|
245
|
+
'select id, name, alias from meta.app where id = $1 and meta.app_workspace(id) = meta.app_workspace($2)', [from, targetId],
|
|
246
|
+
)) ?? null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Copy, step 1: the application to copy from (this one first). */
|
|
250
|
+
async function copyStep1(a: { id: number; name: string }, _user: string): Promise<Raw> {
|
|
251
|
+
const apps = (await owner.query<{ id: number; name: string; alias: string }>(
|
|
252
|
+
'select id, name, alias from meta.app where meta.app_workspace(id) = meta.app_workspace($1) order by id <> $1, lower(name)', [a.id],
|
|
253
|
+
)).rows;
|
|
254
|
+
return html`<p class="muted u-mt0">Copy a page of this application or another application of the workspace, under a new number and name. The shared components it uses (lists of values, authorization schemes, lists…) are mapped to this application's or copied.</p>
|
|
255
|
+
<form method="get" action="${BASE}/apps/${a.id}/copy-page">
|
|
256
|
+
<div class="form-grid">${select('from', 'Copy from application', String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
|
|
257
|
+
<div class="buttons"><button class="btn btn-hot">Next</button></div>
|
|
258
|
+
</form>`;
|
|
259
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { applyBinds, splitStatements } from '../binds.ts';
|
|
4
|
+
import { owner } from '../db.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
8
|
+
import { EXPLAINABLE, sqlFields, type SqlShape } from './advisor.ts';
|
|
9
|
+
import { COMPONENTS } from './components.ts';
|
|
10
|
+
import { appEntries, type Entry } from './search.ts';
|
|
11
|
+
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
12
|
+
|
|
13
|
+
// (sprint 138, AP-5) App Builder → Utilities → Database object dependencies
|
|
14
|
+
// (APEX: Database Object Dependencies). Every piece of SQL the Advisor plans
|
|
15
|
+
// (region sources, lists of values, conditions, processes, …) and the tables of
|
|
16
|
+
// form and grid regions are parsed as the application's database role into a
|
|
17
|
+
// temporary SQL-standard function (CREATE FUNCTION … BEGIN ATOMIC), whose
|
|
18
|
+
// dependencies PostgreSQL records in pg_depend: the tables, views, sequences
|
|
19
|
+
// and functions the statement uses, resolved with the role's search path.
|
|
20
|
+
// Nothing runs; the transaction is rolled back. A statement that names an
|
|
21
|
+
// object that doesn't exist is reported as a missing object (in red), and the
|
|
22
|
+
// role's privileges are compared with what the statements need (SELECT for
|
|
23
|
+
// queries, INSERT / UPDATE / DELETE for the target of a DML statement, USAGE
|
|
24
|
+
// on sequences, EXECUTE on functions). PL/pgSQL blocks (DO) can't be parsed this
|
|
25
|
+
// way and are listed as not scanned.
|
|
26
|
+
|
|
27
|
+
export type ObjKind = 'table' | 'view' | 'matview' | 'sequence' | 'foreign table' | 'function' | 'procedure' | 'relation';
|
|
28
|
+
|
|
29
|
+
export interface Use {
|
|
30
|
+
entry: Entry | null;
|
|
31
|
+
field: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DepObject {
|
|
35
|
+
key: string;
|
|
36
|
+
kind: ObjKind;
|
|
37
|
+
schema: string;
|
|
38
|
+
name: string;
|
|
39
|
+
/** schema-qualified display name (functions with their argument types) */
|
|
40
|
+
display: string;
|
|
41
|
+
/** the Object Browser's reference (regclass / regprocedure text), null for a missing object */
|
|
42
|
+
ref: string | null;
|
|
43
|
+
missing: boolean;
|
|
44
|
+
/** privileges the statements need that the role doesn't have */
|
|
45
|
+
lacking: string[];
|
|
46
|
+
/** the role's privileges on the object */
|
|
47
|
+
privileges: string[];
|
|
48
|
+
usedBy: Use[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Dependencies {
|
|
52
|
+
objects: DepObject[];
|
|
53
|
+
role: string | null;
|
|
54
|
+
scanned: number;
|
|
55
|
+
unscanned: (Use & { message: string })[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Fragment extends Use {
|
|
59
|
+
sql: string;
|
|
60
|
+
shape: SqlShape | 'relation';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SYSTEM = new Set(['pg_catalog', 'information_schema', 'meta', 'pg_toast']);
|
|
64
|
+
|
|
65
|
+
/** Every piece of SQL of an application, and the tables of its form and grid regions. */
|
|
66
|
+
async function fragments(appId: number): Promise<Fragment[]> {
|
|
67
|
+
const entries = await appEntries(appId);
|
|
68
|
+
const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
|
|
69
|
+
const out: Fragment[] = [];
|
|
70
|
+
for (const [kind, spec] of Object.entries(COMPONENTS)) {
|
|
71
|
+
const rows = spec.scope === 'page'
|
|
72
|
+
? (await owner.query(`select t.* from ${spec.table} t join meta.page p on p.id = t.page_id where p.app_id = $1`, [appId])).rows
|
|
73
|
+
: (await owner.query(`select * from ${spec.table} where app_id = $1`, [appId])).rows;
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
const entry = byKey.get(`${kind}-${row.id}`) ?? null;
|
|
76
|
+
const label = (name: string) => spec.fields.find((x) => x.name === name)?.label ?? name;
|
|
77
|
+
for (const f of sqlFields(kind, row)) if (f.shape !== 'regex' && typeof row[f.name] === 'string' && row[f.name].trim()) out.push({ entry, field: label(f.name), sql: row[f.name], shape: f.shape });
|
|
78
|
+
if (kind === 'region' && (row.type === 'form' || row.type === 'grid') && typeof row.table_name === 'string' && row.table_name.trim() && !row.rest_source)
|
|
79
|
+
out.push({ entry, field: label('table_name'), sql: row.table_name.trim(), shape: 'relation' });
|
|
80
|
+
if (kind === 'region' && row.type === 'map')
|
|
81
|
+
for (const l of Array.isArray(row.config?.layers) ? row.config.layers : [])
|
|
82
|
+
if (typeof l?.source === 'string' && l.source.trim()) out.push({ entry, field: `Layer "${typeof l.name === 'string' ? l.name : ''}" query`, sql: l.source, shape: 'select' });
|
|
83
|
+
if (kind === 'search_config' && row.source_type !== 'list' && typeof row.source === 'string' && row.source.trim())
|
|
84
|
+
out.push({ entry, field: 'Source', sql: row.source.trim(), shape: row.source_type === 'table' ? 'relation' : 'select' });
|
|
85
|
+
if (kind === 'rest_module')
|
|
86
|
+
for (const h of Array.isArray(row.handlers) ? row.handlers : [])
|
|
87
|
+
if (typeof h?.source === 'string' && h.source.trim() && h.type !== 'plpgsql') out.push({ entry, field: `${h.method} ${h.path}`, sql: h.source, shape: h.type === 'sql' ? 'statements' : 'select' });
|
|
88
|
+
if (kind === 'workflow_definition')
|
|
89
|
+
for (const st of Array.isArray(row.steps) ? row.steps : []) {
|
|
90
|
+
if (st?.type === 'sql' && typeof st.code === 'string') out.push({ entry, field: `step ${st.name}: code`, sql: st.code, shape: 'statements' });
|
|
91
|
+
if (st?.type === 'task' && typeof st.owners === 'string' && st.owners.trim()) out.push({ entry, field: `step ${st.name}: owners`, sql: st.owners, shape: 'select' });
|
|
92
|
+
if (st?.type === 'switch' && Array.isArray(st.cases)) for (const cs of st.cases) if (typeof cs?.when === 'string') out.push({ entry, field: `step ${st.name}: when`, sql: cs.when, shape: 'boolean' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The DML target of a statement (INSERT INTO x, UPDATE x, DELETE FROM x, MERGE INTO x) and the privilege it needs. */
|
|
100
|
+
export function dmlTarget(stmt: string): { name: string; privilege: string } | null {
|
|
101
|
+
const m = /^\s*(?:with\b[\s\S]*?\)\s*)?(insert\s+into|update|delete\s+from|merge\s+into)\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/i.exec(stmt);
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const verb = m[1].toLowerCase().split(/\s+/)[0];
|
|
104
|
+
return { name: m[2].replace(/\s+/g, ''), privilege: verb === 'merge' ? 'UPDATE' : verb.toUpperCase() };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The statements of a fragment as the body of a BEGIN ATOMIC function, and what can't be parsed that way. */
|
|
108
|
+
export function bodyOf(sql: string, shape: SqlShape | 'relation'): { statements: string[]; skipped: string[] } {
|
|
109
|
+
const plain = applyBinds(sql.trim().replace(/;+\s*$/, ''), {});
|
|
110
|
+
if (shape === 'boolean') return { statements: [`select (${plain})::boolean`], skipped: [] };
|
|
111
|
+
if (shape === 'select') return EXPLAINABLE.test(plain) ? { statements: [plain], skipped: [] } : { statements: [], skipped: ['not a query'] };
|
|
112
|
+
const statements: string[] = [];
|
|
113
|
+
const skipped: string[] = [];
|
|
114
|
+
for (const stmt of splitStatements(plain)) {
|
|
115
|
+
if (EXPLAINABLE.test(stmt)) statements.push(stmt);
|
|
116
|
+
else if (stmt.trim()) skipped.push(stmt.trim().split(/\s+/).slice(0, 2).join(' '));
|
|
117
|
+
}
|
|
118
|
+
return { statements, skipped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A missing object named by an error message of the parser. */
|
|
122
|
+
export function missingOf(message: string): { kind: ObjKind; name: string } | null {
|
|
123
|
+
let m = /relation "([^"]+)" does not exist/.exec(message);
|
|
124
|
+
if (m) return { kind: 'relation', name: m[1] };
|
|
125
|
+
m = /^(function|procedure) (.+?\)) does not exist/.exec(message);
|
|
126
|
+
if (m) return { kind: m[1] as ObjKind, name: m[2] };
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const RELKINDS: Record<string, ObjKind> = { r: 'table', p: 'table', v: 'view', m: 'matview', S: 'sequence', f: 'foreign table' };
|
|
131
|
+
|
|
132
|
+
/** Scan an application. */
|
|
133
|
+
export async function appDependencies(appId: number): Promise<Dependencies> {
|
|
134
|
+
const app = await owner.one<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId]);
|
|
135
|
+
if (!app) throw new Error('application not found');
|
|
136
|
+
const frags = await fragments(appId);
|
|
137
|
+
const found = new Map<string, { cls: 'pg_class' | 'pg_proc'; oid: number; needs: Set<string>; uses: Use[] }>();
|
|
138
|
+
const missing = new Map<string, DepObject>();
|
|
139
|
+
const unscanned: Dependencies['unscanned'] = [];
|
|
140
|
+
let scanned = 0;
|
|
141
|
+
const note = (cls: 'pg_class' | 'pg_proc', oid: number, use: Use, need: string | null) => {
|
|
142
|
+
const k = `${cls}:${oid}`;
|
|
143
|
+
if (!found.has(k)) found.set(k, { cls, oid, needs: new Set(), uses: [] });
|
|
144
|
+
const f = found.get(k)!;
|
|
145
|
+
if (need) f.needs.add(need);
|
|
146
|
+
if (!f.uses.some((u) => u.entry === use.entry && u.field === use.field)) f.uses.push(use);
|
|
147
|
+
};
|
|
148
|
+
const own = await devDbForApp(appId);
|
|
149
|
+
const c = await own.pool.connect();
|
|
150
|
+
try {
|
|
151
|
+
await c.query('begin');
|
|
152
|
+
await c.query(`set local statement_timeout = '5s'`);
|
|
153
|
+
await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
|
|
154
|
+
if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
|
|
155
|
+
for (const f of frags) {
|
|
156
|
+
const use: Use = { entry: f.entry, field: f.field };
|
|
157
|
+
await c.query('savepoint deps');
|
|
158
|
+
try {
|
|
159
|
+
if (f.shape === 'relation') {
|
|
160
|
+
scanned++;
|
|
161
|
+
const r = (await c.query<{ oid: number | null }>('select to_regclass($1)::oid::int as oid', [f.sql])).rows[0];
|
|
162
|
+
if (r?.oid) note('pg_class', r.oid, use, 'SELECT');
|
|
163
|
+
else addMissing(missing, { kind: 'relation', name: f.sql }, use);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const { statements, skipped } = bodyOf(f.sql, f.shape);
|
|
167
|
+
if (skipped.length) unscanned.push({ ...use, message: `Not scanned (only SELECT, INSERT, UPDATE, DELETE and MERGE are parsed): ${skipped.join(', ')}` });
|
|
168
|
+
if (!statements.length) continue;
|
|
169
|
+
scanned++;
|
|
170
|
+
// the extended protocol takes exactly one statement: SQL that ends the function early (…; end; drop …) is refused
|
|
171
|
+
await c.query({ text: `create function pg_temp.__pgkiln_deps() returns void language sql begin atomic ${statements.join(';\n')}; end`, queryMode: 'extended' } as pg.QueryConfig);
|
|
172
|
+
const deps = (await c.query<{ cls: 'pg_class' | 'pg_proc'; oid: number }>(
|
|
173
|
+
`select distinct d.refclassid::regclass::text as cls, d.refobjid::int as oid from pg_depend d
|
|
174
|
+
where d.classid = 'pg_proc'::regclass and d.objid = 'pg_temp.__pgkiln_deps()'::regprocedure
|
|
175
|
+
and d.refclassid in ('pg_class'::regclass, 'pg_proc'::regclass)`)).rows;
|
|
176
|
+
// what the statements need: the target of a DML statement its verb's privilege, everything else SELECT
|
|
177
|
+
const targets = new Map<number, string>();
|
|
178
|
+
for (const st of statements) {
|
|
179
|
+
const t = dmlTarget(st);
|
|
180
|
+
if (!t) continue;
|
|
181
|
+
const oid = (await c.query<{ oid: number | null }>('select to_regclass($1)::oid::int as oid', [t.name])).rows[0]?.oid;
|
|
182
|
+
if (oid) targets.set(oid, t.privilege);
|
|
183
|
+
}
|
|
184
|
+
for (const d of deps) note(d.cls, d.oid, use, d.cls === 'pg_proc' ? 'EXECUTE' : (targets.get(d.oid) ?? 'SELECT'));
|
|
185
|
+
} catch (e) {
|
|
186
|
+
const message = (e as Error).message;
|
|
187
|
+
const m = missingOf(message);
|
|
188
|
+
if (m) addMissing(missing, m, use);
|
|
189
|
+
else unscanned.push({ ...use, message });
|
|
190
|
+
} finally {
|
|
191
|
+
await c.query('rollback to savepoint deps');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
await c.query('rollback').catch(() => {});
|
|
196
|
+
c.release();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// the objects, their kinds and the role's privileges (as the owner: the catalog)
|
|
200
|
+
const objects: DepObject[] = [...missing.values()];
|
|
201
|
+
const rels = [...found.values()].filter((f) => f.cls === 'pg_class');
|
|
202
|
+
const procs = [...found.values()].filter((f) => f.cls === 'pg_proc');
|
|
203
|
+
const role = app.db_role;
|
|
204
|
+
if (rels.length) {
|
|
205
|
+
const rows = (await owner.query(
|
|
206
|
+
`select c.oid::int as oid, c.relkind::text as relkind, n.nspname as schema, c.relname as name, c.oid::regclass::text as ref,
|
|
207
|
+
array_remove(array[
|
|
208
|
+
case when $2::text is null or has_table_privilege($2, c.oid, 'SELECT') then 'SELECT' end,
|
|
209
|
+
case when c.relkind <> 'S' and ($2::text is null or has_table_privilege($2, c.oid, 'INSERT')) then 'INSERT' end,
|
|
210
|
+
case when c.relkind <> 'S' and ($2::text is null or has_table_privilege($2, c.oid, 'UPDATE')) then 'UPDATE' end,
|
|
211
|
+
case when c.relkind <> 'S' and ($2::text is null or has_table_privilege($2, c.oid, 'DELETE')) then 'DELETE' end,
|
|
212
|
+
case when c.relkind = 'S' and ($2::text is null or has_sequence_privilege($2, c.oid, 'USAGE')) then 'USAGE' end], null) as privileges,
|
|
213
|
+
$2::text is null or has_schema_privilege($2, n.oid, 'USAGE') as schema_usage
|
|
214
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.oid = any ($1::oid[])`,
|
|
215
|
+
[rels.map((r) => r.oid), role])).rows;
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
if (SYSTEM.has(r.schema)) continue;
|
|
218
|
+
const f = found.get(`pg_class:${r.oid}`)!;
|
|
219
|
+
const kind = RELKINDS[r.relkind] ?? 'relation';
|
|
220
|
+
// a sequence used by nextval() needs USAGE (or UPDATE); one only read, SELECT
|
|
221
|
+
const needs = kind === 'sequence' ? ['USAGE'] : [...f.needs];
|
|
222
|
+
const lacking = [...(r.schema_usage ? [] : [`USAGE on schema ${r.schema}`]), ...needs.filter((p) => !r.privileges.includes(p))];
|
|
223
|
+
objects.push({ key: `pg_class:${r.oid}`, kind, schema: r.schema, name: r.name, display: `${r.schema}.${r.name}`, ref: r.ref, missing: false, lacking, privileges: r.privileges, usedBy: f.uses });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (procs.length) {
|
|
227
|
+
const rows = (await owner.query(
|
|
228
|
+
`select p.oid::int as oid, p.prokind::text as prokind, n.nspname as schema, p.proname as name, p.oid::regprocedure::text as ref,
|
|
229
|
+
pg_get_function_identity_arguments(p.oid) as args,
|
|
230
|
+
$2::text is null or has_function_privilege($2, p.oid, 'EXECUTE') as exec,
|
|
231
|
+
$2::text is null or has_schema_privilege($2, n.oid, 'USAGE') as schema_usage
|
|
232
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace where p.oid = any ($1::oid[])`,
|
|
233
|
+
[procs.map((r) => r.oid), role])).rows;
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
if (SYSTEM.has(r.schema)) continue;
|
|
236
|
+
const f = found.get(`pg_proc:${r.oid}`)!;
|
|
237
|
+
const lacking = [...(r.schema_usage ? [] : [`USAGE on schema ${r.schema}`]), ...(r.exec ? [] : ['EXECUTE'])];
|
|
238
|
+
objects.push({ key: `pg_proc:${r.oid}`, kind: r.prokind === 'p' ? 'procedure' : 'function', schema: r.schema, name: r.name, display: `${r.schema}.${r.name}(${r.args})`, ref: r.ref, missing: false, lacking, privileges: r.exec ? ['EXECUTE'] : [], usedBy: f.uses });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const rank = (o: DepObject) => (o.missing ? 0 : o.lacking.length ? 1 : 2);
|
|
242
|
+
objects.sort((a, b) => rank(a) - rank(b) || (a.display < b.display ? -1 : a.display > b.display ? 1 : 0));
|
|
243
|
+
return { objects, role, scanned, unscanned };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function addMissing(missing: Map<string, DepObject>, m: { kind: ObjKind; name: string }, use: Use) {
|
|
247
|
+
const key = `missing:${m.kind}:${m.name}`;
|
|
248
|
+
if (!missing.has(key)) {
|
|
249
|
+
const dot = m.name.indexOf('.');
|
|
250
|
+
missing.set(key, { key, kind: m.kind, schema: dot > 0 ? m.name.slice(0, dot) : '', name: dot > 0 ? m.name.slice(dot + 1) : m.name, display: m.name, ref: null, missing: true, lacking: [], privileges: [], usedBy: [] });
|
|
251
|
+
}
|
|
252
|
+
const o = missing.get(key)!;
|
|
253
|
+
if (!o.usedBy.some((u) => u.entry === use.entry && u.field === use.field)) o.usedBy.push(use);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const OB_KIND: Partial<Record<ObjKind, string>> = { table: 'table', view: 'view', matview: 'matview', sequence: 'sequence', function: 'function', procedure: 'procedure' };
|
|
257
|
+
const KIND_ICON: Record<ObjKind, string> = { table: 'table', view: 'layers', matview: 'layers', sequence: 'hash', 'foreign table': 'table', function: 'code', procedure: 'code', relation: 'table' };
|
|
258
|
+
|
|
259
|
+
/** The Object Browser's address of an object (null: it has no page there). */
|
|
260
|
+
export const objectUrl = (o: Pick<DepObject, 'kind' | 'schema' | 'ref'>) =>
|
|
261
|
+
o.ref && OB_KIND[o.kind] ? `${BASE}/sql/objects?${new URLSearchParams({ schema: o.schema, k: OB_KIND[o.kind]!, o: o.ref })}` : null;
|
|
262
|
+
|
|
263
|
+
const useLink = (u: Use) =>
|
|
264
|
+
u.entry
|
|
265
|
+
? html`<li><a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>${u.entry.pageNo !== null && u.entry.kind !== 'page' ? html` <span class="tag">page ${u.entry.pageNo}</span>` : ''} <span class="muted small">${u.field}</span></li>`
|
|
266
|
+
: html`<li><span class="muted small">${u.field}</span></li>`;
|
|
267
|
+
|
|
268
|
+
export async function dependencyRoutes(app: FastifyInstance) {
|
|
269
|
+
app.get(`${BASE}/apps/:id/utilities/dependencies`, async (req: Req, reply) => {
|
|
270
|
+
const s = await developer(req, reply);
|
|
271
|
+
if (!s) return;
|
|
272
|
+
const a = /^\d{1,9}$/.test(req.params.id) ? await owner.one('select * from meta.app where id = $1', [req.params.id]) : undefined;
|
|
273
|
+
if (!a) return reply.code(404).send('Not found');
|
|
274
|
+
const deps = await appDependencies(a.id);
|
|
275
|
+
const filter = String(req.query?.q ?? '').trim().slice(0, 100).toLowerCase();
|
|
276
|
+
const onlyProblems = req.query?.show === 'problems';
|
|
277
|
+
const shown = deps.objects.filter((o) => (!filter || o.display.toLowerCase().includes(filter)) && (!onlyProblems || o.missing || o.lacking.length));
|
|
278
|
+
const missingN = deps.objects.filter((o) => o.missing).length;
|
|
279
|
+
const grantsN = deps.objects.filter((o) => o.lacking.length).length;
|
|
280
|
+
const pages = (o: DepObject) => [...new Set(o.usedBy.map((u) => u.entry?.pageNo).filter((p): p is number => p !== null && p !== undefined))].sort((x, y) => x - y);
|
|
281
|
+
const rows: Raw = shown.length
|
|
282
|
+
? html`<div class="table-wrap"><table class="table dep-table report-reflow"><thead><tr><th scope="col">Object</th><th scope="col">Type</th><th scope="col">Status</th><th scope="col">Pages</th><th scope="col">Used by</th></tr></thead>
|
|
283
|
+
<tbody>${shown.map((o) => {
|
|
284
|
+
const url = objectUrl(o);
|
|
285
|
+
const status = o.missing
|
|
286
|
+
? html`<span class="tag tag-error">Missing</span>`
|
|
287
|
+
: o.lacking.length
|
|
288
|
+
? html`<span class="tag tag-error">No grant</span> <span class="small dep-missing">${deps.role} lacks ${o.lacking.join(', ')}</span>`
|
|
289
|
+
: html`<span class="tag tag-success">OK</span> <span class="muted small">${o.privileges.join(', ')}</span>`;
|
|
290
|
+
return html`<tr${o.missing || o.lacking.length ? raw(' class="dep-problem"') : ''}>
|
|
291
|
+
<td data-label="Object">${icon(KIND_ICON[o.kind])} ${url ? html`<a href="${url}">${o.display}</a>` : html`<span class="dep-missing">${o.display}</span>`}</td>
|
|
292
|
+
<td data-label="Type">${o.kind === 'relation' ? 'table or view' : o.kind}</td>
|
|
293
|
+
<td data-label="Status">${status}</td>
|
|
294
|
+
<td data-label="Pages">${pages(o).join(', ')}</td>
|
|
295
|
+
<td data-label="Used by"><details class="dep-uses"><summary>${o.usedBy.length} component${o.usedBy.length === 1 ? '' : 's'}</summary><ul class="dep-list">${o.usedBy.map(useLink)}</ul></details></td>
|
|
296
|
+
</tr>`;
|
|
297
|
+
})}</tbody></table></div>`
|
|
298
|
+
: html`<p class="muted">No objects${filter || onlyProblems ? ' match' : ''}.</p>`;
|
|
299
|
+
const main = html`${appHeader(a, 'utilities', s)}
|
|
300
|
+
${region('Database object dependencies', html`<p class="muted u-mt0">The tables, views, sequences and functions the application's SQL uses (${deps.scanned} statements parsed as the role <code>${deps.role ?? '(the runtime connection)'}</code>; nothing is run), with the components that use them. Missing objects and privileges the role lacks are marked.</p>
|
|
301
|
+
<p><span class="tag">${deps.objects.length} objects</span> <span class="tag tag-error">${missingN} missing</span> <span class="tag tag-warning">${grantsN} without the grants they need</span></p>
|
|
302
|
+
<form method="get" class="util-filters" role="search">
|
|
303
|
+
<div class="field"><label class="label" for="dep-q">Filter</label><input id="dep-q" name="q" type="search" value="${filter}" placeholder="e.g. sales.orders"></div>
|
|
304
|
+
<div class="field"><label class="label" for="dep-show">Show</label><select id="dep-show" name="show"><option value="">All objects</option><option value="problems"${onlyProblems ? raw(' selected') : ''}>Only problems</option></select></div>
|
|
305
|
+
<div class="buttons"><button class="btn">${icon('filter')} Filter</button></div>
|
|
306
|
+
</form>
|
|
307
|
+
${rows}`)}
|
|
308
|
+
${deps.unscanned.length ? region('Not scanned', html`<ul class="hits findings">${deps.unscanned.map((u) => html`<li class="finding-info">${u.entry ? html`<a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>` : html`<span>${u.field}</span>`}<span></span><span class="tag tag-info">Note</span><span class="hit-snippet"><b>${u.field}:</b> ${u.message}</span></li>`)}</ul>`) : ''}`;
|
|
309
|
+
return send(reply, s, shell(s, `Object dependencies · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Object dependencies']], main));
|
|
310
|
+
});
|
|
311
|
+
}
|