pgkiln 0.35.0 → 0.37.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/SECURITY.md +11 -9
- 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/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- 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 +13 -3
- 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_40_sample_data.sql +1 -1
- 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 +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- 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/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- 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 +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
|
@@ -48,6 +48,8 @@ directory the agent started the server in (the project).
|
|
|
48
48
|
| `get_page` | every component of one page as YAML, SQL inline | no |
|
|
49
49
|
| `read_app_files` | component files by path or prefix (`shared/lovs/`, `navigation.yaml`, …) | no |
|
|
50
50
|
| `describe_schema` | schemas with tables, views and functions; or one table with columns, constraints, indexes, RLS policies and triggers | no |
|
|
51
|
+
| `generate_ddl` | the DDL of a schema's objects, in an order that runs: a start for a migration script | no |
|
|
52
|
+
| `compare_schemas` | the differences between two schemas and a script that makes the target like the source (not run) | no |
|
|
51
53
|
| `run_query` | one SQL statement, read-only, rows as JSON | no |
|
|
52
54
|
| `search_docs` | searches this user guide, the parity matrix and SECURITY.md | no |
|
|
53
55
|
| `recent_errors` | the latest errors and refused requests from the activity log | no |
|
|
@@ -47,4 +47,4 @@ values ('HR demo staff', 'Departments, employees and leave requests for trying o
|
|
|
47
47
|
{"column": "decision_note", "generator": "skip", "options": "", "nulls": 0},
|
|
48
48
|
{"column": "created_at", "generator": "timestamp", "options": "2025-10-01..2026-10-01", "nulls": 0}]}
|
|
49
49
|
]$json$::jsonb, 'admin', 'admin')
|
|
50
|
-
on conflict (name) do nothing;
|
|
50
|
+
on conflict (workspace_id, name) do nothing;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 54: region templates (migration 088;
|
|
3
|
+
-- docs/guide/04-pages-and-regions.md "Region templates")
|
|
4
|
+
--
|
|
5
|
+
-- Page 44 "Region templates" (Administration):
|
|
6
|
+
-- - a hero region (an icon, the title, text and a button);
|
|
7
|
+
-- - a title bar with buttons; a buttons container;
|
|
8
|
+
-- - "Leave policy" opens an inline dialog and "Help" an inline drawer
|
|
9
|
+
-- (dynamic actions "Open region"; a link to #R<id> opens them too);
|
|
10
|
+
-- - a carousel (a display selector with style carousel) over three
|
|
11
|
+
-- regions, turning every 8 seconds;
|
|
12
|
+
-- - the steps of onboarding as a wizard progress list above a wizard
|
|
13
|
+
-- region.
|
|
14
|
+
-- =====================================================================
|
|
15
|
+
|
|
16
|
+
insert into meta.page (app_id, page_no, name, title, parent_page)
|
|
17
|
+
select id, 44, 'Region templates', 'Region templates', 1 from meta.app where alias = 'hr';
|
|
18
|
+
|
|
19
|
+
insert into meta.list (app_id, name, type, description)
|
|
20
|
+
select id, 'HR_ONBOARDING_STEPS', 'static', 'The steps of onboarding (a wizard progress list on page 44)' from meta.app where alias = 'hr';
|
|
21
|
+
|
|
22
|
+
insert into meta.list_entry (app_id, list_name, seq, label, target_page)
|
|
23
|
+
select a.id, 'HR_ONBOARDING_STEPS', e.seq, e.label, e.page
|
|
24
|
+
from meta.app a, (values (10, 'Personal details', 44), (20, 'Contract', 3), (30, 'Equipment', 28), (40, 'Done', 1)) as e (seq, label, page)
|
|
25
|
+
where a.alias = 'hr';
|
|
26
|
+
|
|
27
|
+
insert into meta.region (page_id, seq, title, type, columns, template, source, config)
|
|
28
|
+
select p.id, r.seq, r.title, r.type, 12, r.template, r.source, r.config::jsonb
|
|
29
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
30
|
+
(10, 'Welcome to HR', 'static', 'hero',
|
|
31
|
+
'<p>Everything about the people in the company: employees, departments, leave and reviews.</p>', '{"icon": "users"}'),
|
|
32
|
+
(20, 'Employees this month', 'static', 'title_bar', '<p class="muted">Hires and departures of the current month.</p>', '{}'),
|
|
33
|
+
(30, 'Actions', 'static', 'buttons_container', null, '{}'),
|
|
34
|
+
(40, 'Leave policy', 'static', 'inline_dialog',
|
|
35
|
+
'<p>Everyone has 25 days of leave a year. Ask your manager at least two weeks ahead; approvals come by e-mail.</p>', '{}'),
|
|
36
|
+
(50, 'Help', 'static', 'inline_drawer',
|
|
37
|
+
'<p>This page shows the region templates: hero, title bar, buttons container, inline dialog and drawer, carousel and wizard.</p>', '{}'),
|
|
38
|
+
(60, 'Highlights', 'display_selector', 'plain', null, '{"style": "carousel", "interval": 8, "show_all": false}'),
|
|
39
|
+
(61, 'New colleagues', 'static', 'standard', '<p>Three people joined the Research department this month.</p>', '{"display_selector": true}'),
|
|
40
|
+
(62, 'Anniversaries', 'static', 'standard', '<p>KING celebrates 45 years with the company.</p>', '{"display_selector": true}'),
|
|
41
|
+
(63, 'Open positions', 'static', 'standard', '<p>Two analysts and a clerk: see the intranet.</p>', '{"display_selector": true}'),
|
|
42
|
+
(70, 'Onboarding steps', 'list', 'plain', null, '{"list": "HR_ONBOARDING_STEPS", "template": "wizard_progress"}'),
|
|
43
|
+
(80, 'Personal details', 'static', 'wizard', '<p>Step 1: check your name, address and bank account in My account.</p>', '{}')
|
|
44
|
+
) as r (seq, title, type, template, source, config)
|
|
45
|
+
where a.alias = 'hr' and p.page_no = 44;
|
|
46
|
+
|
|
47
|
+
insert into meta.button (page_id, region_id, seq, name, label, action, hot, target_page)
|
|
48
|
+
select p.id, (select r.id from meta.region r where r.page_id = p.id and r.title = b.region), b.seq, b.name, b.label, b.action, b.hot, b.target
|
|
49
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
50
|
+
('Welcome to HR', 10, 'EMPLOYEES', 'See the employees', 'redirect', true, 2),
|
|
51
|
+
('Employees this month', 20, 'POLICY', 'Leave policy', 'da', false, null),
|
|
52
|
+
('Actions', 30, 'HELP', 'Help', 'da', false, null)
|
|
53
|
+
) as b (region, seq, name, label, action, hot, target)
|
|
54
|
+
where a.alias = 'hr' and p.page_no = 44;
|
|
55
|
+
|
|
56
|
+
insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_region_id)
|
|
57
|
+
select p.id, d.seq, d.name, 'click', d.button, 'open_region', (select r.id from meta.region r where r.page_id = p.id and r.title = d.region)
|
|
58
|
+
from meta.page p join meta.app a on a.id = p.app_id, (values
|
|
59
|
+
(10, 'Open the leave policy', 'POLICY', 'Leave policy'),
|
|
60
|
+
(20, 'Open the help', 'HELP', 'Help')
|
|
61
|
+
) as d (seq, name, button, region)
|
|
62
|
+
where a.alias = 'hr' and p.page_no = 44;
|
|
63
|
+
|
|
64
|
+
insert into meta.nav_entry (app_id, parent_id, seq, label, icon, target_page)
|
|
65
|
+
select a.id, n.id, 50, 'Region templates', 'layers', 44
|
|
66
|
+
from meta.app a join meta.nav_entry n on n.app_id = a.id and n.label = 'Administration' and n.parent_id is null
|
|
67
|
+
where a.alias = 'hr';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- HR example, part 55: page groups (migration 089;
|
|
3
|
+
-- docs/guide/03-builder.md "Page groups")
|
|
4
|
+
--
|
|
5
|
+
-- The HR pages in four groups, shown and filtered in the App Builder's
|
|
6
|
+
-- page list (Shared Components → Page groups).
|
|
7
|
+
-- =====================================================================
|
|
8
|
+
insert into meta.page_group (app_id, name, description)
|
|
9
|
+
select a.id, g.name, g.description
|
|
10
|
+
from meta.app a, (values
|
|
11
|
+
('Employees', 'Employees, departments and their reviews'),
|
|
12
|
+
('Leave', 'Leave requests, the calendar and the planner'),
|
|
13
|
+
('Administration', 'Pages for administrators'),
|
|
14
|
+
('Showcase', 'Pages that show one feature each')
|
|
15
|
+
) as g (name, description)
|
|
16
|
+
where a.alias = 'hr';
|
|
17
|
+
|
|
18
|
+
update meta.page p set page_group = g.name
|
|
19
|
+
from meta.app a, (values
|
|
20
|
+
('Employees', array[2, 3, 4, 5, 8, 11, 19, 20, 27]),
|
|
21
|
+
('Leave', array[6, 7, 12, 22, 24]),
|
|
22
|
+
('Administration', array[9, 10, 43, 44])
|
|
23
|
+
) as g (name, pages)
|
|
24
|
+
where a.alias = 'hr' and p.app_id = a.id and p.page_no = any (g.pages);
|
package/package.json
CHANGED
package/public/app.css
CHANGED
|
@@ -254,7 +254,43 @@ details.menu > summary::-webkit-details-marker { display: none; }
|
|
|
254
254
|
|
|
255
255
|
/* ---------- regions ---------- */
|
|
256
256
|
.region { min-width: 0; }
|
|
257
|
-
.region-standard, .region-collapsible { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
257
|
+
.region-standard, .region-collapsible, .region-wizard, .region-inline_dialog, .region-inline_drawer { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
258
|
+
/* (088) more region templates */
|
|
259
|
+
.region-hero { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 1rem 1.5rem; padding: 2rem 1.75rem; border-radius: var(--radius);
|
|
260
|
+
background: var(--accent-soft); color: var(--text); }
|
|
261
|
+
.region-hero-icon .icon { width: 3rem; height: 3rem; color: var(--accent); }
|
|
262
|
+
.region-hero-text h2 { font-size: 1.75rem; margin: 0 0 0.35rem; }
|
|
263
|
+
.region-hero-buttons, .region-title-buttons, .region-buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
264
|
+
@media (max-width: 640px) { .region-hero { grid-template-columns: 1fr; padding: 1.25rem 1rem; } }
|
|
265
|
+
.region-title-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1rem; padding: 0.25rem 0; border-bottom: 1px solid var(--border); margin-bottom: 0.75rem; }
|
|
266
|
+
.region-title-bar h2 { margin: 0; font-size: 1.15rem; }
|
|
267
|
+
.region-buttons_container .region-buttons { justify-content: flex-end; }
|
|
268
|
+
.region-wizard { max-width: 640px; width: 100%; margin-left: auto; margin-right: auto; }
|
|
269
|
+
dialog[data-inline-region] { padding: 0; max-width: min(640px, calc(100vw - 2rem)); width: 100%; color: var(--text); }
|
|
270
|
+
dialog[data-inline-region]::backdrop { background: rgb(0 0 0 / 45%); }
|
|
271
|
+
dialog[data-inline-region] .region-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
|
272
|
+
dialog.region-inline_drawer { margin: 0 0 0 auto; height: 100vh; max-height: 100vh; max-width: min(480px, 100vw); border-radius: 0; }
|
|
273
|
+
/* without JavaScript: a link to #R<id> shows it */
|
|
274
|
+
dialog[data-inline-region]:target { display: block; position: fixed; inset: 2rem 0 auto; margin: 0 auto; z-index: 1000; }
|
|
275
|
+
dialog.region-inline_drawer:target { inset: 0 0 0 auto; }
|
|
276
|
+
/* (088) carousel (display selector style) and wizard progress (list template) */
|
|
277
|
+
.rds-carousel { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
|
|
278
|
+
.rds-carousel .rds-list { gap: 0.4rem; }
|
|
279
|
+
.rds-carousel .rds-tab { font-size: 0; width: 0.75rem; height: 0.75rem; padding: 0; border-radius: 50%; background: var(--border); min-height: 0; }
|
|
280
|
+
.rds-carousel .rds-tab.is-current { background: var(--accent); }
|
|
281
|
+
.rds-carousel .rds-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
282
|
+
.wizard-progress ol { list-style: none; margin: 0 0 1rem; padding: 0; display: flex; flex-wrap: wrap; gap: 0.5rem 0; counter-reset: none; }
|
|
283
|
+
.wizard-step { flex: 1 1 0; min-width: 6rem; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.3rem; position: relative; color: var(--muted); font-size: 0.85rem; }
|
|
284
|
+
.wizard-step::before { content: ""; position: absolute; top: 0.95rem; left: -50%; right: 50%; border-top: 2px solid var(--border); z-index: 0; }
|
|
285
|
+
.wizard-step:first-child::before { display: none; }
|
|
286
|
+
.wizard-step a { color: inherit; text-decoration: none; display: contents; }
|
|
287
|
+
.wizard-step-no { position: relative; z-index: 1; display: grid; place-items: center; width: 1.9rem; height: 1.9rem; border-radius: 50%; border: 2px solid var(--border); background: var(--surface); font-weight: 600; }
|
|
288
|
+
.wizard-step-no .icon { width: 1rem; height: 1rem; }
|
|
289
|
+
.wizard-step.is-done, .wizard-step.is-current { color: var(--text); }
|
|
290
|
+
.wizard-step.is-done .wizard-step-no { border-color: var(--accent); color: var(--accent); }
|
|
291
|
+
.wizard-step.is-done::before, .wizard-step.is-current::before { border-top-color: var(--accent); }
|
|
292
|
+
.wizard-step.is-current .wizard-step-no { background: var(--accent); border-color: var(--accent); color: var(--accent-text, #fff); }
|
|
293
|
+
.wizard-step.is-current .wizard-step-label { font-weight: 600; }
|
|
258
294
|
.region-header { display: flex; justify-content: space-between; align-items: center; gap: 1rem; min-height: 50px; padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); }
|
|
259
295
|
.region-collapsible summary.region-header { cursor: pointer; list-style: none; }
|
|
260
296
|
.region-collapsible summary.region-header::-webkit-details-marker { display: none; }
|
|
@@ -810,6 +846,14 @@ fieldset.prop-group > legend { font-size: 0.75rem; text-transform: uppercase; le
|
|
|
810
846
|
.grid-table tr.deleted td { opacity: 0.5; text-decoration: line-through; }
|
|
811
847
|
.grid-table tr.grid-new { background: color-mix(in srgb, var(--accent-soft) 45%, transparent); }
|
|
812
848
|
.grid-table tr.dirty td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
|
|
849
|
+
/* the single row view: one row as a form */
|
|
850
|
+
.grid-record { padding: 0.9rem 1rem; border: 1px solid var(--border); border-radius: var(--radius, 8px); display: grid; gap: 0.9rem; }
|
|
851
|
+
.grid-record .grid-field { display: grid; gap: 0.3rem; align-content: start; }
|
|
852
|
+
.grid-record .grid-value { min-height: 1.5rem; overflow-wrap: anywhere; }
|
|
853
|
+
.grid-record.grid-new { background: color-mix(in srgb, var(--accent-soft) 45%, transparent); }
|
|
854
|
+
.grid-record.dirty { box-shadow: inset 3px 0 0 var(--accent); }
|
|
855
|
+
.grid-record.deleted .form-grid { opacity: 0.5; text-decoration: line-through; }
|
|
856
|
+
.grid-record-nav { justify-content: center; }
|
|
813
857
|
.js .grid-template { display: none; }
|
|
814
858
|
.grid-table .icon { width: 14px; height: 14px; }
|
|
815
859
|
/* columns a user hid stay in the form (their values still post) */
|
|
@@ -1006,9 +1050,19 @@ input[type=color] { width: 4rem; min-height: 2.4rem; padding: 0.15rem; }
|
|
|
1006
1050
|
/* single sign-on buttons on the login page */
|
|
1007
1051
|
.sso-buttons { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
1008
1052
|
.sso-buttons .btn { padding: 0.6rem; }
|
|
1009
|
-
.login-card .or { display: flex; align-items: center; gap: 0.75rem; color: var(--muted); font-size: 0.85rem; }
|
|
1010
|
-
.login-card .or::before, .login-card .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
|
|
1053
|
+
.login-card .or, .login-region .or { display: flex; align-items: center; gap: 0.75rem; color: var(--muted); font-size: 0.85rem; }
|
|
1054
|
+
.login-card .or::before, .login-card .or::after, .login-region .or::before, .login-region .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
|
|
1011
1055
|
.login-form { display: flex; flex-direction: column; gap: 1rem; }
|
|
1056
|
+
.login-forgot { align-self: center; font-size: 0.9rem; }
|
|
1057
|
+
/* (087) minimal pages (no header and navigation) and an application's own login page */
|
|
1058
|
+
.t-minimal-main { width: 100%; max-width: 1100px; margin: 0 auto; padding: 1.5rem 1rem; }
|
|
1059
|
+
.login-body .t-minimal-main { max-width: 420px; padding: 1rem; }
|
|
1060
|
+
.login-region { display: flex; flex-direction: column; gap: 1rem; }
|
|
1061
|
+
.login-region .btn { padding: 0.6rem; }
|
|
1062
|
+
.t-minimal .t-titlebar { background: none; border: 0; padding: 0 0 1rem; }
|
|
1063
|
+
.t-minimal .t-content { padding: 0; max-width: none; }
|
|
1064
|
+
/* on the sign-in backdrop the title sits on the header colour, like the built-in page's card title */
|
|
1065
|
+
.login-body .t-titlebar h1 { color: var(--header-text); text-align: center; }
|
|
1012
1066
|
|
|
1013
1067
|
/* ---------- preferences: theme switch, language picker ---------- */
|
|
1014
1068
|
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; margin-top: 0.35rem; }
|
|
@@ -1612,3 +1666,6 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
|
|
|
1612
1666
|
.session-dialog { max-width: min(28rem, calc(100vw - 2rem)); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); padding: 1.25rem; }
|
|
1613
1667
|
.session-dialog::backdrop { background: rgb(0 0 0 / 40%); }
|
|
1614
1668
|
.session-dialog p { margin: 0 0 1rem; }
|
|
1669
|
+
/* (sprint 59) Quick edit from the developer toolbar */
|
|
1670
|
+
.quick-edit .qe-target { outline: 2px dashed var(--accent); outline-offset: 2px; cursor: pointer; }
|
|
1671
|
+
.devbar-quick[aria-pressed="true"] { background: var(--accent); color: var(--accent-text); }
|
package/public/app.js
CHANGED
|
@@ -539,7 +539,11 @@ const pgkilnUnsaved = (() => {
|
|
|
539
539
|
case 'close_region':
|
|
540
540
|
for (const el of targets(da)) {
|
|
541
541
|
const details = el.querySelector(':scope > details');
|
|
542
|
-
|
|
542
|
+
// (088) an inline dialog or drawer region
|
|
543
|
+
if (el.matches('dialog[data-inline-region]')) {
|
|
544
|
+
if (da.action === 'open_region' && !el.open) el.showModal();
|
|
545
|
+
else if (da.action === 'close_region' && el.open) el.close();
|
|
546
|
+
} else if (details) details.open = da.action === 'open_region';
|
|
543
547
|
else el.hidden = da.action === 'close_region';
|
|
544
548
|
}
|
|
545
549
|
return;
|
|
@@ -1157,14 +1161,15 @@ const pgkilnUnsaved = (() => {
|
|
|
1157
1161
|
if (leave && dirty && !window.confirm(pgkilnUnsaved.text)) e.preventDefault();
|
|
1158
1162
|
});
|
|
1159
1163
|
document.addEventListener('input', (e) => {
|
|
1160
|
-
|
|
1164
|
+
// a grid row, or the single row view's form
|
|
1165
|
+
const row = e.target.closest?.('[data-grid] table.grid-table > tbody > tr, [data-grid] .grid-record');
|
|
1161
1166
|
if (!row) return;
|
|
1162
1167
|
row.classList.add('dirty');
|
|
1163
1168
|
dirty = true;
|
|
1164
1169
|
});
|
|
1165
1170
|
document.addEventListener('change', (e) => {
|
|
1166
1171
|
const del = e.target.closest?.('[data-grid] .grid-sel input');
|
|
1167
|
-
if (del) del.closest('tr').classList.toggle('deleted', del.checked);
|
|
1172
|
+
if (del) del.closest('tr, .grid-record').classList.toggle('deleted', del.checked);
|
|
1168
1173
|
});
|
|
1169
1174
|
document.addEventListener('submit', () => (dirty = false));
|
|
1170
1175
|
window.addEventListener('beforeunload', (e) => {
|
|
@@ -1544,6 +1549,30 @@ document.addEventListener('input', (e) => {
|
|
|
1544
1549
|
if (custom) custom.checked = true;
|
|
1545
1550
|
});
|
|
1546
1551
|
|
|
1552
|
+
// (088) Inline dialog and drawer regions: a link to #R<id> opens one as a modal dialog (without
|
|
1553
|
+
// JavaScript CSS shows it as the :target), its close button and Esc close it, a click on the
|
|
1554
|
+
// backdrop too.
|
|
1555
|
+
document.addEventListener('click', (e) => {
|
|
1556
|
+
const close = e.target.closest?.('[data-region-close]');
|
|
1557
|
+
if (close) {
|
|
1558
|
+
e.preventDefault();
|
|
1559
|
+
close.closest('dialog')?.close();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const a = e.target.closest?.('a[href^="#R"]');
|
|
1563
|
+
const d = a && document.getElementById(a.getAttribute('href').slice(1));
|
|
1564
|
+
if (d?.matches('dialog[data-inline-region]')) {
|
|
1565
|
+
e.preventDefault();
|
|
1566
|
+
if (!d.open) d.showModal();
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (e.target.matches?.('dialog[data-inline-region]')) e.target.close();
|
|
1570
|
+
});
|
|
1571
|
+
if (location.hash) {
|
|
1572
|
+
const d = document.getElementById(location.hash.slice(1));
|
|
1573
|
+
if (d?.matches('dialog[data-inline-region]')) d.showModal();
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1547
1576
|
// Region display selector: the links to the regions become ARIA tabs (or a
|
|
1548
1577
|
// select list) that show one tab's regions at a time; "Show all" shows them
|
|
1549
1578
|
// all. The choice follows #R<id> in the URL and is remembered for the session.
|
|
@@ -1617,6 +1646,23 @@ document.querySelectorAll('[data-rds]').forEach((nav) => {
|
|
|
1617
1646
|
const tab = byHash(location.hash.slice(1));
|
|
1618
1647
|
if (tab) show(tab, false);
|
|
1619
1648
|
});
|
|
1649
|
+
// (088) carousel: Previous and Next, and turning by itself every data-rds-interval seconds
|
|
1650
|
+
if (nav.classList.contains('rds-carousel')) {
|
|
1651
|
+
const step = (n) => {
|
|
1652
|
+
const i = tabs.findIndex((t) => t.classList.contains('is-current'));
|
|
1653
|
+
show(tabs[(i + n + tabs.length) % tabs.length], false);
|
|
1654
|
+
};
|
|
1655
|
+
nav.querySelectorAll('[data-rds-step]').forEach((b) => {
|
|
1656
|
+
b.hidden = false;
|
|
1657
|
+
b.addEventListener('click', () => step(Number(b.dataset.rdsStep)));
|
|
1658
|
+
});
|
|
1659
|
+
const seconds = Number(nav.dataset.rdsInterval) || 0;
|
|
1660
|
+
if (seconds && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
1661
|
+
const area = [nav, ...panels];
|
|
1662
|
+
const busy = () => area.some((el) => el.matches(':hover') || el.contains(document.activeElement));
|
|
1663
|
+
setInterval(() => { if (!busy() && !document.hidden) step(1); }, seconds * 1000);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1620
1666
|
});
|
|
1621
1667
|
|
|
1622
1668
|
// Popup list of values: the select (which works without JavaScript) is hidden
|
|
@@ -3433,3 +3479,43 @@ function areaFilter(map, f) {
|
|
|
3433
3479
|
}
|
|
3434
3480
|
check();
|
|
3435
3481
|
})();
|
|
3482
|
+
|
|
3483
|
+
// (sprint 59) Quick edit from the developer toolbar (APEX: Quick Edit): while it is on, pointing at a
|
|
3484
|
+
// region, item or button outlines it and a click opens it in the Page Designer (in the builder's tab);
|
|
3485
|
+
// Esc or the button again turns it off. The toolbar is only there for developers of the application.
|
|
3486
|
+
(() => {
|
|
3487
|
+
const toggle = document.querySelector('[data-quick-edit]');
|
|
3488
|
+
if (!toggle) return;
|
|
3489
|
+
toggle.hidden = false;
|
|
3490
|
+
const base = toggle.dataset.quickEdit;
|
|
3491
|
+
const pick = (el) => el?.closest?.('[data-item], [data-button], .region[id^="R"]');
|
|
3492
|
+
let on = false;
|
|
3493
|
+
let marked = null;
|
|
3494
|
+
const mark = (el) => {
|
|
3495
|
+
marked?.classList.remove('qe-target');
|
|
3496
|
+
marked = el;
|
|
3497
|
+
el?.classList.add('qe-target');
|
|
3498
|
+
};
|
|
3499
|
+
const set = (v) => {
|
|
3500
|
+
on = v;
|
|
3501
|
+
toggle.setAttribute('aria-pressed', String(v));
|
|
3502
|
+
document.body.classList.toggle('quick-edit', v);
|
|
3503
|
+
if (!v) mark(null);
|
|
3504
|
+
};
|
|
3505
|
+
const urlOf = (el) => {
|
|
3506
|
+
if (el.dataset.item) return `${base}/quick?item=${encodeURIComponent(el.dataset.item)}`;
|
|
3507
|
+
if (el.dataset.button) return `${base}/quick?button=${encodeURIComponent(el.dataset.button)}`;
|
|
3508
|
+
return `${base}?c=region-${el.id.slice(1)}`;
|
|
3509
|
+
};
|
|
3510
|
+
toggle.addEventListener('click', () => set(!on));
|
|
3511
|
+
document.addEventListener('pointerover', (e) => { if (on && !e.target.closest('.devbar')) mark(pick(e.target)); });
|
|
3512
|
+
document.addEventListener('click', (e) => {
|
|
3513
|
+
if (!on || e.target.closest('.devbar')) return;
|
|
3514
|
+
const el = pick(e.target);
|
|
3515
|
+
if (!el) return;
|
|
3516
|
+
e.preventDefault();
|
|
3517
|
+
e.stopPropagation();
|
|
3518
|
+
window.open(urlOf(el), 'pgkiln-builder');
|
|
3519
|
+
}, true);
|
|
3520
|
+
document.addEventListener('keydown', (e) => { if (on && e.key === 'Escape') set(false); });
|
|
3521
|
+
})();
|
package/public/builder.css
CHANGED
|
@@ -676,6 +676,11 @@ details.support-script pre.source { margin-top: 0.4rem; white-space: pre-wrap; o
|
|
|
676
676
|
|
|
677
677
|
/* (sprint 34) working copies: differences and conflicts */
|
|
678
678
|
pre.diff { max-height: 32rem; overflow: auto; background: var(--code-bg); padding: 0.6rem; font-size: 0.82rem; white-space: pre; }
|
|
679
|
+
pre.diff .diff-add { color: var(--success, #1a7f37); }
|
|
680
|
+
pre.diff .diff-del { color: var(--danger, #cf222e); }
|
|
681
|
+
/* (sprint 56) SQL Workshop → Schema Comparison */
|
|
682
|
+
.compare-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.35rem; }
|
|
683
|
+
.compare-list summary { cursor: pointer; overflow-wrap: anywhere; }
|
|
679
684
|
tr.row-conflict > td:first-child { border-left: 3px solid var(--warn); }
|
|
680
685
|
.badge.badge-warn { background: var(--warning-soft); color: var(--text); }
|
|
681
686
|
.publish-buttons .btn { white-space: normal; overflow-wrap: anywhere; max-width: 100%; text-align: left; }
|
|
@@ -770,3 +775,9 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
|
|
|
770
775
|
.provider-switch .field { margin: 0; min-width: min(100%, 280px); }
|
|
771
776
|
.mail-check-buttons { margin: 4px 0 14px; }
|
|
772
777
|
.mail-check-result:not(:empty) { margin-top: 8px; }
|
|
778
|
+
/* (sprint 59) Page Designer: the help pane under the properties */
|
|
779
|
+
.pe-help { border-top: 1px solid var(--border); margin-top: 0.75rem; padding: 0.5rem 0.75rem; }
|
|
780
|
+
.pe-help summary { cursor: pointer; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
|
|
781
|
+
.pe-help-body { padding-top: 0.4rem; overflow-wrap: anywhere; }
|
|
782
|
+
.pe-help-body h3 { font-size: 0.95rem; margin: 0 0 0.3rem; }
|
|
783
|
+
.pe-help-body p { margin: 0 0 0.4rem; }
|
package/public/builder.js
CHANGED
|
@@ -809,3 +809,53 @@ document.addEventListener('click', async (e) => {
|
|
|
809
809
|
buttons.forEach((b) => (b.disabled = false));
|
|
810
810
|
}
|
|
811
811
|
});
|
|
812
|
+
|
|
813
|
+
// (sprint 59) The Page Designer's help pane (APEX: the Help tab of the property editor): the property
|
|
814
|
+
// that has the focus, its help, the column it is stored in and the guide section of the component type.
|
|
815
|
+
(() => {
|
|
816
|
+
const pane = document.querySelector('[data-pe-help]');
|
|
817
|
+
if (!pane) return;
|
|
818
|
+
const guide = pane.closest('.pe-help')?.dataset.guide;
|
|
819
|
+
let current = null;
|
|
820
|
+
const show = (field) => {
|
|
821
|
+
if (!field || field === current) return;
|
|
822
|
+
current = field;
|
|
823
|
+
const label = (field.querySelector('label.label, label.check, .label')?.textContent || field.dataset.prop).trim();
|
|
824
|
+
const help = field.querySelector('.help');
|
|
825
|
+
pane.replaceChildren();
|
|
826
|
+
const h = document.createElement('h3');
|
|
827
|
+
h.textContent = label;
|
|
828
|
+
pane.append(h);
|
|
829
|
+
if (help) {
|
|
830
|
+
const p = document.createElement('p');
|
|
831
|
+
// the help is the server's own (escaped) markup: copy its nodes, links included
|
|
832
|
+
p.append(...[...help.childNodes].map((n) => n.cloneNode(true)));
|
|
833
|
+
pane.append(p);
|
|
834
|
+
} else {
|
|
835
|
+
const p = document.createElement('p');
|
|
836
|
+
p.className = 'muted';
|
|
837
|
+
p.textContent = 'This property has no help of its own: see the guide.';
|
|
838
|
+
pane.append(p);
|
|
839
|
+
}
|
|
840
|
+
const meta = document.createElement('p');
|
|
841
|
+
meta.className = 'small muted';
|
|
842
|
+
const code = document.createElement('code');
|
|
843
|
+
code.textContent = field.dataset.prop;
|
|
844
|
+
meta.append('Stored as ', code, ' (in exports and application files).');
|
|
845
|
+
pane.append(meta);
|
|
846
|
+
if (guide) {
|
|
847
|
+
const a = document.createElement('a');
|
|
848
|
+
a.href = guide;
|
|
849
|
+
a.target = '_blank';
|
|
850
|
+
a.rel = 'noopener';
|
|
851
|
+
a.textContent = 'This component in the guide ▸';
|
|
852
|
+
pane.append(a);
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
document.addEventListener('focusin', (e) => show(e.target.closest?.('.pe [data-prop]')));
|
|
856
|
+
document.addEventListener('pointerover', (e) => {
|
|
857
|
+
// pointing at a property shows its help too, unless another one has the focus
|
|
858
|
+
if (document.activeElement?.closest?.('.pe [data-prop]')) return;
|
|
859
|
+
show(e.target.closest?.('.pe [data-prop]'));
|
|
860
|
+
});
|
|
861
|
+
})();
|
package/public/code-editor.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
:root {
|
|
5
5
|
--ce-bg: #fbfcfd;
|
|
6
6
|
--ce-gutter: #f1f3f6;
|
|
7
|
-
--ce-gutter-text: #
|
|
7
|
+
--ce-gutter-text: #5d6876;
|
|
8
8
|
--ce-text: #1b2330;
|
|
9
9
|
--ce-kw: #8b2bb9;
|
|
10
10
|
--ce-type: #0b6e8a;
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
:root:not([data-theme="light"]) {
|
|
27
27
|
--ce-bg: #12161d;
|
|
28
28
|
--ce-gutter: #171c24;
|
|
29
|
-
--ce-gutter-text: #
|
|
29
|
+
--ce-gutter-text: #8f99a8;
|
|
30
30
|
--ce-text: #e3e7ed;
|
|
31
31
|
--ce-kw: #d39bff;
|
|
32
32
|
--ce-type: #5fd0e6;
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
:root[data-theme="dark"] {
|
|
47
47
|
--ce-bg: #12161d;
|
|
48
48
|
--ce-gutter: #171c24;
|
|
49
|
-
--ce-gutter-text: #
|
|
49
|
+
--ce-gutter-text: #8f99a8;
|
|
50
50
|
--ce-text: #e3e7ed;
|
|
51
51
|
--ce-kw: #d39bff;
|
|
52
52
|
--ce-type: #5fd0e6;
|
package/public/sw.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// pgkiln service worker for one application (served as /a/<alias>/sw.js with PGKILN = {base, offlinePages, offlineSubmit, version}).
|
|
2
|
-
// static files (app.css, app.js, icons) and the offline page: cached at install, served from the cache
|
|
2
|
+
// static files (app.css, app.js, icons) and the offline page: cached at install, served from the cache by their
|
|
3
|
+
// exact URL; pages ask for them with ?v=<version> (static-version.ts), so a changed file is fetched anew
|
|
3
4
|
// pages (navigations): network first; offline, the cached copy (only when the app keeps pages) or the offline page
|
|
4
5
|
// signing in or out empties the page cache, so the next person on the device doesn't see them
|
|
5
6
|
// push notifications (migration 074): shown as they arrive, a click opens their page of the app;
|
|
@@ -7,7 +8,7 @@
|
|
|
7
8
|
/* global PGKILN */
|
|
8
9
|
const STATIC = `pgkiln-static-${PGKILN.version}`;
|
|
9
10
|
const PAGES = `pgkiln-pages-${PGKILN.base}`;
|
|
10
|
-
const SHELL = ['
|
|
11
|
+
const SHELL = [...['app.css', 'app.js', 'icons.svg'].map((f) => `/static/${f}?v=${PGKILN.version}`), `${PGKILN.base}/offline`];
|
|
11
12
|
|
|
12
13
|
self.addEventListener('install', (event) => {
|
|
13
14
|
event.waitUntil(caches.open(STATIC).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
|
@@ -39,7 +40,8 @@ self.addEventListener('fetch', (event) => {
|
|
|
39
40
|
if (req.method !== 'GET') return;
|
|
40
41
|
|
|
41
42
|
if (url.pathname.startsWith('/static/')) {
|
|
42
|
-
|
|
43
|
+
// the exact URL: another ?v= (a changed file) is not the cached copy
|
|
44
|
+
event.respondWith(caches.match(req, { cacheName: STATIC }).then((hit) => hit || fetch(req)));
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
45
47
|
|
package/src/app.ts
CHANGED
|
@@ -7,6 +7,7 @@ import fastifyStatic from '@fastify/static';
|
|
|
7
7
|
import Fastify from 'fastify';
|
|
8
8
|
import { apiRoutes } from './builder/api.ts';
|
|
9
9
|
import { globalizationRoutes } from './builder/globalization.ts';
|
|
10
|
+
import { loginPageRoutes } from './builder/login-page.ts';
|
|
10
11
|
import { designerRoutes } from './builder/designer.ts';
|
|
11
12
|
import { lockRoutes } from './builder/locks.ts';
|
|
12
13
|
import { supportingRoutes } from './builder/supporting.ts';
|
|
@@ -64,6 +65,8 @@ import { ownerUrl, runtime } from './db.ts';
|
|
|
64
65
|
import { refreshInstanceSettings } from './instance.ts';
|
|
65
66
|
import { loadSecrets, securityHeaders, trustProxySetting } from './security.ts';
|
|
66
67
|
import { lucideSymbol } from './icons.ts';
|
|
68
|
+
import { runtimeForAlias } from './workspace-db.ts';
|
|
69
|
+
import { runtimeScope } from './db.ts';
|
|
67
70
|
|
|
68
71
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
69
72
|
// a database older than the code: apply the migrations (MIGRATE_ON_START=true)
|
|
@@ -103,6 +106,22 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
103
106
|
);
|
|
104
107
|
});
|
|
105
108
|
}
|
|
109
|
+
// an application of an isolated workspace runs on its workspace's own login (src/workspace-db.ts):
|
|
110
|
+
// the rest of the request runs in that scope, so runtime.* uses that pool
|
|
111
|
+
app.addHook('onRequest', (req, _reply, done) => {
|
|
112
|
+
const m = /^\/a\/([^/?#]{1,100})/.exec(req.url);
|
|
113
|
+
if (!m || pending.length) return done();
|
|
114
|
+
let alias: string;
|
|
115
|
+
try {
|
|
116
|
+
alias = decodeURIComponent(m[1]);
|
|
117
|
+
} catch {
|
|
118
|
+
return done();
|
|
119
|
+
}
|
|
120
|
+
runtimeForAlias(alias).then(
|
|
121
|
+
(db) => runtimeScope.run(db, done),
|
|
122
|
+
(err: Error) => done(err),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
106
125
|
// debug messages: stored after the response, for requests that have a debug log (src/debug.ts)
|
|
107
126
|
app.addHook('onError', async (req, _reply, err) => {
|
|
108
127
|
debugOf(req)?.add(1, 'error', `unhandled: ${err.message}`);
|
|
@@ -186,6 +205,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
186
205
|
await app.register(oauthRoutes);
|
|
187
206
|
await app.register(apiRoutes);
|
|
188
207
|
await app.register(globalizationRoutes);
|
|
208
|
+
await app.register(loginPageRoutes);
|
|
189
209
|
await app.register(codeEditorRoutes);
|
|
190
210
|
await app.register(dataReporterRoutes);
|
|
191
211
|
await app.register(reporterBuilderRoutes);
|
package/src/appfiles.ts
CHANGED
|
@@ -190,6 +190,7 @@ const NAMED: [section: string, dir: string, table: string][] = [
|
|
|
190
190
|
['email_templates', 'shared/email-templates', 'email_template'],
|
|
191
191
|
// (086) application settings
|
|
192
192
|
['app_settings', 'shared/app-settings', 'app_setting'],
|
|
193
|
+
['page_groups', 'shared/page-groups', 'page_group'],
|
|
193
194
|
];
|
|
194
195
|
|
|
195
196
|
/** Components of a page: [array in the document, directory, table, key source]. */
|
package/src/automations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from 'pg';
|
|
2
2
|
import { owner, runtime } from './db.ts';
|
|
3
|
+
import { withAppDb } from './workspace-db.ts';
|
|
3
4
|
|
|
4
5
|
// Automations (Shared Components → Automations): SQL or PL/pgSQL on a cron
|
|
5
6
|
// schedule, run by the pgkiln server as the application's database role.
|
|
@@ -226,7 +227,7 @@ export async function runAutomation(id: number, trigger: 'schedule' | 'manual' =
|
|
|
226
227
|
|
|
227
228
|
/** The actions, as the application's role, through meta.automation_execute() (also used by meta.run_automation). */
|
|
228
229
|
async function execute(def: Definition): Promise<{ rows: number; failed: number; errors: RowError[] }> {
|
|
229
|
-
return runtime.tx(async (c) => {
|
|
230
|
+
return withAppDb(def.app_id, () => runtime.tx(async (c) => {
|
|
230
231
|
await c.query(
|
|
231
232
|
`select set_config('pgkiln.app_user', $1, true), set_config('pgkiln.app_id', $2, true),
|
|
232
233
|
set_config('pgkiln.automation_id', $3, true), set_config('pgkiln.session_id', '', true),
|
|
@@ -235,7 +236,7 @@ async function execute(def: Definition): Promise<{ rows: number; failed: number;
|
|
|
235
236
|
);
|
|
236
237
|
if (def.db_role) await c.query(`set local role ${pg.escapeIdentifier(def.db_role)}`);
|
|
237
238
|
return (await c.query('select meta.automation_execute($1::jsonb) as r', [JSON.stringify(def)])).rows[0].r;
|
|
238
|
-
});
|
|
239
|
+
}));
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
// ------------------------------------------------------------------ scheduler
|
package/src/builder/advisor.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import pg from 'pg';
|
|
3
3
|
import { applyBinds, splitStatements } from '../binds.ts';
|
|
4
|
-
import { owner } from '../db.ts';
|
|
4
|
+
import { owner, type Db } from '../db.ts';
|
|
5
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
5
6
|
import { html, type Raw } from '../html.ts';
|
|
6
7
|
import { icon } from '../icons.ts';
|
|
7
8
|
import { templateProblem } from '../runtime/document.ts';
|
|
@@ -156,7 +157,7 @@ const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
|
|
|
156
157
|
* e.g. an import that hasn't committed yet) everything is read and checked
|
|
157
158
|
* on that client, inside a savepoint that is rolled back.
|
|
158
159
|
*/
|
|
159
|
-
export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
|
|
160
|
+
export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db } = {}): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
|
|
160
161
|
const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
|
|
161
162
|
const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
|
|
162
163
|
const app = await one('select * from meta.app where id = $1', [appId]);
|
|
@@ -173,10 +174,29 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
|
|
|
173
174
|
).rows.map((row) => ({ kind, row }));
|
|
174
175
|
const all = (await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))).flat();
|
|
175
176
|
|
|
177
|
+
// metadata checks, as the owner (before the role switch below: the application's role can't read meta)
|
|
178
|
+
// (087) the application's login page: a page that needs no sign-in, with a login region
|
|
179
|
+
if (app.login_page) {
|
|
180
|
+
const page = await one('select id, requires_auth from meta.page where app_id = $1 and page_no = $2', [appId, app.login_page]);
|
|
181
|
+
const field = 'Application settings → Login page';
|
|
182
|
+
if (!page) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} doesn't exist: the built-in sign-in page is shown instead.` });
|
|
183
|
+
else if (page.requires_auth) findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} requires authentication: the built-in sign-in page is shown instead.` });
|
|
184
|
+
else if (!all.some((x) => x.kind === 'region' && x.row.type === 'login' && Number(x.row.page_no) === Number(app.login_page)))
|
|
185
|
+
findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
|
|
186
|
+
}
|
|
187
|
+
// (089) a page's group exists
|
|
188
|
+
for (const p of (await query(`select p.page_no, p.page_group from meta.page p where p.app_id = $1 and p.page_group is not null
|
|
189
|
+
and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)
|
|
190
|
+
findings.push({ severity: 'warning', entry: null, field: `Page ${p.page_no} → Page group`, message: `Page group ${p.page_group} doesn't exist (Shared Components → Page groups).` });
|
|
176
191
|
// ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
|
|
177
|
-
|
|
192
|
+
// (090) an isolated workspace's application: planned on the workspace's developer login, in a
|
|
193
|
+
// transaction of its own (planning can run the application's functions; RESET ROLE there only
|
|
194
|
+
// reaches that login, never the owner); `opts.db` names the login for an import into the workspace
|
|
195
|
+
const own = opts.db && opts.db !== owner ? opts.db : await devDbForApp(appId);
|
|
196
|
+
const inTx = !!tx && own === owner;
|
|
197
|
+
const c = (inTx ? tx : await (own === owner ? owner : own).pool.connect()) as pg.PoolClient;
|
|
178
198
|
try {
|
|
179
|
-
await c.query(
|
|
199
|
+
await c.query(inTx ? 'savepoint advisor_all' : 'begin');
|
|
180
200
|
await c.query(`set local statement_timeout = '5s'`);
|
|
181
201
|
await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
|
|
182
202
|
if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
|
|
@@ -249,7 +269,7 @@ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findi
|
|
|
249
269
|
}
|
|
250
270
|
}
|
|
251
271
|
} finally {
|
|
252
|
-
if (
|
|
272
|
+
if (inTx) {
|
|
253
273
|
await c.query('rollback to savepoint advisor_all');
|
|
254
274
|
await c.query('release savepoint advisor_all');
|
|
255
275
|
} else {
|