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.
Files changed (103) hide show
  1. package/SECURITY.md +11 -9
  2. package/db/migrations/087_login_page.sql +175 -0
  3. package/db/migrations/088_region_templates.sql +18 -0
  4. package/db/migrations/089_page_groups.sql +330 -0
  5. package/db/migrations/090_workspace_isolation.sql +512 -0
  6. package/docs/apex-feature-parity.md +23 -21
  7. package/docs/guide/03-builder.md +85 -8
  8. package/docs/guide/04-pages-and-regions.md +42 -3
  9. package/docs/guide/08-security.md +46 -5
  10. package/docs/guide/11-from-apex.md +4 -1
  11. package/docs/guide/12-development.md +13 -3
  12. package/docs/guide/17-mobile.md +3 -1
  13. package/docs/guide/18-cli.md +2 -0
  14. package/docs/guide/20-ai-agents.md +2 -0
  15. package/examples/hr/hr_40_sample_data.sql +1 -1
  16. package/examples/hr/hr_54_region_templates.sql +67 -0
  17. package/examples/hr/hr_55_page_groups.sql +24 -0
  18. package/package.json +1 -1
  19. package/public/app.css +60 -3
  20. package/public/app.js +89 -3
  21. package/public/builder.css +11 -0
  22. package/public/builder.js +50 -0
  23. package/public/code-editor.css +3 -3
  24. package/public/sw.js +5 -3
  25. package/src/app.ts +20 -0
  26. package/src/appfiles.ts +1 -0
  27. package/src/automations.ts +3 -2
  28. package/src/builder/advisor.ts +25 -5
  29. package/src/builder/ai-builder.ts +27 -18
  30. package/src/builder/blueprints.ts +7 -7
  31. package/src/builder/code-editor.ts +3 -2
  32. package/src/builder/components.ts +15 -2
  33. package/src/builder/dataload.ts +24 -14
  34. package/src/builder/ddl-tools.ts +117 -0
  35. package/src/builder/designer.ts +29 -3
  36. package/src/builder/documents.ts +2 -1
  37. package/src/builder/forms.ts +6 -4
  38. package/src/builder/home.ts +6 -2
  39. package/src/builder/login-page.ts +82 -0
  40. package/src/builder/newapp.ts +21 -3
  41. package/src/builder/quicksql.ts +3 -2
  42. package/src/builder/region-help.ts +4 -2
  43. package/src/builder/region-settings.ts +19 -3
  44. package/src/builder/report-settings.ts +4 -2
  45. package/src/builder/routes.ts +18 -6
  46. package/src/builder/sampledata.ts +22 -18
  47. package/src/builder/scripts.ts +27 -16
  48. package/src/builder/shared.ts +1 -1
  49. package/src/builder/sql.ts +18 -10
  50. package/src/builder/ui.ts +2 -2
  51. package/src/builder/unload.ts +3 -2
  52. package/src/builder/workspaces.ts +175 -15
  53. package/src/cli/main.ts +45 -0
  54. package/src/cli/mcp.ts +42 -0
  55. package/src/cli/replace.ts +4 -0
  56. package/src/db.ts +36 -6
  57. package/src/ddl.ts +510 -0
  58. package/src/debug.ts +3 -2
  59. package/src/i18n/ar.ts +17 -0
  60. package/src/i18n/cs.ts +17 -0
  61. package/src/i18n/da.ts +17 -0
  62. package/src/i18n/de.ts +17 -0
  63. package/src/i18n/el.ts +17 -0
  64. package/src/i18n/es.ts +17 -0
  65. package/src/i18n/fi.ts +17 -0
  66. package/src/i18n/fr.ts +17 -0
  67. package/src/i18n/he.ts +17 -0
  68. package/src/i18n/it.ts +17 -0
  69. package/src/i18n/ja.ts +17 -0
  70. package/src/i18n/ko.ts +17 -0
  71. package/src/i18n/nb.ts +17 -0
  72. package/src/i18n/pl.ts +17 -0
  73. package/src/i18n/pt.ts +17 -0
  74. package/src/i18n/ru.ts +17 -0
  75. package/src/i18n/sv.ts +17 -0
  76. package/src/i18n/tr.ts +17 -0
  77. package/src/i18n/uk.ts +17 -0
  78. package/src/i18n/zh.ts +17 -0
  79. package/src/i18n.ts +34 -0
  80. package/src/icons.ts +2 -1
  81. package/src/layout.ts +3 -2
  82. package/src/metadata.ts +10 -4
  83. package/src/migrate.ts +3 -0
  84. package/src/process-jobs.ts +7 -2
  85. package/src/restsync.ts +3 -2
  86. package/src/runtime/devbar.ts +3 -1
  87. package/src/runtime/display-selector.ts +12 -4
  88. package/src/runtime/grid.ts +170 -26
  89. package/src/runtime/lists.ts +11 -1
  90. package/src/runtime/password-reset.ts +171 -0
  91. package/src/runtime/pwa.ts +7 -10
  92. package/src/runtime/regions.ts +33 -0
  93. package/src/runtime/render.ts +15 -0
  94. package/src/runtime/report-views.ts +4 -2
  95. package/src/runtime/report.ts +36 -22
  96. package/src/runtime/routes.ts +39 -33
  97. package/src/runtime/signin.ts +80 -0
  98. package/src/sampledata.ts +3 -3
  99. package/src/session.ts +5 -2
  100. package/src/static-version.ts +34 -0
  101. package/src/websources.ts +3 -1
  102. package/src/workflow.ts +65 -9
  103. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
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
- if (details) details.open = da.action === 'open_region';
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
- const row = e.target.closest?.('[data-grid] table.grid-table > tbody > tr');
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
+ })();
@@ -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
+ })();
@@ -4,7 +4,7 @@
4
4
  :root {
5
5
  --ce-bg: #fbfcfd;
6
6
  --ce-gutter: #f1f3f6;
7
- --ce-gutter-text: #8a94a3;
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: #6c7686;
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: #6c7686;
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 = ['/static/app.css', '/static/app.js', '/static/icons.svg', `${PGKILN.base}/offline`];
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
- event.respondWith(caches.match(req, { ignoreSearch: true }).then((hit) => hit || fetch(req)));
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]. */
@@ -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
@@ -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
- const c = (tx ?? (await owner.pool.connect())) as pg.PoolClient;
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(tx ? 'savepoint advisor_all' : 'begin');
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 (tx) {
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 {