pgkiln 0.38.0 → 0.40.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 (56) hide show
  1. package/SECURITY.md +3 -0
  2. package/db/migrations/093_workspace_preferences.sql +34 -0
  3. package/db/migrations/094_task_vacation_expiry.sql +182 -0
  4. package/docs/apex-feature-parity.md +8 -8
  5. package/docs/guide/03-builder.md +62 -2
  6. package/docs/guide/04-pages-and-regions.md +27 -5
  7. package/docs/guide/06-processing.md +27 -1
  8. package/docs/guide/12-development.md +15 -6
  9. package/docs/guide/13-rest-api.md +3 -1
  10. package/examples/hr/hr_56_more_charts.sql +57 -0
  11. package/examples/hr/hr_57_task_policies.sql +18 -0
  12. package/examples/hr/hr_58_facet_charts.sql +17 -0
  13. package/package.json +1 -1
  14. package/public/app.css +30 -0
  15. package/public/sw.js +2 -1
  16. package/src/app.ts +2 -0
  17. package/src/automations.ts +6 -0
  18. package/src/blueprint-from-app.ts +240 -0
  19. package/src/builder/blueprints.ts +31 -1
  20. package/src/builder/components.ts +7 -0
  21. package/src/builder/home.ts +3 -2
  22. package/src/builder/region-settings.ts +13 -5
  23. package/src/builder/sql-utilities.ts +278 -0
  24. package/src/builder/sql.ts +3 -1
  25. package/src/builder/ui.ts +16 -3
  26. package/src/builder/workspace-backups.ts +78 -0
  27. package/src/builder/workspaces.ts +83 -1
  28. package/src/cli/replace.ts +2 -0
  29. package/src/i18n/ar.ts +25 -0
  30. package/src/i18n/cs.ts +25 -0
  31. package/src/i18n/da.ts +25 -0
  32. package/src/i18n/de.ts +25 -0
  33. package/src/i18n/el.ts +25 -0
  34. package/src/i18n/es.ts +25 -0
  35. package/src/i18n/fi.ts +25 -0
  36. package/src/i18n/fr.ts +25 -0
  37. package/src/i18n/he.ts +25 -0
  38. package/src/i18n/it.ts +25 -0
  39. package/src/i18n/ja.ts +25 -0
  40. package/src/i18n/ko.ts +25 -0
  41. package/src/i18n/nb.ts +25 -0
  42. package/src/i18n/pl.ts +25 -0
  43. package/src/i18n/pt.ts +25 -0
  44. package/src/i18n/ru.ts +25 -0
  45. package/src/i18n/sv.ts +25 -0
  46. package/src/i18n/tr.ts +25 -0
  47. package/src/i18n/uk.ts +25 -0
  48. package/src/i18n/zh.ts +25 -0
  49. package/src/i18n.ts +50 -0
  50. package/src/runtime/charts.ts +154 -5
  51. package/src/runtime/facet-state.ts +3 -0
  52. package/src/runtime/facets.ts +25 -0
  53. package/src/runtime/rest.ts +8 -2
  54. package/src/runtime/tasks.ts +62 -4
  55. package/src/session.ts +5 -0
  56. package/src/workflow.ts +6 -4
@@ -25,6 +25,7 @@ src/
25
25
  appfiles.ts application export as one file per component (dir layout, static ids) and back
26
26
  blueprint.ts blueprints (migration 063): checkBlueprint (names, types, references, pages, sample rows), tableOrder,
27
27
  blueprintSql, buildBlueprint (tables, grants, rows, pages via meta.generate_page, menu), the AI draft schema
28
+ blueprint-from-app.ts (sprint 66) blueprintFromApp: an application's tables (catalog), pages and menu as a blueprint, notes; sample rows
28
29
  subscriptions.ts application types and subscriptions (migration 056): offers, subscribe, refresh, publish, in sync
29
30
  workingcopy.ts working copies (migration 055): create, three-way compare per component (base, main, copy),
30
31
  merge into the main application or refresh the copy, both through cli/replace.ts
@@ -64,7 +65,7 @@ src/
64
65
  sqlscript.ts SQL scripts: splitScript() (statements, line numbers, psql commands), runScript() (stop/continue, transaction, savepoints)
65
66
  quicksql.ts Quick SQL: shorthand parser and PostgreSQL DDL generator (unit tested)
66
67
  xlsx.ts Excel writer for report downloads and Unload Data (typed cells, streamed through fflate's Zip)
67
- automations.ts cron parser, next run in a time zone, scheduler, running automations (the actions run
68
+ automations.ts cron parser, next run in a time zone, scheduler (also meta.task_expire_tick, 094), running automations (the actions run
68
69
  in PL/pgSQL: meta.automation_execute, shared with meta.run_automation; migration 044)
69
70
  html.ts auto-escaping html`` templates
70
71
  richtext.ts rich text and Markdown items: allow-list HTML sanitiser, Markdown renderer
@@ -115,11 +116,11 @@ src/
115
116
  style (user choice, default), themeCss() (only hex values and constants reach the CSS)
116
117
  template-options.ts template options: the fixed CSS class list per region and button, templateClasses() (unknown values ignored)
117
118
  regions.ts region shell + chart (drill-down links, gauge settings)/cards/dynamic dispatch with row limits, lazy placeholder and cache, buttons (menu buttons, badges)
118
- report.ts, report-views.ts (group by, pivot, chart), compute.ts (computed column expressions), grid.ts (aggregates, row actions, Actions menu, single row view, layoutFromForm), grid-layout.ts (column layouts: clean, arrange, per user), master-detail.ts (signed master row selection, details), facets.ts, items.ts
119
+ report.ts, report-views.ts (group by, pivot, chart), compute.ts (computed column expressions), grid.ts (aggregates, row actions, Actions menu, single row view, layoutFromForm), grid-layout.ts (column layouts: clean, arrange, per user), master-detail.ts (signed master row selection, details), facets.ts (facetChart: a facet's counts as a chart, sprint 65), items.ts
119
120
  (report.ts: paging with row ranges and max_rows, keyset paging (keysetPlan, seekCondition, signed r<id>_k), pagerNav, streamed CSV/Excel downloads with a cursor;
120
121
  items.ts: lovOptions, searchLov/lovLookup for popup LOVs, served by POST /a/:alias/:page/lov/:item/search in routes.ts)
121
122
  region-cache.ts region caching (keys per scope, CSRF placeholder, invalidation on submit) and lazy regions (GET …/region/:id is in routes.ts)
122
- charts.ts server-rendered charts (SVG and CSS classes): bar … radar, gauges, Gantt (time axis, dependencies), pyramid, polar, drill-down marks, data table
123
+ charts.ts server-rendered charts (SVG and CSS classes): bar … radar, gauges, Gantt (time axis, dependencies), pyramid, polar, range, box plot (quantile, boxes), stock, drill-down marks, data table
123
124
  calendar.ts calendar region: month/week/day/list views, create links, drag and drop (moveEvent, moveCalendarEvent;
124
125
  the route POST …/calendar/:id/move is in routes.ts)
125
126
  links.ts page links with checksums; fillItems() fills #column# in link items
@@ -143,7 +144,7 @@ src/
143
144
  template-components.ts template components: template language (allow-list, directives, escaping), plug-in files, report column templates
144
145
  builtin-components.ts built-in template components (ut_avatar, ut_badge, ut_comments, ut_media_list, ut_metric_card, ut_timeline)
145
146
  template-region.ts template_component region
146
- tasks.ts task list region and task actions (approvals)
147
+ tasks.ts task list region and task actions (approvals); (094) the vacation rule panel and route
147
148
  data-reporter.ts Data Reporter region (migration 057): sources from the region's config, checkDef (offered columns, whitelists),
148
149
  reportQuery/chartQuery, the list and editor (GET form), save/delete routes (meta.save_data_report)
149
150
  workflows.ts workflow console region and its actions
@@ -187,6 +188,8 @@ src/
187
188
  forms.ts generic component property form (lookups, render, save)
188
189
  backups.ts Application → Backups (migration 082): list, back up now, download, restore (replaceApp), keep N;
189
190
  backupTick() for the scheduler's daily backups of changed applications
191
+ workspace-backups.ts Workspace utilities → Manage backups (sprint 62): the backups of every application of the current
192
+ workspace, back them all up (only the changed ones by default)
190
193
  availability.ts Application → Availability (migration 083): status, message, users, build status (Run only is
191
194
  enforced by locks.ts blockingLock); availabilityBadge() in the application header
192
195
  feedback.ts Application → Feedback (migration 084): the setting, the list with filters, status and answers
@@ -213,6 +216,8 @@ src/
213
216
  search.ts app search, "where used" (appEntries, search, whereUsed, usedInPanel)
214
217
  advisor.ts Advisor: EXPLAIN every SQL fragment, reference checks, plpgsql_check
215
218
  ddl-tools.ts SQL Workshop → DDL and Compare (src/ddl.ts)
219
+ sql-utilities.ts SQL Workshop → Utilities (sprint 62): the tools' tiles, OBJECT_REPORTS (fixed catalogue SQL over the
220
+ connection's usable schemas), About Database, Database Monitor (administrators); read only, with a timeout
216
221
  login-page.ts Application settings → Login page and Forgot password; Create a login page (page 9999)
217
222
  top-sql.ts Top SQL per app role from pg_stat_statements
218
223
  diagnostics.ts Activity → Debug messages (level, list, one request's entries, purge); Workspace utilities →
@@ -240,8 +245,11 @@ src/
240
245
  workspaces.ts workspaces (064): loadWorkspaces/appAllowed (checked in ui.ts developer() for every /apps/:id and /pages/:pid
241
246
  request), the current workspace (session state __WS), placeApp, the switcher, Workspace utilities → Workspaces;
242
247
  (090) isolation and the workspace's database roles, workshopDb (the SQL Workshop's connection)
248
+ (093) workspace preferences (sql_workshop: checked in ui.ts developer() for /builder/sql…;
249
+ rest_services: checked in runtime/rest.ts) and extension links (the rail's Extensions menu in shell())
243
250
  locks.ts page and application locks (blockingLock, checked in ui.ts developer() for every builder POST; appOfPath), developer comments, administrators
244
- blueprints.ts Create → From a blueprint: list, JSON editor, AI draft, review (signed with the session), create in one transaction
251
+ blueprints.ts Create → From a blueprint: list, JSON editor, AI draft, review (signed with the session), create in one transaction;
252
+ (sprint 66) From an application
245
253
  ai-builder.ts App Builder AI (migration 062): the builder's AI service (meta.builder_ai), SQL Workshop → AI (SQL from a
246
254
  question, shown not run; explain), describe tables (meta.ai_table_note, COMMENT ON, AI drafts),
247
255
  create pages with AI (proposals checked by checkProposals, created with meta.generate_page)
@@ -281,7 +289,7 @@ test/
281
289
  page-logic.test.ts download, chain (background jobs) and workflow processes, function/app branches, dialog_closed (HR page 28)
282
290
  code-editor.test.ts code editor: completions scoped to the app's role, the check, marked fields
283
291
  builder-home.test.ts App Builder home: search, sort, views, Recent, Create/Import pages, dashboard, utilities
284
- charts.test.ts chart markup per kind (geometry as classes), gauges, Gantt time axis and dependencies, pyramid, polar, drill-down links
292
+ charts.test.ts chart markup per kind (geometry as classes), gauges, Gantt time axis and dependencies, pyramid, polar, range, box plot, stock, drill-down links
285
293
  calendar.test.ts calendar views, create links, moving events (pure and over HTTP)
286
294
  rest-sources.test.ts REST data sources, web credentials, SSRF checks, invoke_api (mock service + HR page 23)
287
295
  workflow-invoke.test.ts workflow invoke_api steps: the call between transactions, faults, retry, lease, Advisor, export (mock service)
@@ -305,6 +313,7 @@ test/
305
313
  ai-assistant.test.ts AI assistant region (context, tools as the app role, writes, REST, histories, limits, sessions), OpenAI,
306
314
  natural-language report filters, builder settings; against ai-script-mock.ts
307
315
  blueprints.test.ts blueprints: checks and SQL, review then create (rows, pages, grants), one transaction, AI draft, save
316
+ blueprint-from-app.test.ts a blueprint of HR: checks clean, pages and menu, notes, a working copy with sample rows, workspaces
308
317
  ai-builder.test.ts App Builder AI: the builder's service, SQL from a question, explain, describe tables, pages with AI
309
318
  ai-script-mock.ts a scripted mock of Claude (SSE, tool_use with a thinking block) and OpenAI (tool calls), reply by reply
310
319
  ai-mock.ts a local mock of the Claude Messages API (SSE stream) and OpenAI Chat Completions
@@ -16,7 +16,9 @@ same row level security, and both publish an OpenAPI description.
16
16
  ## REST modules in the builder
17
17
 
18
18
  **Shared Components → REST modules** (APEX: RESTful Services). A module is a set of handlers under
19
- `/a/<alias>/rest/<name>/`:
19
+ `/a/<alias>/rest/<name>/`. A workspace's administrators can turn the REST modules of all its applications off with
20
+ the workspace preference *Enable RESTful services* ([chapter 3](03-builder.md#workspace-preferences)): they then
21
+ answer 404.
20
22
 
21
23
  ```json
22
24
  [{"method": "GET", "path": "orders", "type": "collection", "source": "select id, customer, total from sales.orders order by id"},
@@ -0,0 +1,57 @@
1
+ -- =====================================================================
2
+ -- HR example, part 56: range, box plot and stock charts
3
+ -- (docs/guide/04-pages-and-regions.md, chart regions)
4
+ --
5
+ -- On page 32 "Project plan":
6
+ -- - a range chart of the salary range per department (lowest and
7
+ -- highest salary), drilling down to the department's employees;
8
+ -- - a box plot of the salaries per job, computed from one row per
9
+ -- employee;
10
+ -- - a stock chart of a made-up share price over the last 30 working
11
+ -- days (open, high, low and close), generated in the query.
12
+ -- =====================================================================
13
+
14
+ insert into meta.region (page_id, seq, title, type, columns, template, source, config)
15
+ select p.id, r.seq, r.title, r.type, r.cols, r.tpl, r.source, r.config::jsonb
16
+ from meta.page p join meta.app a on a.id = p.app_id, (values
17
+ (60, 'Salary range per department', 'chart', 4, 'standard',
18
+ $q$select d.dname as "Department", min(e.sal) as "Lowest", max(e.sal) as "Highest", d.deptno
19
+ from hr.dept d join hr.emp e on e.deptno = d.deptno and e.active
20
+ group by d.deptno, d.dname
21
+ order by d.dname$q$,
22
+ '{"kind": "range", "link": {"page": 2, "items": {"P2_DEPTNO": "#deptno#"}}}'),
23
+ (70, 'Salaries per job', 'chart', 4, 'standard',
24
+ $q$select e.job as "Job", e.sal as "Salary"
25
+ from hr.emp e
26
+ where e.active
27
+ order by e.job$q$,
28
+ '{"kind": "boxplot"}'),
29
+ (80, 'Share price (demo data)', 'chart', 4, 'standard',
30
+ $q$with days as (
31
+ select d::date as day, row_number() over (order by d) as n
32
+ from generate_series(current_date - 41, current_date - 1, interval '1 day') d
33
+ where extract(isodow from d) < 6
34
+ order by d desc limit 30
35
+ ), price as (
36
+ select day, n, 100 + 8 * sin(n / 4.0) + n / 3.0 as mid from days
37
+ )
38
+ select to_char(day, 'DD Mon') as "Day",
39
+ round((mid - 1.5 * cos(n))::numeric, 2) as "Open",
40
+ round((mid + 2 + abs(sin(n * 1.7)))::numeric, 2) as "High",
41
+ round((mid - 2 - abs(cos(n * 1.3)))::numeric, 2) as "Low",
42
+ round((mid + 1.5 * cos(n))::numeric, 2) as "Close"
43
+ from price
44
+ order by day$q$,
45
+ '{"kind": "stock"}')
46
+ ) r(seq, title, type, cols, tpl, source, config)
47
+ where a.alias = 'hr' and p.page_no = 32;
48
+
49
+ insert into meta.translation (app_id, language, source, target)
50
+ select a.id, 'nl', t.source, t.target
51
+ from meta.app a, (values
52
+ ('Salary range per department', 'Salarisbereik per afdeling'),
53
+ ('Salaries per job', 'Salarissen per functie'),
54
+ ('Share price (demo data)', 'Aandelenkoers (voorbeeldgegevens)')
55
+ ) t(source, target)
56
+ where a.alias = 'hr'
57
+ on conflict do nothing;
@@ -0,0 +1,18 @@
1
+ -- =====================================================================
2
+ -- HR example, part 57: what happens to overdue tasks, and vacation rules
3
+ -- (docs/guide/06-processing.md, approvals)
4
+ --
5
+ -- - a leave request nobody decided on within its 2 days escalates once to
6
+ -- the administrators (role admin), with priority 1; overdue again, it
7
+ -- expires;
8
+ -- - access requests of the onboarding workflow are renewed twice, then
9
+ -- expire (the workflow then ends: its step has no "expired" branch).
10
+ -- Managers set a vacation rule on the task list (page 14): while they are
11
+ -- away, the user they name decides on their leave requests.
12
+ -- =====================================================================
13
+
14
+ update meta.task_definition d
15
+ set expiration = x.expiration, max_renewals = x.max_renewals, escalate_role = x.escalate_role
16
+ from meta.app a, (values ('LEAVE_APPROVAL', 'escalate', null::int, 'admin'),
17
+ ('ONBOARD_ACCESS', 'renew', 2, null)) x(name, expiration, max_renewals, escalate_role)
18
+ where a.alias = 'hr' and d.app_id = a.id and d.name = x.name;
@@ -0,0 +1,17 @@
1
+ -- =====================================================================
2
+ -- HR example, part 58: facet charts
3
+ -- (docs/guide/04-pages-and-regions.md, faceted search)
4
+ --
5
+ -- The faceted search on page 21 shows a bar chart of the jobs and a pie
6
+ -- chart of the salary ranges under their facets; a bar or a slice filters
7
+ -- the report on its value.
8
+ -- =====================================================================
9
+
10
+ update meta.region r
11
+ set config = jsonb_set(r.config, '{facets}', (
12
+ select jsonb_agg(case f->>'column' when 'job' then f || '{"chart": "bar"}'
13
+ when 'salary' then f || '{"chart": "pie"}'
14
+ else f end order by n)
15
+ from jsonb_array_elements(r.config->'facets') with ordinality x(f, n)))
16
+ from meta.page p join meta.app a on a.id = p.app_id
17
+ where p.id = r.page_id and a.alias = 'hr' and p.page_no = 21 and r.type = 'facets';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.38.0",
3
+ "version": "0.40.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
@@ -465,6 +465,10 @@ table.report { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
465
465
  .chart-x { display: flex; margin-left: 2.6rem; font-size: 0.75rem; color: var(--muted); }
466
466
  .chart-x span { flex: 1 1 0; min-width: 0; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.3rem 1px 0; }
467
467
  .chart-x span.skip { visibility: hidden; }
468
+ .chart-x.sparse span:not(.skip) { overflow: visible; display: flex; justify-content: center; }
469
+ /* a narrow chart (a region of a few columns, a phone): every other label of a sparse axis */
470
+ .chart:has(> .chart-plot) { container-type: inline-size; }
471
+ @container (max-width: 480px) { .chart-x.sparse span.odd { visibility: hidden; } }
468
472
  .chart-line .chart-x, .chart-area .chart-x { margin-right: 0; }
469
473
  .chart-cols { position: absolute; inset: 0; display: flex; }
470
474
  .col-group { position: relative; flex: 1 1 0; display: flex; justify-content: center; gap: 2px; outline-offset: -2px; }
@@ -473,6 +477,22 @@ table.report { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
473
477
  .col { position: absolute; left: 0; right: 0; background: var(--c); border-radius: 4px 4px 0 0; }
474
478
  .col-value { position: absolute; left: 0; right: 0; text-align: center; font-size: 0.72rem; color: var(--muted); transform: translateY(-1.3em); font-variant-numeric: tabular-nums; pointer-events: none; }
475
479
  .col.neg { border-radius: 0 0 4px 4px; }
480
+ /* range: a floating column from low to high */
481
+ .col.range-col { border-radius: 4px; }
482
+ /* box plot: whisker from minimum to maximum with caps, the box between the quartiles, the median across it */
483
+ .box-slot { flex-basis: 32px; }
484
+ .box-whisker { position: absolute; left: 50%; width: 0; border-left: 1.5px solid var(--muted); }
485
+ .box-cap { position: absolute; left: 25%; right: 25%; border-top: 1.5px solid var(--muted); }
486
+ .box-iqr { position: absolute; left: 0; right: 0; box-sizing: border-box; background: color-mix(in srgb, var(--c) 35%, var(--surface)); border: 1.5px solid var(--c); border-radius: 3px; }
487
+ .box-median { position: absolute; left: 0; right: 0; border-top: 2.5px solid var(--text); }
488
+ /* stock: a candle per row, the wick from low to high, the body from open to close */
489
+ .stock-slot { flex-basis: 14px; }
490
+ .stock-wick { position: absolute; left: 50%; width: 0; border-left: 1.5px solid var(--muted); }
491
+ .stock-body { position: absolute; left: 0; right: 0; border-radius: 1px; }
492
+ .stock-body.up { background: var(--success); }
493
+ .stock-body.down { background: var(--danger); }
494
+ .stock-thin .col-group { gap: 0; }
495
+ .stock-thin .stock-slot { flex-basis: 6px; }
476
496
  .line-plot { overflow: visible; }
477
497
  .chart-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
478
498
  .chart-lines .line { fill: none; stroke: var(--c); stroke-width: 2px; stroke-linejoin: round; stroke-linecap: round; }
@@ -972,6 +992,11 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
972
992
  .facet legend { font-weight: 650; font-size: 0.85rem; margin-bottom: 0.3rem; padding: 0; display: flex; gap: 0.5rem; }
973
993
  .facet-clear { font-weight: 400; font-size: 0.8rem; }
974
994
  .facet-value { font-weight: 400; font-size: 0.9rem; }
995
+ /* (sprint 65) a facet's chart of its counts, collapsed under the legend */
996
+ .facet-chart { margin: 0.1rem 0 0.4rem; }
997
+ .facet-chart > summary { cursor: pointer; font-size: 0.8rem; color: var(--muted); }
998
+ .facet-chart .chart { margin-top: 0.4rem; font-size: 0.85rem; }
999
+ .facet-chart .donut-figure { max-width: 9rem; margin: 0 auto; }
975
1000
  .facet-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
976
1001
  .facet-count { font-size: 0.75rem; color: var(--muted); background: var(--surface-3); border-radius: 99px; padding: 0 0.5rem; font-variant-numeric: tabular-nums; }
977
1002
  .js .facet-actions .btn-hot { display: none; }
@@ -1190,6 +1215,11 @@ dl.details dd { margin: 0; overflow-wrap: anywhere; }
1190
1215
  .task-subject { overflow-wrap: anywhere; }
1191
1216
  .task-meta { font-size: 0.85rem; margin-top: 0.15rem; }
1192
1217
  .task-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-top: 0.6rem; }
1218
+ /* (094) the vacation rule: label above each field, in a row that wraps */
1219
+ .task-vacation { margin-bottom: 1rem; }
1220
+ .task-vacation > summary { cursor: pointer; }
1221
+ .vacation-fields { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: flex-end; }
1222
+ .vacation-fields label { display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 10rem; max-width: 16rem; }
1193
1223
  .task-actions input[name="comment"] { flex: 1 1 14rem; min-width: 0; }
1194
1224
  .task-delegate { display: inline-flex; gap: 0.3rem; }
1195
1225
  .task-delegate input { width: 9rem; }
package/public/sw.js CHANGED
@@ -52,7 +52,8 @@ self.addEventListener('fetch', (event) => {
52
52
  // keep successful pages of this app (not the sign-in page, not downloads)
53
53
  if (PGKILN.offlinePages && res.ok && !res.redirected && (res.headers.get('content-type') || '').startsWith('text/html') && !signInOrOut(url)) {
54
54
  const copy = res.clone();
55
- caches.open(PAGES).then((c) => c.put(req, copy));
55
+ // waitUntil: the worker isn't stopped before the copy is kept (a page used offline right after)
56
+ event.waitUntil(caches.open(PAGES).then((c) => c.put(req, copy)).catch(() => {}));
56
57
  }
57
58
  return res;
58
59
  })
package/src/app.ts CHANGED
@@ -69,6 +69,7 @@ import { runtimeForAlias } from './workspace-db.ts';
69
69
  import { galleryRoutes } from './builder/gallery.ts';
70
70
  import { appGroupRoutes } from './builder/app-groups.ts';
71
71
  import { crossAppRoutes } from './builder/cross-app.ts';
72
+ import { workspaceBackupRoutes } from './builder/workspace-backups.ts';
72
73
  import { runtimeScope } from './db.ts';
73
74
 
74
75
  export async function buildApp(opts: { logger?: boolean } = {}) {
@@ -177,6 +178,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
177
178
  await app.register(galleryRoutes);
178
179
  await app.register(appGroupRoutes);
179
180
  await app.register(crossAppRoutes);
181
+ await app.register(workspaceBackupRoutes);
180
182
  await app.register(sharedRoutes);
181
183
  await app.register(designerRoutes);
182
184
  await app.register(lockRoutes);
@@ -306,6 +306,12 @@ export function startScheduler() {
306
306
  } catch (e) {
307
307
  console.error('push notifications:', (e as Error).message);
308
308
  }
309
+ try {
310
+ // (094) overdue tasks: expire, renew or escalate them by their definition's expiration policy
311
+ await owner.query('select meta.task_expire_tick()');
312
+ } catch (e) {
313
+ console.error('task expiration:', (e as Error).message);
314
+ }
309
315
  try {
310
316
  // (082) a daily backup of every application that changed (src/builder/backups.ts)
311
317
  await (await import('./builder/backups.ts')).backupTick();
@@ -0,0 +1,240 @@
1
+ import { owner, type Db } from './db.ts';
2
+ import { LIMITS } from './blueprint.ts';
3
+
4
+ // A blueprint from an existing application (sprint 66; APEX 26.1: create a
5
+ // blueprint from an application). The application's tables (those its
6
+ // regions read: a form's table, the first schema.table after FROM in a
7
+ // query) become the blueprint's tables, its pages the blueprint's pages and
8
+ // its menu the navigation. The result is a starting point that the developer
9
+ // reviews in the blueprint editor: checkBlueprint() checks it like any other,
10
+ // and `notes` say what did not carry over.
11
+ //
12
+ // Only tables the application's database role may read are used (the owner's
13
+ // catalog is not a way to see other schemas). Sample rows are read only when
14
+ // asked for, at most 20 per table, read only, on the connection the caller
15
+ // gives (the builder: devDbForApp, an isolated workspace's developer login).
16
+
17
+ const NAME = /^[a-z][a-z0-9_]{0,62}$/;
18
+ const SAMPLE_ROWS = 20;
19
+
20
+ interface Col {
21
+ name: string;
22
+ type: string;
23
+ required: boolean;
24
+ unique: boolean;
25
+ values: string[];
26
+ references: string | null;
27
+ }
28
+ interface Table {
29
+ key: string; // schema.table
30
+ name: string;
31
+ label: string | null;
32
+ hasId: boolean;
33
+ columns: Col[];
34
+ }
35
+
36
+ /** The first schema.table after FROM in a query (lower case, quotes removed), or null. */
37
+ export function firstTable(source: string | null | undefined): string | null {
38
+ const m = /\bfrom\s+("?[a-z_][\w$]*"?)\s*\.\s*("?[a-z_][\w$]*"?)/i.exec(source ?? '');
39
+ return m ? `${m[1].replace(/"/g, '').toLowerCase()}.${m[2].replace(/"/g, '').toLowerCase()}` : null;
40
+ }
41
+
42
+ /** The literals of a CHECK (col = ANY (ARRAY['a'::text, …])) or (col IN ('a', …)), or null. */
43
+ export function checkValues(def: string): string[] | null {
44
+ const m = /^CHECK \(\(?\(?"?[a-z_][\w$]*"?\)?(?:::text)? = ANY \(\(?ARRAY\[(.*)\]\)?(?:::text\[\])?\)\)\)?$/i.exec(def.trim());
45
+ if (!m) return null;
46
+ const values = [...m[1].matchAll(/'((?:[^']|'')*)'(?:::[a-z ]+)?/gi)].map((x) => x[1].replace(/''/g, "'"));
47
+ return values.length ? values : null;
48
+ }
49
+
50
+ const TYPES: Record<string, string> = {
51
+ int2: 'integer', int4: 'integer', int8: 'integer', numeric: 'number', float4: 'number', float8: 'number', money: 'number',
52
+ date: 'date', timestamp: 'timestamp', timestamptz: 'timestamp', bool: 'boolean', text: 'text', varchar: 'text', bpchar: 'text', citext: 'text',
53
+ };
54
+
55
+ export async function blueprintFromApp(appId: number, opts: { sampleRows?: Db | null } = {}) {
56
+ const db = owner;
57
+ const notes: string[] = [];
58
+ const app = await db.one<{ name: string; alias: string; authentication: string; db_role: string | null }>(
59
+ 'select name, alias, authentication, db_role from meta.app where id = $1', [appId]);
60
+ if (!app) throw new Error('No such application.');
61
+ const regions = (await db.query<{ page_no: number; page_name: string; type: string; table_name: string | null; source: string | null; config: any }>(
62
+ `select p.page_no, p.name as page_name, r.type, r.table_name, r.source, r.config
63
+ from meta.region r join meta.page p on p.id = r.page_id where p.app_id = $1 order by p.page_no, r.seq, r.id`, [appId])).rows;
64
+ const tableOf = (r: (typeof regions)[number]) => (r.table_name ? r.table_name.toLowerCase().replace(/"/g, '') : firstTable(r.source));
65
+
66
+ // ---------------------------------------------------------------- tables
67
+ const wanted = [...new Set(regions.map(tableOf).filter((t): t is string => !!t))];
68
+ const readable = (await db.query<{ key: string; oid: number; label: string | null }>(
69
+ `select format('%s.%s', n.nspname, c.relname) as key, c.oid::int, obj_description(c.oid, 'pg_class') as label
70
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
71
+ where c.relkind in ('r', 'p') and format('%s.%s', n.nspname, c.relname) = any($1::text[])
72
+ and ($2::text is null or has_table_privilege($2::text, c.oid, 'SELECT'))
73
+ order by array_position($1::text[], format('%s.%s', n.nspname, c.relname))`, [wanted, app.db_role])).rows;
74
+ const tables: Table[] = [];
75
+ for (const t of readable.slice(0, LIMITS.tables)) {
76
+ const name = t.key.split('.')[1];
77
+ if (!NAME.test(name) || tables.some((x) => x.name === name)) {
78
+ notes.push(`Table ${t.key} is left out: ${NAME.test(name) ? 'another schema has a table with that name' : 'its name is not a-z, 0-9 and _'}.`);
79
+ continue;
80
+ }
81
+ const cols = (await db.query<{ name: string; typname: string; typtype: string; notnull: boolean; has_default: boolean; generated: boolean; pk: boolean; uniq: boolean; enum_values: string[] | null; checks: string[]; fk_table: string | null; fk_col: string | null }>(
82
+ `select a.attname as name, t.typname, t.typtype, a.attnotnull as notnull, a.atthasdef as has_default, a.attgenerated <> '' as generated,
83
+ exists (select 1 from pg_constraint k where k.conrelid = a.attrelid and k.contype = 'p' and k.conkey = array[a.attnum]) as pk,
84
+ exists (select 1 from pg_constraint k where k.conrelid = a.attrelid and k.contype = 'u' and k.conkey = array[a.attnum]) as uniq,
85
+ (select array_agg(e.enumlabel order by e.enumsortorder) from pg_enum e where e.enumtypid = t.oid) as enum_values,
86
+ coalesce((select array_agg(pg_get_constraintdef(k.oid)) from pg_constraint k where k.conrelid = a.attrelid and k.contype = 'c' and k.conkey = array[a.attnum]), '{}') as checks,
87
+ (select format('%s.%s', fn.nspname, fc.relname) from pg_constraint k join pg_class fc on fc.oid = k.confrelid join pg_namespace fn on fn.oid = fc.relnamespace
88
+ where k.conrelid = a.attrelid and k.contype = 'f' and k.conkey = array[a.attnum] limit 1) as fk_table,
89
+ (select fa.attname from pg_constraint k join pg_attribute fa on fa.attrelid = k.confrelid and fa.attnum = k.confkey[1]
90
+ where k.conrelid = a.attrelid and k.contype = 'f' and k.conkey = array[a.attnum] limit 1) as fk_col
91
+ from pg_attribute a join pg_type t on t.oid = a.atttypid
92
+ where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum`, [t.oid])).rows;
93
+ const table: Table = { key: t.key, name, label: t.label?.split('\n')[0].slice(0, 80) || null, hasId: false, columns: [] };
94
+ for (const c of cols) {
95
+ if (c.name === 'id' && c.pk) {
96
+ table.hasId = true;
97
+ continue;
98
+ }
99
+ if (c.generated) continue;
100
+ if (!NAME.test(c.name) || c.name === 'id') {
101
+ notes.push(`${t.key}.${c.name} is left out: the blueprint gives every table an id, and names are a-z, 0-9 and _.`);
102
+ continue;
103
+ }
104
+ if (table.columns.length >= LIMITS.columns) {
105
+ notes.push(`${t.key}: only the first ${LIMITS.columns} columns are kept.`);
106
+ break;
107
+ }
108
+ if (c.typname === 'bytea') {
109
+ notes.push(`${t.key}.${c.name} is left out: blueprints have no column type for files or binary data.`);
110
+ continue;
111
+ }
112
+ const type = c.typtype === 'e' ? 'text' : TYPES[c.typname];
113
+ if (!type) notes.push(`${t.key}.${c.name} (${c.typname}) becomes text.`);
114
+ if (c.pk) notes.push(`${t.key}'s key is ${c.name}: the blueprint's tables get an id key; ${c.name} stays a required, unique column.`);
115
+ const values = c.enum_values ?? c.checks.map(checkValues).find((v) => v) ?? [];
116
+ table.columns.push({
117
+ name: c.name, type: type ?? 'text', required: c.pk || (c.notnull && !c.has_default), unique: c.pk || c.uniq,
118
+ values: (type ?? 'text') === 'text' ? values.slice(0, LIMITS.values).filter((v) => v && v.length <= 100) : [],
119
+ references: c.fk_table && c.fk_col === 'id' ? c.fk_table : null,
120
+ });
121
+ if (c.fk_table && c.fk_col !== 'id') notes.push(`${t.key}.${c.name} refers to ${c.fk_table}.${c.fk_col}: blueprint references point at an id, so it stays a plain column.`);
122
+ }
123
+ if (!table.columns.length) {
124
+ notes.push(`Table ${t.key} is left out: it has no columns besides its id.`);
125
+ continue;
126
+ }
127
+ tables.push(table);
128
+ }
129
+ // references only to tables of the blueprint (by their name there)
130
+ const byKey = new Map(tables.map((t) => [t.key, t.name]));
131
+ for (const t of tables)
132
+ for (const c of t.columns)
133
+ if (c.references) {
134
+ const target = byKey.get(c.references);
135
+ if (!target) notes.push(`${t.key}.${c.name} refers to ${c.references}, which the application doesn't show: it stays a plain column.`);
136
+ c.references = target ?? null;
137
+ if (!target) c.type = 'integer';
138
+ }
139
+ for (const w of wanted) if (!readable.some((r) => r.key === w)) notes.push(`${w} is left out: not a table the application's role may read.`);
140
+
141
+ // ---------------------------------------------------------------- pages
142
+ const PRIORITY = ['grid', 'facets', 'calendar', 'cards', 'map', 'chart', 'report', 'form'];
143
+ const byPage = new Map<number, (typeof regions)>();
144
+ for (const r of regions) byPage.set(r.page_no, [...(byPage.get(r.page_no) ?? []), r]);
145
+ const known = new Set(tables.map((t) => t.key));
146
+ const nameOf = (key: string | null) => (key && known.has(key) ? byKey.get(key)! : null);
147
+ const pages: Record<string, unknown>[] = [];
148
+ const formPages = new Map<string, number>(); // table → the first page with just a form on it
149
+ for (const [no, rs] of byPage) {
150
+ const data = rs.filter((r) => PRIORITY.includes(r.type));
151
+ if (no > 1 && data.length === 1 && data[0].type === 'form' && nameOf(tableOf(data[0])) && !formPages.has(nameOf(tableOf(data[0]))!))
152
+ formPages.set(nameOf(tableOf(data[0]))!, no);
153
+ }
154
+ const consumed = new Set<number>();
155
+ const pageNos = [...byPage.keys()].filter((n) => n >= 2 && n <= 99999).sort((a, b) => a - b);
156
+ const used = new Set<number>([1, ...pageNos]);
157
+ const freeNo = () => {
158
+ let n = Math.max(...used) + 1;
159
+ while (used.has(n)) n++;
160
+ used.add(n);
161
+ return n;
162
+ };
163
+ for (const no of pageNos) {
164
+ if (consumed.has(no) || pages.length >= LIMITS.pages) continue;
165
+ const rs = byPage.get(no)!;
166
+ const label = rs[0].page_name;
167
+ const main = [...rs].filter((r) => PRIORITY.includes(r.type)).sort((a, b) => PRIORITY.indexOf(a.type) - PRIORITY.indexOf(b.type))[0];
168
+ const table = main
169
+ ? nameOf(main.type === 'facets' ? firstTable(rs.find((r) => r.type === 'report')?.source) : tableOf(main))
170
+ : null;
171
+ if (!main || !table) {
172
+ pages.push({ type: 'blank', page: no, label });
173
+ continue;
174
+ }
175
+ if (main.type === 'report') {
176
+ const form = formPages.get(table);
177
+ const formPage = form && form !== no && !consumed.has(form) ? form : freeNo();
178
+ consumed.add(formPage);
179
+ pages.push({ type: 'report_form', table, page: no, form_page: formPage, label });
180
+ } else if (main.type === 'form') {
181
+ if (formPages.get(table) === no && pages.some((p) => p.table === table && p.type === 'report_form')) continue;
182
+ pages.push({ type: 'form', table, page: no, label });
183
+ } else pages.push({ type: main.type, table, page: no, label });
184
+ }
185
+ if (pageNos.length > LIMITS.pages) notes.push(`Only the first ${LIMITS.pages} pages are kept.`);
186
+
187
+ // ---------------------------------------------------------------- navigation
188
+ const pageSet = new Set<number>([1, ...pages.flatMap((p) => [p.page as number, ...(p.form_page ? [p.form_page as number] : [])])]);
189
+ const navigation = (await db.query<{ label: string; page: number; icon: string | null }>(
190
+ `select label, target_page as page, icon from meta.nav_entry where app_id = $1 and target_page is not null order by seq, id`, [appId])).rows
191
+ .filter((n) => pageSet.has(n.page) && n.label)
192
+ .slice(0, LIMITS.pages)
193
+ .map((n) => ({ label: n.label.slice(0, 80), page: n.page, ...(n.icon && /^[a-z-]{1,30}$/.test(n.icon) ? { icon: n.icon } : {}) }));
194
+
195
+ // ---------------------------------------------------------------- sample rows (asked for only)
196
+ const sample_data: { table: string; columns: string[]; rows: unknown[][] }[] = [];
197
+ if (opts.sampleRows) {
198
+ for (const t of tables) {
199
+ const columns = [...(t.hasId ? ['id'] : []), ...t.columns.map((c) => c.name)];
200
+ const [schema, rel] = t.key.split('.');
201
+ const res = await opts.sampleRows.tx(async (c) => {
202
+ await c.query('set transaction read only');
203
+ await c.query(`set local statement_timeout = '10s'`);
204
+ return c.query({ text: `select ${columns.map((x) => `"${x}"`).join(', ')} from "${schema}"."${rel}" ${t.hasId ? 'order by id' : ''} limit ${SAMPLE_ROWS}`, rowMode: 'array' });
205
+ }).catch((e: Error) => {
206
+ notes.push(`No sample rows from ${t.key}: ${e.message}`);
207
+ return null;
208
+ });
209
+ if (!res?.rows.length) continue;
210
+ sample_data.push({
211
+ table: t.name, columns,
212
+ rows: res.rows.map((row: unknown[]) => row.map((v) =>
213
+ v === null || typeof v === 'boolean' || typeof v === 'number' ? v
214
+ : v instanceof Date ? v.toISOString()
215
+ : typeof v === 'object' ? JSON.stringify(v).slice(0, 2000)
216
+ : String(v).slice(0, 2000))),
217
+ });
218
+ }
219
+ }
220
+
221
+ const alias = `${app.alias}-copy`.slice(0, 50);
222
+ const spec = {
223
+ blueprint: 1,
224
+ name: `${app.name} (copy)`.slice(0, 100),
225
+ alias,
226
+ schema: alias.replace(/-/g, '_'),
227
+ authentication: app.authentication === 'none' ? 'none' : 'app_users',
228
+ tables: tables.map((t) => ({
229
+ name: t.name, ...(t.label ? { label: t.label } : {}),
230
+ columns: t.columns.map((c) => ({
231
+ name: c.name, ...(c.references ? { references: c.references } : { type: c.type }),
232
+ ...(c.required ? { required: true } : {}), ...(c.unique ? { unique: true } : {}), ...(c.values.length ? { values: c.values } : {}),
233
+ })),
234
+ })),
235
+ pages,
236
+ ...(navigation.length ? { navigation } : {}),
237
+ ...(sample_data.length ? { sample_data } : {}),
238
+ };
239
+ return { spec, notes };
240
+ }
@@ -11,6 +11,9 @@ import { builderService } from './ai-builder.ts';
11
11
  import { addDashboard } from './appsheets.ts';
12
12
  import { checkNewApp, createApp, createAppError } from './newapp.ts';
13
13
  import { currentWorkspace } from './workspaces.ts';
14
+ import { blueprintFromApp } from '../blueprint-from-app.ts';
15
+ import { devDbForApp } from '../workspace-db.ts';
16
+ import { isAdmin } from './locks.ts';
14
17
  import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
15
18
  import { WIZARD_KINDS } from './wizards.ts';
16
19
 
@@ -148,6 +151,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
148
151
  const s = await developer(req, reply);
149
152
  if (!s) return;
150
153
  const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
154
+ const apps = (await owner.query(`select id, name, alias from meta.app where meta.app_workspace(id) = $1 order by lower(name), id`, [currentWorkspace(s)])).rows;
151
155
  return page(s, reply, '', html`<h1>Blueprints</h1>
152
156
  <p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
153
157
  ${list.length
@@ -156,7 +160,33 @@ export async function blueprintRoutes(app: FastifyInstance) {
156
160
  <td data-label="Application created">${x.app_id ? html`<a href="${BASE}/apps/${x.app_id}">${x.alias}</a>` : '—'}</td><td data-label="Changed">${String(x.updated).slice(0, 16)}</td>
157
161
  <td><form method="post" action="${BASE}/blueprints/${x.id}/delete">${csrf(s)}<button class="btn btn-small">Delete</button></form></td></tr>`)}
158
162
  </tbody></table></div>`
159
- : html`<p class="muted">No saved blueprints yet.</p>`}`);
163
+ : html`<p class="muted">No saved blueprints yet.</p>`}
164
+ ${apps.length ? region('From an application', html`<form method="post" action="${BASE}/blueprints/from-app">${csrf(s)}
165
+ <p class="muted u-mt0">A blueprint of an application of this workspace: the tables its pages use, its pages and its menu, to review and change in the editor (for a copy, a new version or a test system). Nothing is created until you confirm the review.</p>
166
+ <div class="form-grid">
167
+ <div class="field"><label class="label" for="f_bp_app">Application</label><select id="f_bp_app" name="app">${apps.map((a) => html`<option value="${a.id}">${a.name} (${a.alias})</option>`)}</select></div>
168
+ <div class="field"><label class="check"><input type="checkbox" name="rows" value="true"> Sample rows: up to 20 per table</label>
169
+ <small class="help">The rows are copied into the blueprint as they are: leave this off for personal or confidential data.</small></div>
170
+ </div>
171
+ <div class="buttons"><button class="btn">${icon('layers')} Create a blueprint</button></div></form>`) : ''}`);
172
+ });
173
+
174
+ // (sprint 66) a blueprint from an application of the current workspace, opened in the editor
175
+ app.post(`${BASE}/blueprints/from-app`, async (req: Req, reply) => {
176
+ const s = await developer(req, reply);
177
+ if (!s) return;
178
+ const id = /^\d{1,9}$/.test(String(req.body?.app ?? '')) ? Number(req.body!.app) : null;
179
+ const a = id === null ? null : await owner.one<{ id: number; name: string }>(
180
+ `select id, name from meta.app where id = $1 and (meta.app_workspace(id) = $2 or $3)`, [id, currentWorkspace(s), await isAdmin(s.username)]);
181
+ if (!a) return reply.code(404).send('Not found');
182
+ const rows = req.body?.rows === 'true';
183
+ const { spec, notes } = await blueprintFromApp(a.id, { sampleRows: rows ? await devDbForApp(a.id) : null });
184
+ await logActivity({ appId: a.id, username: s.username, event: 'blueprint', ip: clientIp(req), detail: `blueprint from the application${rows ? ', with sample rows' : ''}` });
185
+ return editor(s, reply, {
186
+ name: `${a.name} blueprint`.slice(0, 100), spec: JSON.stringify(spec, null, 2),
187
+ message: html`<div class="alert alert-info" role="status"><p>A blueprint of ${a.name}: review it, change the name, alias and schema, then <b>Review</b>.</p>
188
+ ${notes.length ? html`<details><summary>${notes.length} note${notes.length === 1 ? '' : 's'} on what didn't carry over</summary><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></details>` : ''}</div>`,
189
+ });
160
190
  });
161
191
 
162
192
  app.get(`${BASE}/blueprints/new`, async (req: Req, reply) => {
@@ -544,6 +544,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
544
544
  validate: (v) => {
545
545
  if (v.due_in && !/^\s*\d+\s*(minute|hour|day|week|month)s?\s*$/i.test(String(v.due_in))) return 'Due in: a number and a unit, e.g. 2 days or 4 hours.';
546
546
  if (v.priority !== null && v.priority !== undefined && (Number(v.priority) < 1 || Number(v.priority) > 5)) return 'Priority: 1 (urgent) to 5 (low).';
547
+ if (v.expiration && !v.due_in) return 'When overdue: set "Due in" first (a task without a due date is never overdue).';
548
+ if (v.expiration === 'renew' && v.max_renewals !== null && v.max_renewals !== undefined && v.max_renewals !== '' && (Number(v.max_renewals) < 1 || Number(v.max_renewals) > 100)) return 'Maximum renewals: 1 to 100.';
549
+ if (v.expiration === 'escalate' && !String(v.escalate_role ?? '').trim()) return 'Escalation role: the role that takes the task over.';
547
550
  return null;
548
551
  },
549
552
  fields: [
@@ -555,6 +558,10 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
555
558
  { name: 'initiator_can_complete', label: 'The person who requested it may complete it', kind: 'bool', group: 'Participants' },
556
559
  { name: 'priority', label: 'Priority (1 urgent – 5 low)', kind: 'int', group: 'Deadline' },
557
560
  { name: 'due_in', label: 'Due in', kind: 'text', group: 'Deadline', help: 'e.g. 2 days; overdue tasks are marked in the task list.' },
561
+ { name: 'expiration', label: 'When overdue', kind: 'select', options: ['', 'expire', 'renew', 'escalate'], group: 'Deadline',
562
+ help: 'Empty: the task stays open. expire: it ends as expired. renew: the due date moves on by "Due in" (up to the maximum renewals), then it expires. escalate: once, the escalation role becomes an owner, the priority 1 and the task unassigned with a new due date; overdue again, it expires. A workflow waiting for the task goes on with the outcome "expired".' },
563
+ { name: 'max_renewals', label: 'Maximum renewals', kind: 'int', group: 'Deadline', help: 'For renew: 1 to 100 (default 3).' },
564
+ { name: 'escalate_role', label: 'Escalation role', kind: 'text', group: 'Deadline', help: 'For escalate: the role (e.g. a manager role) whose members may then act on the task.' },
558
565
  { name: 'details_page', label: 'Details page', kind: 'page', group: 'Details', help: 'The subject links here, setting the item below to the task\'s record key.' },
559
566
  { name: 'details_item', label: 'Details item', kind: 'upper', group: 'Details', help: 'e.g. P7_ID' },
560
567
  { name: 'action_code', label: 'On completion (SQL)', kind: 'code', wide: true, group: 'Action',