pgkiln 0.35.0 → 0.36.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/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/docs/apex-feature-parity.md +22 -20
- package/docs/guide/03-builder.md +36 -2
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +7 -1
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +2 -0
- package/src/appfiles.ts +1 -0
- package/src/builder/advisor.ts +14 -0
- package/src/builder/components.ts +15 -2
- package/src/builder/ddl-tools.ts +114 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/forms.ts +6 -4
- package/src/builder/login-page.ts +82 -0
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/routes.ts +16 -4
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/ui.ts +2 -2
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/ddl.ts +509 -0
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +35 -32
- package/src/runtime/signin.ts +80 -0
- package/src/static-version.ts +34 -0
package/src/builder/designer.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
|
|
|
5
5
|
import { COMPONENTS } from './components.ts';
|
|
6
6
|
import { back, BASE, bicon, csrf, developer, flash, input, select, send, shell, type Req } from './ui.ts';
|
|
7
7
|
import { buildOptionChoices, componentForm, lookups, saveComponent } from './forms.ts';
|
|
8
|
+
import { regionConfigHelp } from './region-help.ts';
|
|
8
9
|
import { parseIncludes } from './static-files.ts';
|
|
9
10
|
import { regionSettingsForm } from './region-settings.ts';
|
|
10
11
|
import { usedInPanel } from './search.ts';
|
|
@@ -301,6 +302,8 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
301
302
|
</div></fieldset>
|
|
302
303
|
<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
|
|
303
304
|
${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
|
|
305
|
+
${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
|
|
306
|
+
${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['minimal', 'Minimal (no navigation)']], 'Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
|
|
304
307
|
${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
|
|
305
308
|
${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
|
|
306
309
|
${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
|
|
@@ -322,12 +325,24 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
322
325
|
<form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-zone">${csrf(s)}
|
|
323
326
|
<button class="btn btn-sm btn-danger" data-confirm="Delete page ${p.page_no} and all its components?">Delete page</button></form>`;
|
|
324
327
|
}
|
|
328
|
+
// (sprint 59) the help pane: the guide section of the selected component's type
|
|
329
|
+
const guide = (() => {
|
|
330
|
+
const GUIDE = 'https://pgkiln.vargar.eu/docs';
|
|
331
|
+
const k = newKind || kind;
|
|
332
|
+
if (k === 'region') return regionConfigHelp((newKind ? req.query.type : rows.region.find((r: any) => String(r.id) === cid)?.type) ?? null).url ?? `${GUIDE}/pages-and-regions#region-types`;
|
|
333
|
+
return ({ item: `${GUIDE}/items`, button: `${GUIDE}/processing#buttons`, dynamic_action: `${GUIDE}/dynamic-actions`, validation: `${GUIDE}/processing#validations`,
|
|
334
|
+
process: `${GUIDE}/processing#processes`, computation: `${GUIDE}/processing#computations`, branch: `${GUIDE}/processing#branches` } as Record<string, string>)[k] ?? `${GUIDE}/pages-and-regions`;
|
|
335
|
+
})();
|
|
325
336
|
const right = html`
|
|
326
337
|
<div class="pe-head"><div class="pe-title">${heading}</div>
|
|
327
338
|
<div class="pe-filter-row" hidden><label class="sr-only" for="pe-filter">Filter properties</label>${icon('filter')}<input id="pe-filter" class="pe-filter" type="search" placeholder="Filter" autocomplete="off"></div>
|
|
328
339
|
</div>
|
|
329
340
|
${arrange}
|
|
330
|
-
<div class="pe">${props}</div
|
|
341
|
+
<div class="pe">${props}</div>
|
|
342
|
+
<details class="pe-help" open${guide ? raw(` data-guide="${guide}"`) : ''}>
|
|
343
|
+
<summary>${icon('help')} Help</summary>
|
|
344
|
+
<div class="pe-help-body" aria-live="polite" data-pe-help><p class="muted">Select a property to see its help here.</p></div>
|
|
345
|
+
</details>`;
|
|
331
346
|
|
|
332
347
|
// ------------------------------------------------------------ toolbar
|
|
333
348
|
const idx = pages.findIndex((x) => x.id === p.id);
|
|
@@ -380,6 +395,17 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
380
395
|
return send(reply, s, shell(s, `Page ${p.page_no}`, [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [`Page ${p.page_no}: ${p.name}`]], main, 'apps', { toolbar, full: true }));
|
|
381
396
|
});
|
|
382
397
|
|
|
398
|
+
// (sprint 59) Quick edit from the developer toolbar: an item or button of the page by its name
|
|
399
|
+
app.get(`${BASE}/pages/:pid/quick`, async (req: Req, reply) => {
|
|
400
|
+
const s = await developer(req, reply);
|
|
401
|
+
if (!s) return;
|
|
402
|
+
if (!/^\d{1,9}$/.test(req.params.pid)) return reply.code(404).send('Not found');
|
|
403
|
+
const q = req.query ?? {};
|
|
404
|
+
const [kind, table, name] = q.item ? ['item', 'meta.item', String(q.item).toUpperCase()] : q.button ? ['button', 'meta.button', String(q.button).toUpperCase()] : [null, null, null];
|
|
405
|
+
const row = kind ? await owner.one(`select id from ${table} where page_id = $1 and name = $2 order by id limit 1`, [req.params.pid, name]) : null;
|
|
406
|
+
return reply.redirect(`${BASE}/pages/${req.params.pid}${row ? `?c=${kind}-${row.id}` : ''}`);
|
|
407
|
+
});
|
|
408
|
+
|
|
383
409
|
app.post(`${BASE}/pages/:pid`, async (req: Req, reply) => {
|
|
384
410
|
const s = await developer(req, reply);
|
|
385
411
|
if (!s) return;
|
|
@@ -388,9 +414,9 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
388
414
|
const before = await owner.one('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
|
|
389
415
|
await owner.query(
|
|
390
416
|
`update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
|
|
391
|
-
dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14 where id = $1`,
|
|
417
|
+
dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
|
|
392
418
|
[req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
|
|
393
|
-
['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true'],
|
|
419
|
+
['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', b.page_template === 'minimal' ? 'minimal' : 'standard', b.page_group?.trim() || null],
|
|
394
420
|
);
|
|
395
421
|
// the page's lock and comments follow a new page number
|
|
396
422
|
if (before && before.page_no !== Number(b.page_no))
|
package/src/builder/forms.ts
CHANGED
|
@@ -19,10 +19,11 @@ export interface Lookups {
|
|
|
19
19
|
lists: string[];
|
|
20
20
|
listEntries: { id: number; list_name: string; label: string }[];
|
|
21
21
|
automations: string[];
|
|
22
|
+
pageGroups: string[];
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export async function lookups(appId: number, pageId?: number): Promise<Lookups> {
|
|
25
|
-
const [regions, pages, authz, nav, buildOptions, rest, lists, listEntries, automations] = await Promise.all([
|
|
26
|
+
const [regions, pages, authz, nav, buildOptions, rest, lists, listEntries, automations, pageGroups] = await Promise.all([
|
|
26
27
|
pageId ? owner.query('select id, title, type from meta.region where page_id = $1 order by seq, id', [pageId]) : Promise.resolve({ rows: [] }),
|
|
27
28
|
owner.query('select page_no, name from meta.page where app_id = $1 order by page_no', [appId]),
|
|
28
29
|
owner.query('select name from meta.authz_scheme where app_id = $1 order by name', [appId]),
|
|
@@ -32,8 +33,9 @@ export async function lookups(appId: number, pageId?: number): Promise<Lookups>
|
|
|
32
33
|
owner.query('select name from meta.list where app_id = $1 order by name', [appId]),
|
|
33
34
|
owner.query('select id, list_name, label from meta.list_entry where app_id = $1 order by list_name, seq, id', [appId]),
|
|
34
35
|
owner.query('select name from meta.automation where app_id = $1 order by name', [appId]),
|
|
36
|
+
owner.query('select name from meta.page_group where app_id = $1 order by name', [appId]),
|
|
35
37
|
]);
|
|
36
|
-
return { regions: regions.rows, pages: pages.rows, authz: authz.rows.map((r) => r.name), nav: nav.rows, buildOptions: buildOptions.rows, restSources: rest.rows.map((r) => r.name), lists: lists.rows.map((r) => r.name), listEntries: listEntries.rows, automations: automations.rows.map((r) => r.name) };
|
|
38
|
+
return { regions: regions.rows, pages: pages.rows, authz: authz.rows.map((r) => r.name), nav: nav.rows, buildOptions: buildOptions.rows, restSources: rest.rows.map((r) => r.name), lists: lists.rows.map((r) => r.name), listEntries: listEntries.rows, automations: automations.rows.map((r) => r.name), pageGroups: pageGroups.rows.map((r) => r.name) };
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Choices of a build option field: each option and its negation, plus a missing current value. */
|
|
@@ -84,7 +86,7 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
|
|
|
84
86
|
html`<select id="${id}" name="${f.name}">${list.map(([val, label]) => html`<option value="${val}"${String(v ?? '') === val ? raw(' selected') : ''}>${label}</option>`)}</select>`;
|
|
85
87
|
switch (f.kind) {
|
|
86
88
|
case 'bool':
|
|
87
|
-
return html`<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="${f.name}" value="true"${v ? raw(' checked') : ''}> ${f.label}</label>${help}</div>`;
|
|
89
|
+
return html`<div class="field" data-prop="${f.name}"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="${f.name}" value="true"${v ? raw(' checked') : ''}> ${f.label}</label>${help}</div>`;
|
|
88
90
|
case 'options': {
|
|
89
91
|
const chosen: unknown[] = Array.isArray(v) ? v : [];
|
|
90
92
|
return html`<fieldset class="field options-field"${f.wide ? raw(' data-wide') : ''}><legend class="label">${f.label}</legend>
|
|
@@ -159,7 +161,7 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
|
|
|
159
161
|
default:
|
|
160
162
|
control = html`<input id="${id}" name="${f.name}" type="${f.kind === 'int' ? 'number' : 'text'}" value="${v ?? ''}">`;
|
|
161
163
|
}
|
|
162
|
-
return html`<div class="field"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
|
|
164
|
+
return html`<div class="field" data-prop="${f.name}"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
|
|
163
165
|
};
|
|
164
166
|
const groups = [...new Set(spec.fields.map((f) => f.group ?? ''))];
|
|
165
167
|
return html`<form method="post" action="${action}" class="component-form"${opts.id ? html` id="${opts.id}"` : ''}>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { back, BASE, developer, flash, type Req } from './ui.ts';
|
|
5
|
+
|
|
6
|
+
// Application settings → Sign-in methods (migration 087): the application's
|
|
7
|
+
// own login page (APEX: the login page, usually 9999) and "Forgot password?".
|
|
8
|
+
// "Create login page" adds a minimal public page with a login region and
|
|
9
|
+
// makes it the login page.
|
|
10
|
+
|
|
11
|
+
interface AppRow {
|
|
12
|
+
id: number;
|
|
13
|
+
name: string;
|
|
14
|
+
authentication: string;
|
|
15
|
+
local_login: boolean;
|
|
16
|
+
login_page: number | null;
|
|
17
|
+
password_reset: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The fields in the settings form. */
|
|
21
|
+
export async function loginSettingsFields(a: AppRow) {
|
|
22
|
+
const pages = (await owner.query<{ page_no: number; name: string; requires_auth: boolean }>(
|
|
23
|
+
'select page_no, name, requires_auth from meta.page where app_id = $1 order by page_no', [a.id],
|
|
24
|
+
)).rows;
|
|
25
|
+
const mail = await owner.one<{ service: string | null; enabled: boolean | null; from_address: string | null }>(
|
|
26
|
+
`select s.name as service, s.enabled, ams.from_address
|
|
27
|
+
from meta.app_mail_service ams join meta.mail_service s on s.id = ams.service_id where ams.app_id = $1`, [a.id],
|
|
28
|
+
);
|
|
29
|
+
const mailOk = !!(mail?.enabled && mail.from_address?.trim());
|
|
30
|
+
const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
|
|
31
|
+
const current = a.login_page ? String(a.login_page) : '';
|
|
32
|
+
return html`<div class="form-grid">
|
|
33
|
+
<div class="field"><label class="label" for="f_login_page">Login page</label>
|
|
34
|
+
<select id="f_login_page" name="login_page">${opt('', '- the built-in sign-in page -', current)}${pages.map((p) =>
|
|
35
|
+
opt(String(p.page_no), `${p.page_no} ${p.name}${p.requires_auth ? ' (requires sign-in: not usable)' : ''}`, current))}</select>
|
|
36
|
+
<small class="help">A page of the application with a <em>Login</em> region replaces the built-in sign-in page (APEX: the login page). It must not require authentication; with page template <em>Minimal</em> it has no header and navigation.
|
|
37
|
+
<button class="link-button" formaction="${BASE}/apps/${a.id}/login-page">Create a login page</button></small></div>
|
|
38
|
+
<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
|
|
39
|
+
<small class="help">Users with a password and an e-mail address in the user directory get a link to choose a new password (single-use, valid for 30 minutes).
|
|
40
|
+
${mailOk
|
|
41
|
+
? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
|
|
42
|
+
: html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
|
|
43
|
+
Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Save them (with the rest of the settings form). */
|
|
48
|
+
export async function saveLoginSettings(appId: string, b: Record<string, string | undefined>) {
|
|
49
|
+
const page = b.login_page?.trim() ? Number(b.login_page) : null;
|
|
50
|
+
if (page !== null && !(await owner.one('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, page]))) throw new Error(`Login page: there is no page ${b.login_page}.`);
|
|
51
|
+
await owner.query('update meta.app set login_page = $2, password_reset = $3 where id = $1', [appId, page, b.password_reset === 'true']);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loginPageRoutes(app: FastifyInstance) {
|
|
55
|
+
// Create a login page: page 9999 (or the next free number), public, minimal, with a login region
|
|
56
|
+
app.post(`${BASE}/apps/:id/login-page`, async (req: Req, reply) => {
|
|
57
|
+
const s = await developer(req, reply);
|
|
58
|
+
if (!s) return;
|
|
59
|
+
const a = await owner.one<{ id: number; name: string }>('select id, name from meta.app where id = $1', [req.params.id]);
|
|
60
|
+
if (!a) return reply.code(404).send('Not found');
|
|
61
|
+
try {
|
|
62
|
+
const { page, no } = await owner.tx(async (c) => {
|
|
63
|
+
const n = (await c.query<{ n: number }>(
|
|
64
|
+
`select case when not exists (select 1 from meta.page where app_id = $1 and page_no = 9999) then 9999
|
|
65
|
+
else (select max(page_no) + 1 from meta.page where app_id = $1) end as n`, [a.id],
|
|
66
|
+
)).rows[0].n;
|
|
67
|
+
const id = (await c.query<{ id: number }>(
|
|
68
|
+
`insert into meta.page (app_id, page_no, name, title, requires_auth, page_template) values ($1, $2, 'Login', $3, false, 'minimal') returning id`,
|
|
69
|
+
[a.id, n, a.name],
|
|
70
|
+
)).rows[0].id;
|
|
71
|
+
await c.query(`insert into meta.region (page_id, seq, title, type, template) values ($1, 10, $2, 'login', 'standard')`, [id, a.name]);
|
|
72
|
+
await c.query('update meta.app set login_page = $2, updated_at = now() where id = $1', [a.id, n]);
|
|
73
|
+
return { page: id, no: n };
|
|
74
|
+
});
|
|
75
|
+
flash(s, `Page ${no} "Login" created and made the login page. Add regions around the sign-in form in the Page Designer.`);
|
|
76
|
+
return back(reply, s, `${BASE}/pages/${page}`);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
flash(s, (e as Error).message, 'error');
|
|
79
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/settings`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -12,7 +12,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
12
12
|
[['chart'], '{"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"}'],
|
|
13
13
|
[['cards'], '{"style":"metric","link":{...},"max_rows":500}'],
|
|
14
14
|
[['dynamic'], '{"max_rows":1000}'],
|
|
15
|
-
[['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME"}'],
|
|
15
|
+
[['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME","views":["row","group","chart"] (views besides the grid; left out: all three)}'],
|
|
16
16
|
[['calendar'], '{"link":{...}}'],
|
|
17
17
|
[['facets'], '{"report":<region id>,"search":true,"facets":[{"column":"job","label":"Job","exclude":true},{"column":"sal","type":"range","ranges":[{"to":1000},{"from":1000}],"custom":true},{"column":"rating","type":"star","max":5}]}'],
|
|
18
18
|
[['smart_filters'], '{"report":<region id>,"suggestions":3,"placeholder":"…","facets":[…as facets]}'],
|
|
@@ -25,6 +25,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
25
25
|
[['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
|
|
26
26
|
[['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
|
|
27
27
|
[['form'], '{"lost_update":false} saves without checking that the row is unchanged since it was shown'],
|
|
28
|
+
[['login'], '(no attributes) the sign-in form of the application\'s login page (Application settings → Login page): providers, user name and password, "Keep me signed in", "Forgot password?"'],
|
|
28
29
|
[['url'], '{"height":480,"sandbox":["allow-scripts","allow-forms","allow-popups","allow-same-origin"] (the default; false: no sandbox),"fullscreen":true}'],
|
|
29
30
|
[['search'], '{"configs":["CUSTOMERS","ORDERS"] (Shared Components → Search configurations; empty: all),"item":"P1_SEARCH" (search with this item\'s value instead of a field of its own),"placeholder":"…","min_length":2,"empty":"No results"}'],
|
|
30
31
|
];
|
|
@@ -53,6 +54,7 @@ const ANCHORS: Record<string, string> = {
|
|
|
53
54
|
plugin: 'plug-ins-with-their-own-code',
|
|
54
55
|
tasks: 'approvals-and-the-task-list',
|
|
55
56
|
workflows: 'workflows',
|
|
57
|
+
login: 'the-login-page',
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
/** The help of every type (a region whose type isn't known yet). */
|
|
@@ -63,7 +65,7 @@ export function regionConfigHelp(type: string | null | undefined): { text: strin
|
|
|
63
65
|
if (!type) return { text: ALL_REGION_CONFIG_HELP, url: null };
|
|
64
66
|
const own = REGION_CONFIG_HELP.filter(([types]) => types.includes(type)).map(([, text]) => text);
|
|
65
67
|
const any = REGION_CONFIG_HELP.filter(([types]) => types.includes('*')).map(([, text]) => `any region: ${text}`);
|
|
66
|
-
const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : 'pages-and-regions';
|
|
68
|
+
const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : type === 'login' ? 'security' : 'pages-and-regions';
|
|
67
69
|
return {
|
|
68
70
|
text: [...own, ...any].join(' · ') || 'No attributes of its own.',
|
|
69
71
|
url: ANCHORS[type] ? `${GUIDE}/${chapter}#${ANCHORS[type]}` : null,
|
|
@@ -242,6 +242,10 @@ function mergeGridFeatures(out: Config, b: Body, a: Allowed, columns: string[])
|
|
|
242
242
|
set('saved_reports', b.saved_reports === 'true' ? undefined : false);
|
|
243
243
|
const pub = (b.public_reports ?? '').trim().toUpperCase();
|
|
244
244
|
set('public_reports', pub && a.authz?.has(pub) ? pub : undefined);
|
|
245
|
+
// views besides the grid: all three is the default (left out); none ticked means the grid only
|
|
246
|
+
// (only when the form has the Views fieldset)
|
|
247
|
+
const views = GRID_USER_VIEWS.filter((v) => b[`view_${v}`] === 'true');
|
|
248
|
+
if (b.grid_views === '1') set('views', views.length < GRID_USER_VIEWS.length ? views : undefined);
|
|
245
249
|
// row actions: an edit link, duplicate and delete; custom links stay as written in the JSON
|
|
246
250
|
const edit = mergeLink(b, a.pages, 'edit');
|
|
247
251
|
const ra: Config = { ...(out.row_actions && typeof out.row_actions === 'object' ? out.row_actions : {}) };
|
|
@@ -342,8 +346,10 @@ export function mergeSmartFiltersSettings(config: Config, b: Body, a: Allowed):
|
|
|
342
346
|
export function mergeDisplaySelectorSettings(config: Config, b: Body): Config {
|
|
343
347
|
const out = { ...config };
|
|
344
348
|
const set = setter(out);
|
|
345
|
-
set('style', b.style === 'select'
|
|
349
|
+
set('style', b.style === 'select' || b.style === 'carousel' ? b.style : undefined);
|
|
346
350
|
set('show_all', b.show_all === 'true' ? undefined : false);
|
|
351
|
+
const interval = Number(b.interval);
|
|
352
|
+
set('interval', b.style === 'carousel' && Number.isInteger(interval) && interval >= 1 && interval <= 120 ? interval : undefined);
|
|
347
353
|
set('remember', b.remember === 'true' ? undefined : false);
|
|
348
354
|
return out;
|
|
349
355
|
}
|
|
@@ -497,6 +503,11 @@ async function gridFields(appId: number, r: RegionRow, id: (n: string) => string
|
|
|
497
503
|
<div class="field"><label class="label" for="${id('public_reports')}">Public reports by</label>
|
|
498
504
|
<select id="${id('public_reports')}" name="public_reports">${opt('', '- nobody -', cfg.public_reports)}${authz.map((n) => opt(n, n, cfg.public_reports))}</select></div>
|
|
499
505
|
</div></fieldset>
|
|
506
|
+
<input type="hidden" name="grid_views" value="1">
|
|
507
|
+
<fieldset class="prop-group"><legend>Views</legend><div class="form-grid">
|
|
508
|
+
${GRID_USER_VIEWS.map((v) => check(`view_${v}`, GRID_VIEW_LABELS[v], !Array.isArray(cfg.views) || cfg.views.includes(v)))}
|
|
509
|
+
</div>
|
|
510
|
+
<small class="help">Views users may switch to besides the grid. The single row view edits one row at a time (also from the row actions menu); group by and chart are set up in Actions.</small></fieldset>
|
|
500
511
|
<fieldset class="prop-group"><legend>Row actions</legend><div class="form-grid">
|
|
501
512
|
${check('row_actions', 'Row actions menu', !!cfg.row_actions)}
|
|
502
513
|
${check('row_duplicate', 'Duplicate', cfg.row_actions?.duplicate !== false)}
|
|
@@ -634,6 +645,8 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
|
|
|
634
645
|
}
|
|
635
646
|
|
|
636
647
|
const VIEW_LABELS: Record<string, string> = { month: 'Month', week: 'Week', day: 'Day', list: 'List' };
|
|
648
|
+
const GRID_USER_VIEWS = ['row', 'group', 'chart'] as const;
|
|
649
|
+
const GRID_VIEW_LABELS: Record<string, string> = { row: 'Single row view', group: 'Group by', chart: 'Chart' };
|
|
637
650
|
|
|
638
651
|
async function calendarFields(appId: number, r: RegionRow, id: (n: string) => string, pages: { page_no: number; name: string }[]) {
|
|
639
652
|
const cfg = r.config ?? {};
|
|
@@ -676,7 +689,10 @@ async function displaySelectorFields(r: RegionRow, pageId: number, id: (n: strin
|
|
|
676
689
|
where page_id = $1 and id <> $2 and type <> 'display_selector' order by seq, id`, [pageId, r.id])).rows;
|
|
677
690
|
return html`<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
|
|
678
691
|
<div class="field"><label class="label" for="${id('style')}">Show as</label>
|
|
679
|
-
<select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}</select></div>
|
|
692
|
+
<select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}${opt('carousel', 'Carousel (one at a time, Previous and Next)', cfg.style)}</select></div>
|
|
693
|
+
<div class="field"><label class="label" for="${id('interval')}">Carousel: next region every (seconds)</label>
|
|
694
|
+
<input id="${id('interval')}" name="interval" type="number" min="1" max="120" value="${cfg.interval ?? ''}" placeholder="empty: only by hand">
|
|
695
|
+
<small class="help">Not while the pointer or the focus is in the carousel, nor for users who prefer reduced motion. A carousel has no "Show all".</small></div>
|
|
680
696
|
</div>
|
|
681
697
|
${check('show_all', '"Show all" choice (shows every region)', cfg.show_all !== false)}
|
|
682
698
|
${check('remember', 'Remember the choice during the session', cfg.remember !== false)}</fieldset>
|
|
@@ -765,7 +781,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
|
|
|
765
781
|
<div class="field"><label class="label" for="${id('list')}">List</label>
|
|
766
782
|
<select id="${id('list')}" name="list">${opt('', '- choose -', cfg.list)}${lists.map((l) => opt(l.name, `${l.name} (${l.type})`, cfg.list))}${cfg.list && !lists.some((l) => l.name === cfg.list) ? opt(cfg.list, `${cfg.list} (missing!)`, cfg.list) : ''}</select></div>
|
|
767
783
|
<div class="field"><label class="label" for="${id('template')}">Template</label>
|
|
768
|
-
<select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}</select></div>
|
|
784
|
+
<select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}${opt('wizard_progress', 'Wizard progress (numbered steps)', cfg.template)}</select></div>
|
|
769
785
|
</div></fieldset>`;
|
|
770
786
|
break;
|
|
771
787
|
}
|
package/src/builder/routes.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { pwaSection } from './pwa.ts';
|
|
|
12
12
|
import { documentShell } from '../layout.ts';
|
|
13
13
|
import { passwordProblem } from '../accounts.ts';
|
|
14
14
|
import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
|
|
15
|
+
import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
|
|
15
16
|
import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
|
|
16
17
|
import { appOr404 } from './forms.ts';
|
|
17
18
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
@@ -207,6 +208,11 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
207
208
|
wizardTables(a.db_role),
|
|
208
209
|
]);
|
|
209
210
|
const nextPage = Math.max(0, ...pages.rows.map((p) => p.page_no)) + 1;
|
|
211
|
+
// (089) page groups: a column and a filter (?group=name, or "-" for pages without one)
|
|
212
|
+
const groups = (await owner.query<{ name: string }>('select name from meta.page_group where app_id = $1 order by name', [a.id])).rows.map((g) => g.name);
|
|
213
|
+
const grouped = groups.length > 0 || pages.rows.some((p) => p.page_group);
|
|
214
|
+
const groupFilter = typeof req.query?.group === 'string' ? req.query.group : '';
|
|
215
|
+
const shownPages = pages.rows.filter((p) => !groupFilter || (groupFilter === '-' ? !p.page_group : p.page_group === groupFilter));
|
|
210
216
|
const locks = new Map((await appLocks(a.id)).map((l) => [l.page_no, l]));
|
|
211
217
|
const commentsPerPage = new Map((await owner.query('select page_no, count(*)::int as n from meta.dev_comment where app_id = $1 group by 1', [a.id])).rows.map((r) => [r.page_no, r.n]));
|
|
212
218
|
const appLock = locks.get(0);
|
|
@@ -214,10 +220,14 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
214
220
|
const main = html`
|
|
215
221
|
${appHeader(a, 'pages')}
|
|
216
222
|
${appLock && appLock.locked_by !== s.username ? html`<div class="alert alert-error" role="status">${icon('key')} ${lockText(appLock)} Changes are refused until it is unlocked.</div>` : ''}
|
|
217
|
-
${region('Pages', html
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
${region('Pages', html`${grouped
|
|
224
|
+
? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">Page group</label>
|
|
225
|
+
<select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
|
|
226
|
+
<button class="btn u-selfend">Show</button></form>`
|
|
227
|
+
: ''}<div class="table-wrap"><table class="report">
|
|
228
|
+
<thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
|
|
229
|
+
<tbody>${shownPages.map((p) => html`<tr>
|
|
230
|
+
<td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
|
|
221
231
|
<td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
|
|
222
232
|
<td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
|
|
223
233
|
<td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
|
|
@@ -354,6 +364,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
354
364
|
: html`<p class="muted">No LDAP directories configured. <a href="${BASE}/users/directories">Add one</a> to check passwords against LDAP or Active Directory.</p>`}
|
|
355
365
|
<small class="help">LDAP passwords are checked by the username and password form, after local accounts, so keep that method on.</small>
|
|
356
366
|
<div class="form-grid">${input('session_group', 'Session sharing group', a.session_group ?? '', { placeholder: 'empty: not shared', help: 'Applications with the same group (and the user directory as authentication) share a sign-in: after signing in to one, the others open without a new sign-in, each with its own access check and roles; signing out of one signs out of all (APEX: session sharing). Lower case letters, digits and _.' })}</div>
|
|
367
|
+
${await loginSettingsFields(a)}
|
|
357
368
|
<div class="form-grid">${input('remember_me_days', '"Keep me signed in" for (days)', a.remember_me_days ?? '', { type: 'number', placeholder: 'empty: not offered', help: 'Offers a checkbox on the sign-in form (APEX: persistent authentication). The browser stays signed in for this many days after the sign-in, also when the session ends; signing out, a new password, deactivation or removed access ends it. 1 to 365.' })}</div>
|
|
358
369
|
${providers.length
|
|
359
370
|
? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> Sign in with ${pr.display_name}${pr.enabled ? '' : ' (disabled)'}</label></div>`)
|
|
@@ -484,6 +495,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
484
495
|
/^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null],
|
|
485
496
|
);
|
|
486
497
|
await saveTimeZoneSettings(req.params.id, b);
|
|
498
|
+
await saveLoginSettings(req.params.id, b);
|
|
487
499
|
flash(s, 'Settings saved.');
|
|
488
500
|
} catch (e) {
|
|
489
501
|
flash(s, (e as Error).message, 'error');
|
package/src/builder/shared.ts
CHANGED
|
@@ -33,7 +33,7 @@ function listExtras(appId: number, list: any, entries: any[]): Raw {
|
|
|
33
33
|
|
|
34
34
|
export async function sharedRoutes(app: FastifyInstance) {
|
|
35
35
|
// ---------------------------------------------------------------- shared components
|
|
36
|
-
const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
|
|
36
|
+
const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
|
|
37
37
|
|
|
38
38
|
const ORDER: Record<string, string> = {
|
|
39
39
|
search_config: 'seq, name', dynamic_translation: 'language, source',
|
package/src/builder/sql.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { scriptRoutes } from './scripts.ts';
|
|
|
10
10
|
import { quickSqlRoutes } from './quicksql.ts';
|
|
11
11
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
12
12
|
import { unloadRoutes } from './unload.ts';
|
|
13
|
+
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
13
14
|
|
|
14
15
|
// SQL Workshop: SQL commands and the object browser (owner connection).
|
|
15
16
|
|
|
@@ -20,6 +21,8 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
20
21
|
await app.register(queryBuilderRoutes);
|
|
21
22
|
// Unload Data (sprint 32)
|
|
22
23
|
await app.register(unloadRoutes);
|
|
24
|
+
// Generate DDL and Schema Comparison (sprint 56)
|
|
25
|
+
await app.register(ddlToolRoutes);
|
|
23
26
|
// ---------------------------------------------------------------- SQL workshop
|
|
24
27
|
const resultTable = (res: pg.QueryResult<any[]>, limit = 500) => {
|
|
25
28
|
const rows = (res.rows ?? []).slice(0, limit);
|
package/src/builder/ui.ts
CHANGED
|
@@ -209,13 +209,13 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
209
209
|
};
|
|
210
210
|
|
|
211
211
|
/** Tabs of the SQL Workshop. */
|
|
212
|
-
export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai';
|
|
212
|
+
export type WorkshopTab = 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
|
|
213
213
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
214
214
|
const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
|
|
215
215
|
html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
216
216
|
return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
|
|
217
217
|
${tab('sql', '/sql', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
|
|
218
|
-
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
|
|
218
|
+
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
|
|
219
219
|
};
|
|
220
220
|
|
|
221
221
|
/** Number of app tile colours (.app-color-0 … -7 in app.css). */
|
package/src/cli/main.ts
CHANGED
|
@@ -133,6 +133,51 @@ const COMMANDS: Record<string, Command> = {
|
|
|
133
133
|
},
|
|
134
134
|
},
|
|
135
135
|
|
|
136
|
+
ddl: {
|
|
137
|
+
usage: 'pgkiln ddl <schema> [--no-comments] [--no-grants] [--out <file>]',
|
|
138
|
+
summary: 'write the DDL of a schema\'s objects (SQL Workshop → Generate DDL)',
|
|
139
|
+
details:
|
|
140
|
+
'Types, sequences, tables, functions, views, constraints, indexes, triggers, row level security,\n' +
|
|
141
|
+
'comments and grants, in an order that runs, with the schema in search_path at the top (so the\n' +
|
|
142
|
+
'script also creates the objects in another schema). Objects of extensions are left out.',
|
|
143
|
+
options: { 'no-comments': { type: 'boolean' }, 'no-grants': { type: 'boolean' }, out: { type: 'string', short: 'o' } },
|
|
144
|
+
optionHelp: [['--no-comments', 'leave out comments'], ['--no-grants', 'leave out grants'], ['-o, --out <file>', 'write to a file instead of standard output']],
|
|
145
|
+
positionals: [1, 1],
|
|
146
|
+
async run(v, [schema]) {
|
|
147
|
+
const { ddlScript, OBJECT_TYPES, pick, readSchema } = await import('../ddl.ts');
|
|
148
|
+
const objects = await withDb((db) => readSchema(db, schema));
|
|
149
|
+
const script = ddlScript(schema, pick(objects, new Set(OBJECT_TYPES), { comments: !v['no-comments'], grants: !v['no-grants'] }));
|
|
150
|
+
if (v.out) {
|
|
151
|
+
writeFileSync(v.out as string, script);
|
|
152
|
+
err(`Wrote the DDL of ${schema} to ${v.out}.\n`);
|
|
153
|
+
} else out(script);
|
|
154
|
+
return EXIT.ok;
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
compare: {
|
|
159
|
+
usage: 'pgkiln compare <source> <target> [--script]',
|
|
160
|
+
summary: 'compare two schemas (SQL Workshop → Schema Comparison); exit code 1 when they differ',
|
|
161
|
+
details:
|
|
162
|
+
'Lists what only the source has (+), what only the target has (-) and what differs (~), by their DDL;\n' +
|
|
163
|
+
'the order of a table\'s columns is not compared. --script writes the script that makes the target\n' +
|
|
164
|
+
'like the source instead (drops of what only the target has are commented out). Nothing is run.',
|
|
165
|
+
options: { script: { type: 'boolean' } },
|
|
166
|
+
optionHelp: [['--script', 'write the change script']],
|
|
167
|
+
positionals: [2, 2],
|
|
168
|
+
async run(v, [source, target]) {
|
|
169
|
+
const { changeScript, compare, readSchema } = await import('../ddl.ts');
|
|
170
|
+
const [a, b] = await withDb(async (db) => [await readSchema(db, source), await readSchema(db, target)]);
|
|
171
|
+
const { differences, same } = compare(a, b);
|
|
172
|
+
if (v.script) out(changeScript(source, target, a, b));
|
|
173
|
+
else {
|
|
174
|
+
for (const d of differences) out(`${d.state === 'source' ? '+' : d.state === 'target' ? '-' : '~'} ${d.kind} ${d.name}\n`);
|
|
175
|
+
err(differences.length ? `${differences.length} difference(s), ${same} object(s) the same.\n` : `The schemas are the same (${same} objects).\n`);
|
|
176
|
+
}
|
|
177
|
+
return differences.length ? EXIT.differences : EXIT.ok;
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
|
|
136
181
|
export: {
|
|
137
182
|
usage: 'pgkiln export <alias> [--format json|dir|text] [--out <path>]',
|
|
138
183
|
summary: 'export an application as one JSON file or as a directory with a file per component',
|
package/src/cli/mcp.ts
CHANGED
|
@@ -473,6 +473,48 @@ export const TOOLS: Tool[] = [
|
|
|
473
473
|
});
|
|
474
474
|
},
|
|
475
475
|
},
|
|
476
|
+
{
|
|
477
|
+
name: 'generate_ddl',
|
|
478
|
+
title: 'Generate the DDL of a schema',
|
|
479
|
+
description:
|
|
480
|
+
'The DDL of a schema\'s objects from the catalog (types, sequences, tables, functions, views, constraints, indexes, triggers, ' +
|
|
481
|
+
'row level security, comments and grants), in an order that runs, with the schema in search_path at the top. ' +
|
|
482
|
+
'Use it to start a migration script or to see how objects are defined; nothing is run.',
|
|
483
|
+
inputSchema: { type: 'object', properties: { schema: { type: 'string', description: 'the schema, e.g. sales' } }, required: ['schema'] },
|
|
484
|
+
annotations: { readOnlyHint: true },
|
|
485
|
+
async run(a) {
|
|
486
|
+
const schema = str(a, 'schema');
|
|
487
|
+
if (!schema) throw new ToolError('schema is required');
|
|
488
|
+
const { ddlScript, readSchema } = await import('../ddl.ts');
|
|
489
|
+
return withDb(async (db) => cap(ddlScript(schema, await readSchema(db, schema))));
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
name: 'compare_schemas',
|
|
494
|
+
title: 'Compare two schemas',
|
|
495
|
+
description:
|
|
496
|
+
'Compares the objects of two schemas of the database by their DDL: what only the source has (+), only the target has (-), ' +
|
|
497
|
+
'and what differs (~), followed by a script that makes the target like the source (drops commented out). Nothing is run.',
|
|
498
|
+
inputSchema: {
|
|
499
|
+
type: 'object',
|
|
500
|
+
properties: { source: { type: 'string', description: 'the schema to compare with, e.g. sales' }, target: { type: 'string', description: 'the schema to make like the source, e.g. sales_dev' } },
|
|
501
|
+
required: ['source', 'target'],
|
|
502
|
+
},
|
|
503
|
+
annotations: { readOnlyHint: true },
|
|
504
|
+
async run(a) {
|
|
505
|
+
const source = str(a, 'source');
|
|
506
|
+
const target = str(a, 'target');
|
|
507
|
+
if (!source || !target) throw new ToolError('source and target are required');
|
|
508
|
+
const { changeScript, compare, readSchema } = await import('../ddl.ts');
|
|
509
|
+
return withDb(async (db) => {
|
|
510
|
+
const [x, y] = [await readSchema(db, source), await readSchema(db, target)];
|
|
511
|
+
const { differences, same } = compare(x, y);
|
|
512
|
+
if (!differences.length) return `The schemas are the same (${same} objects).\n`;
|
|
513
|
+
const list = differences.map((d) => `${d.state === 'source' ? '+' : d.state === 'target' ? '-' : '~'} ${d.kind} ${d.name}`).join('\n');
|
|
514
|
+
return cap(`${differences.length} difference(s), ${same} object(s) the same:\n${list}\n\n${changeScript(source, target, x, y)}`);
|
|
515
|
+
});
|
|
516
|
+
},
|
|
517
|
+
},
|
|
476
518
|
{
|
|
477
519
|
name: 'run_query',
|
|
478
520
|
title: 'Run a read-only query',
|
package/src/cli/replace.ts
CHANGED
|
@@ -26,9 +26,13 @@ export const REPLACED = [
|
|
|
26
26
|
'search_config', 'email_template',
|
|
27
27
|
// (086) application settings (installed values kept, see below), dynamic translations
|
|
28
28
|
'app_setting', 'dynamic_translation',
|
|
29
|
+
// (089) page groups
|
|
30
|
+
'page_group',
|
|
29
31
|
];
|
|
30
32
|
/** Tables of an application that belong to the installation: kept. */
|
|
31
33
|
export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'saved_report', 'persistent_login', 'task', 'workflow', 'process_job',
|
|
34
|
+
// (087) password reset links of this installation's accounts
|
|
35
|
+
'password_reset',
|
|
32
36
|
// (042) builder state of this installation: locks and developer comments (by page number)
|
|
33
37
|
'builder_lock', 'dev_comment',
|
|
34
38
|
// (051) debug messages recorded by this installation
|