pgkiln 0.31.0 → 0.35.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 (107) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/docs/apex-feature-parity.md +55 -35
  15. package/docs/guide/01-installation.md +1 -0
  16. package/docs/guide/03-builder.md +118 -2
  17. package/docs/guide/04-pages-and-regions.md +115 -0
  18. package/docs/guide/05-items.md +47 -0
  19. package/docs/guide/06-processing.md +210 -2
  20. package/docs/guide/07-dynamic-actions.md +60 -2
  21. package/docs/guide/08-security.md +13 -2
  22. package/docs/guide/09-reference.md +21 -0
  23. package/docs/guide/11-from-apex.md +5 -1
  24. package/docs/guide/12-development.md +33 -1
  25. package/docs/guide/14-globalization.md +15 -0
  26. package/docs/guide/15-extensions.md +1 -1
  27. package/docs/guide/18-cli.md +30 -1
  28. package/docs/guide/20-ai-agents.md +3 -2
  29. package/examples/hr/hr_50_search.sql +47 -0
  30. package/examples/hr/hr_51_mail.sql +70 -0
  31. package/examples/hr/hr_52_feedback.sql +15 -0
  32. package/examples/hr/hr_53_data_settings.sql +108 -0
  33. package/package.json +3 -1
  34. package/public/app.css +62 -0
  35. package/public/app.js +408 -11
  36. package/public/builder.css +11 -0
  37. package/public/builder.js +27 -0
  38. package/src/app.ts +8 -0
  39. package/src/appfiles.ts +12 -0
  40. package/src/automations.ts +12 -0
  41. package/src/builder/advisor.ts +54 -14
  42. package/src/builder/arrange.ts +2 -2
  43. package/src/builder/availability.ts +74 -0
  44. package/src/builder/backups.ts +134 -0
  45. package/src/builder/components.ts +109 -15
  46. package/src/builder/designer.ts +8 -3
  47. package/src/builder/feedback.ts +82 -0
  48. package/src/builder/forms.ts +4 -1
  49. package/src/builder/home.ts +3 -0
  50. package/src/builder/locks.ts +15 -5
  51. package/src/builder/mail.ts +425 -0
  52. package/src/builder/newapp.ts +1 -1
  53. package/src/builder/region-help.ts +71 -0
  54. package/src/builder/routes.ts +44 -1
  55. package/src/builder/search.ts +4 -4
  56. package/src/builder/shared.ts +3 -2
  57. package/src/builder/ui.ts +7 -1
  58. package/src/cli/apps.ts +46 -5
  59. package/src/cli/main.ts +21 -5
  60. package/src/cli/mcp.ts +16 -4
  61. package/src/cli/replace.ts +36 -4
  62. package/src/geocode.ts +44 -0
  63. package/src/i18n/ar.ts +39 -0
  64. package/src/i18n/cs.ts +39 -0
  65. package/src/i18n/da.ts +39 -0
  66. package/src/i18n/de.ts +39 -0
  67. package/src/i18n/el.ts +39 -0
  68. package/src/i18n/es.ts +39 -0
  69. package/src/i18n/fi.ts +39 -0
  70. package/src/i18n/fr.ts +39 -0
  71. package/src/i18n/he.ts +39 -0
  72. package/src/i18n/it.ts +39 -0
  73. package/src/i18n/ja.ts +39 -0
  74. package/src/i18n/ko.ts +39 -0
  75. package/src/i18n/nb.ts +39 -0
  76. package/src/i18n/pl.ts +39 -0
  77. package/src/i18n/pt.ts +39 -0
  78. package/src/i18n/ru.ts +39 -0
  79. package/src/i18n/sv.ts +39 -0
  80. package/src/i18n/tr.ts +39 -0
  81. package/src/i18n/uk.ts +39 -0
  82. package/src/i18n/zh.ts +39 -0
  83. package/src/i18n.ts +78 -0
  84. package/src/mail/mail.ts +148 -0
  85. package/src/mail/providers.ts +444 -0
  86. package/src/metadata.ts +26 -8
  87. package/src/runtime/ajax.ts +97 -0
  88. package/src/runtime/authz.ts +1 -1
  89. package/src/runtime/availability.ts +37 -0
  90. package/src/runtime/context.ts +46 -6
  91. package/src/runtime/data-export.ts +94 -0
  92. package/src/runtime/data-items.ts +194 -0
  93. package/src/runtime/devbar.ts +182 -0
  94. package/src/runtime/engine.ts +26 -1
  95. package/src/runtime/feedback.ts +128 -0
  96. package/src/runtime/items.ts +8 -0
  97. package/src/runtime/processes.ts +83 -2
  98. package/src/runtime/regions.ts +8 -0
  99. package/src/runtime/render.ts +33 -7
  100. package/src/runtime/rest.ts +4 -0
  101. package/src/runtime/routes.ts +59 -5
  102. package/src/runtime/search.ts +252 -0
  103. package/src/runtime/url-region.ts +93 -0
  104. package/src/security.ts +11 -1
  105. package/src/server.ts +2 -0
  106. package/src/session.ts +8 -2
  107. package/src/workingcopy.ts +2 -2
@@ -68,6 +68,7 @@ src/
68
68
  css.ts PageCss: data-dependent styles as classes in the page's nonce'd <style> (CSP)
69
69
  metadata.ts types + loaders for apps and pages (components of excluded build options are left out here)
70
70
  maptiles.ts map tile server URL, attribution and CSP origin
71
+ geocode.ts the installation's geocoder for address items (GEOCODER_URL, Nominatim search API)
71
72
  mvt.ts Mapbox Vector Tile encoding for map layers served as tiles
72
73
  yamltext.ts the text style of the directory export: a strict YAML subset, written and read
73
74
  webclient.ts outgoing HTTP to web services: allow-list, address checks at connect time (SSRF), redirects, limits
@@ -80,6 +81,11 @@ src/
80
81
  webrequests.ts web requests from SQL (meta.web_request, migration 052): runPending() after each sql page process
81
82
  (same transaction), webRequestTick() for committed ones (automations scheduler), retention purge;
82
83
  calls go through websources.ts call()/invoke() (allow-list, SSRF checks, credentials)
84
+ mail/ e-mail (migration 081): providers.ts (one deliver() per provider: SMTP through nodemailer with a password
85
+ or XOAUTH2, Microsoft Graph sendMail, Amazon SES v2 raw MIME with signV4, Resend, Postmark, SendGrid,
86
+ Mailgun, log; MailError says whether a retry may help; client-credentials token cache; settingsProblems),
87
+ mail.ts (sendWith(): service enabled, allowed sender, secret decrypted; mailTick(): claim with SKIP LOCKED,
88
+ send, retry after 1/4/16/64 minutes, purge after 30 days; the NOTIFY pgkiln_mail listener)
83
89
  ai/ AI services (migration 060): types.ts (the provider interface, AiError kinds),
84
90
  anthropic.ts (Claude through @anthropic-ai/sdk: streamed, effort, structured outputs via
85
91
  output_config.format, refusal fallbacks, stop_reason checks, typed SDK errors), openai.ts (the openai
@@ -142,6 +148,21 @@ src/
142
148
  session, formatAnswer (escaped), send/clear routes
143
149
  ai-filter.ts natural-language filters on a report ("ai_filter"): columns → structured output → checked → r<id>_f/q/s/d
144
150
  pdf.ts report PDFs with report layouts (pdfkit); rows from a cursor in batches (tablePdf takes batches)
151
+ search.ts search region and search configurations (migration 080): words as ILIKE parameters, table/sql/list
152
+ sources as the app role in savepoints, marked results, configColumns/searchConfigProblems for the Advisor
153
+ url-region.ts URL region (migration 078): literal origins only (urlTarget), &ITEM. URL-encoded, sandbox and height,
154
+ frameOrigins() for the CSP's frame-src (security.ts)
155
+ ajax.ts Ajax callbacks (migration 085: POST …/ajax/:name, point "ajax" processes, x01 … x10 binds) and the
156
+ download dynamic action (POST …/da/:id/download)
157
+ availability.ts availabilityRefusal(): who may use an application (migration 083); loadContext, the login page
158
+ and REST modules ask it (PostgREST: meta.api_check)
159
+ feedback.ts users' feedback page and the navigation bar entry (migration 084)
160
+ data-items.ts display image and address items (migration 086): imageUrl (https origins into the CSP's img-src),
161
+ GET …/image/:item (a BLOB from the item's query), POST …/geocode/:item, and items kept per user
162
+ (loadPreferences / savePreferences through meta.get_preference / set_preference)
163
+ data-export.ts the download process's {"export": …}: meta.data_export for csv/json/xml/html, Excel and PDF here
164
+ devbar.ts developer toolbar and session state viewer (migration 079): the pgkiln_devbar token of a builder
165
+ session (ensureDevbar/devbarOf), the toolbar markup, the viewer, clear and per-session debug routes
145
166
  builder/
146
167
  components.ts property spec of every component (drives the property editor)
147
168
  ui.ts IDE shell (icon rail, toolbar, breadcrumb, status bar), builder theme, form helpers, CSRF check, app tabs
@@ -158,6 +179,14 @@ src/
158
179
  appwizard.ts Create → From pasted data (kept as a temp file, then the From a file steps) and From existing tables
159
180
  (a schema's tables/views → report+form or report pages, navigation, dashboard)
160
181
  forms.ts generic component property form (lookups, render, save)
182
+ backups.ts Application → Backups (migration 082): list, back up now, download, restore (replaceApp), keep N;
183
+ backupTick() for the scheduler's daily backups of changed applications
184
+ availability.ts Application → Availability (migration 083): status, message, users, build status (Run only is
185
+ enforced by locks.ts blockingLock); availabilityBadge() in the application header
186
+ feedback.ts Application → Feedback (migration 084): the setting, the list with filters, status and answers
187
+ region-help.ts the Attributes (JSON) help per region type, with links to the guide
188
+ mail.ts Workspace utilities → Mail services (administrators: providers, write-only secret, allowed senders,
189
+ applications with From and daily limit, test) and Applications → Activity → Mail (retry, cancel)
161
190
  shared.ts Shared Components and access control
162
191
  designer.ts page designer: component tree (with computations and branches), layout canvas and gallery, property editor, toolbar
163
192
  arrange.ts page designer layout changes: move, column span, create from the gallery, undo / redo
@@ -377,5 +406,8 @@ browser enhancements at the end of `public/app.js` (the item must work without t
377
406
  1. Update `CHANGELOG.md` and the version in `package.json`.
378
407
  2. Merge to `main`; CI must be green (every job). The `main` branch should be protected on
379
408
  GitHub (Settings → Branches → rule for `main`: require the CI checks).
380
- 3. Tag: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`.
409
+ 3. Tag: `git tag -a vX.Y.Z -m vX.Y.Z && git push origin vX.Y.Z`. The tag publishes the Docker image
410
+ (`docker-publish.yml`: `pgkiln/pgkiln` and `ghcr.io/pgkiln/pgkiln`, amd64 and arm64) and the npm package
411
+ (`npm-publish.yml`, trusted publishing with provenance; it refuses a tag that doesn't match
412
+ `package.json`). Then `gh release create vX.Y.Z` with the changelog section as notes.
381
413
  4. Add the new tag to the `upgrade` job's matrix in `.github/workflows/ci.yml` (see above).
@@ -98,6 +98,21 @@ raise exception '%', meta.message('LEAVE_OVERLAP', to_char(p_start, 'DD-MM-YYYY'
98
98
 
99
99
  The HR sample's dashboard uses them for its key figures (`examples/hr/hr_04_i18n.sql`).
100
100
 
101
+ ### Dynamic translations
102
+
103
+ For **values from tables** (department names, statuses, list of values entries), use dynamic
104
+ translations (APEX: *Dynamic Translations*, `APEX_LANG.LANG`): **Shared Components → Dynamic
105
+ translations** holds a language, the text as stored and its translation, and `meta.lang` returns
106
+ the text in the session's language:
107
+
108
+ ```sql
109
+ select meta.lang(dname) as d, deptno as r from hr.dept order by 1 -- a list of values
110
+ ```
111
+
112
+ An exact language match wins, then the base language (`nl-BE` → `nl`), else the text as it is.
113
+ `meta.lang(text, 'de')` asks for another language. Dynamic translations are exported with the
114
+ application (`globalization/dynamic-translations.yaml` in an application's files).
115
+
101
116
  ### Date formats
102
117
 
103
118
  Dates and timestamps in reports, grids and display items are formatted with a **mask**:
@@ -109,7 +109,7 @@ Audit triggers record such changes as made by `nobody`, which is what you want f
109
109
  | **[pgmq](https://github.com/pgmq/pgmq)** | A message queue in Postgres for background work handled by a worker service | Like AWS SQS, transactional |
110
110
  | **pg_partman** | Partition big append-only tables such as `meta.activity_log` by month | Only needed at high volume |
111
111
  | **[plv8](https://github.com/plv8/plv8)** | Server-side JavaScript functions | Like APEX's MLE JavaScript. Maintained upstream, but some hosts (e.g. Supabase on PostgreSQL 17) no longer offer it, so check before depending on it |
112
- | **[pg_smtp_client](https://github.com/brianpursley/pg_smtp_client)** | Sending e-mail from SQL, if your team needs it | pgkiln deliberately doesn't send mail ([chapter 11](11-from-apex.md)). This keeps mail out of pgkiln and in your own schema |
112
+ | **[pg_smtp_client](https://github.com/brianpursley/pg_smtp_client)** | Sending e-mail synchronously from SQL, outside an application | pgkiln itself sends mail queued with `meta.send_mail` after the commit, through SMTP, Microsoft Graph, Amazon SES or an HTTP API ([chapter 6](06-processing.md#sending-e-mail)); this extension talks SMTP from the database itself |
113
113
 
114
114
  ## Security notes
115
115
 
@@ -35,7 +35,7 @@ any (see [what is not exported](03-builder.md#export-format)).
35
35
  | `pgkiln migrate [--example <name>]` | Applies the migrations that were not applied yet, then optionally `examples/<name>/` (same as `npm run db:migrate` / `npm run example:hr`; `--root` and `--seed` as in `scripts/migrate.ts`) |
36
36
  | `pgkiln apps [--json]` | Lists the applications: alias, number of pages, name |
37
37
  | `pgkiln export <alias> [--format json\|dir\|text] [--out <path>]` | `json` (default): the `pgkiln/2` document with sorted keys, to `--out` or standard output. `dir`: a directory (default `./<alias>`), see below. `text`: the same directory in YAML with the code inline ([text files](#text-files-apexlang)) |
38
- | `pgkiln import <path> [--alias <alias>] [--replace]` | Imports a JSON export, an application directory or a `.zip` of one. `--alias` gives the copy another alias. `--replace` updates the application with that alias in place |
38
+ | `pgkiln import <path> [--alias <alias>] [--replace] [--check [--force] \| --dry-run]` | Imports a JSON export, an application directory or a `.zip` of one. `--alias` gives the copy another alias. `--replace` updates the application with that alias in place (after a [backup](03-builder.md#backups)). `--check` checks the application before it commits, `--dry-run` only checks ([below](#checking-before-an-import)) |
39
39
  | `pgkiln diff <alias> <path> [--name-only \| --quiet]` | What differs between the application in the database and a directory (or JSON file, or zip) |
40
40
  | `pgkiln mcp` | An MCP server on standard input/output for AI coding agents, see [chapter 20](20-ai-agents.md) |
41
41
  | `pgkiln plugin build <dir> [-o <file>]` | Builds a plug-in file (`pgkiln-plugin/2`) from a source directory, see [plug-ins](04-pages-and-regions.md#plug-ins-with-their-own-code) |
@@ -187,6 +187,9 @@ the files any more are removed. What belongs to this installation stays:
187
187
  [chapter 19](19-rest-data-sources.md#web-credentials)).
188
188
  - the **style variant each user chose** ([chapter 14](14-globalization.md#style-variants-theme-roller));
189
189
  a style that the file no longer has falls back to the default.
190
+ - its **availability and build status** ([chapter 3](03-builder.md#availability-and-build-status)): exports
191
+ leave them out, so a deployment doesn't open an application you closed, or close one;
192
+ - its **backups**: the version the import replaces is the newest one ([chapter 3](03-builder.md#backups)).
190
193
 
191
194
  It all happens in one transaction; any error leaves the application unchanged. Without an
192
195
  application with that alias, `--replace` simply imports, so the same command deploys the first
@@ -201,6 +204,32 @@ pgkiln diff hr apps/hr # on the target: what would cha
201
204
  pgkiln import apps/hr --replace # deploy
202
205
  ```
203
206
 
207
+ ### Checking before an import
208
+
209
+ An import doesn't need the application's tables, columns and functions to exist (neither does APEX's):
210
+ a page whose SQL names a missing table imports fine and fails when it runs. `--check` asks the
211
+ [Advisor](03-builder.md#advisor) first, inside the import's transaction, before anything is committed:
212
+ every SQL fragment is planned as the application's database role (unknown tables, columns,
213
+ functions and types, missing grants), PL/pgSQL blocks are compiled, and references to pages,
214
+ items, lists of values, schemes and search configurations are checked.
215
+
216
+ ```sh
217
+ pgkiln import apps/hr --replace --check # errors: nothing changes, exit code 1
218
+ pgkiln import apps/hr --replace --dry-run # only check (exit code 0 without errors)
219
+ pgkiln import apps/hr --replace --check --force # report, import anyway
220
+ ```
221
+
222
+ ```text
223
+ ERROR page 2, region Employees (Source): relation "hr.employee" does not exist
224
+ WARNING page 3, item P3_SAL (Default value): …
225
+ Check: 1 error(s), 1 warning(s), 0 note(s).
226
+ Not imported: the check found 1 error(s). Fix them, or use --force.
227
+ ```
228
+
229
+ Deploy the database changes (your migration scripts) before the application, so the check sees
230
+ the tables the new version needs. The MCP server's `import_app` checks by default; the builder's
231
+ Import page has the check ticked.
232
+
204
233
  `export --format dir` into an existing directory removes the files of deleted components and
205
234
  leaves dot files (`.git`, `.gitattributes`) alone; it refuses a non-empty directory without a
206
235
  `pgkiln.json`, so a typo can't empty the wrong folder.
@@ -53,7 +53,7 @@ directory the agent started the server in (the project).
53
53
  | `recent_errors` | the latest errors and refused requests from the activity log | no |
54
54
  | `export_app` | writes an application to a directory (default `apps/<alias>`, format `text`) | files on disk |
55
55
  | `diff_app` | what an import of a directory would change (A/D/M with unified diffs); also reports files it can't read | no |
56
- | `import_app` | imports a directory, zip or JSON file; `replace: true` updates the application in place | the database |
56
+ | `import_app` | imports a directory, zip or JSON file; `replace: true` updates the application in place. It checks the application first (the Advisor, in the import's transaction): errors refuse the import and are listed; `force: true` imports anyway, `dry_run: true` only checks | the database |
57
57
 
58
58
  The files are the [directory format](18-cli.md#the-directory-format) of the command line, in the
59
59
  [text style](18-cli.md#text-files-apexlang): one YAML file per component, with SQL, PL/pgSQL and
@@ -67,7 +67,8 @@ users, sessions and saved reports kept, supporting objects and plug-in install S
67
67
  `describe_schema` for the tables; `search_docs` before guessing a property or region type.
68
68
  2. `export_app` to `apps/<alias>`, then edit the YAML files there.
69
69
  3. `diff_app`, so you (and it) see exactly what will change.
70
- 4. `import_app` with `replace: true`. Open the page in the browser to check.
70
+ 4. `import_app` with `replace: true`. When it names errors (a missing column, a typo in SQL), the agent fixes
71
+ the files and imports again; nothing was changed. Open the page in the browser to check.
71
72
 
72
73
  Tables, views and functions are ordinary PostgreSQL objects: an agent changes them in your
73
74
  migration scripts, not in the application files (`run_query` can't change them anyway).
@@ -0,0 +1,47 @@
1
+ -- =====================================================================
2
+ -- HR example, part 50: search configurations and the search region
3
+ -- (migration 080; docs/guide/04-pages-and-regions.md, search region)
4
+ --
5
+ -- Page 42 "Search": one field searches three sources as you type:
6
+ -- - EMPLOYEES: a query (employees with their department), each result
7
+ -- opens the employee's form (page 3);
8
+ -- - DEPARTMENTS: the table hr.dept, each result opens page 5;
9
+ -- - SHORTCUTS: the entries of the list HR_SHORTCUTS the user may see.
10
+ -- =====================================================================
11
+
12
+ insert into meta.search_config (app_id, name, label, seq, source_type, source, searchable_columns,
13
+ title_column, subtitle_column, badge_column, icon, link)
14
+ select a.id, c.name, c.label, c.seq, c.source_type, c.source, c.searchable, c.title, c.subtitle, c.badge, c.icon, c.link::jsonb
15
+ from meta.app a, (values
16
+ ('EMPLOYEES', 'Employees', 10, 'sql',
17
+ $q$select e.empno, initcap(e.ename) as name, initcap(e.job) as job, initcap(d.dname) as department
18
+ from hr.emp e left join hr.dept d on d.deptno = e.deptno$q$,
19
+ '{name,job,department}'::text[], 'name', 'job', 'department', 'user', '{"page": 3, "items": {"P3_EMPNO": "#EMPNO#"}}'),
20
+ ('DEPARTMENTS', 'Departments', 20, 'table', 'hr.dept', '{dname,loc}'::text[], 'dname', 'loc', null, 'building',
21
+ '{"page": 5, "items": {"P5_DEPTNO": "#DEPTNO#"}}'),
22
+ ('SHORTCUTS', 'Shortcuts', 30, 'list', 'HR_SHORTCUTS', '{}'::text[], null, null, null, 'link', null)
23
+ ) as c (name, label, seq, source_type, source, searchable, title, subtitle, badge, icon, link)
24
+ where a.alias = 'hr';
25
+
26
+ insert into meta.page (app_id, page_no, name, title, parent_page)
27
+ select id, 42, 'Search', 'Search', 1 from meta.app where alias = 'hr';
28
+
29
+ insert into meta.region (page_id, seq, title, type, columns, template, config)
30
+ select p.id, 10, 'Search', 'search', 12, 'standard',
31
+ '{"configs": ["EMPLOYEES", "DEPARTMENTS", "SHORTCUTS"], "placeholder": "Search employees, departments and shortcuts…"}'
32
+ from meta.page p join meta.app a on a.id = p.app_id where a.alias = 'hr' and p.page_no = 42;
33
+
34
+ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
35
+ select id, 2, 'Search', 'search', 42 from meta.app where alias = 'hr';
36
+
37
+ insert into meta.translation (app_id, language, source, target)
38
+ select a.id, 'nl', t.source, t.target
39
+ from meta.app a, (values
40
+ ('Search', 'Zoeken'),
41
+ ('Employees', 'Medewerkers'),
42
+ ('Departments', 'Afdelingen'),
43
+ ('Shortcuts', 'Snelkoppelingen'),
44
+ ('Search employees, departments and shortcuts…', 'Zoek medewerkers, afdelingen en snelkoppelingen…')
45
+ ) as t (source, target)
46
+ where a.alias = 'hr'
47
+ on conflict do nothing;
@@ -0,0 +1,70 @@
1
+ -- =====================================================================
2
+ -- HR example, part 51: e-mail (migration 081; docs/guide/06-processing.md,
3
+ -- "Sending e-mail")
4
+ --
5
+ -- The example sends through a mail service of provider "log": the server
6
+ -- writes each mail to its log and sends nothing, so the example works on
7
+ -- any installation. An administrator points HR at a real service under
8
+ -- Workspace utilities → Mail services (SMTP, Microsoft 365, Amazon SES,
9
+ -- Resend, Postmark, SendGrid or Mailgun).
10
+ --
11
+ -- The template LEAVE_DECIDED tells an employee that a leave request was
12
+ -- approved or rejected; a trigger queues it from the running application
13
+ -- when the status changes (the decision's transaction: a rollback sends
14
+ -- nothing). A mail that can't be queued never stops the decision.
15
+ -- =====================================================================
16
+
17
+ insert into meta.mail_service (name, description, provider, senders, settings)
18
+ values ('DEVELOPMENT_LOG', 'Writes mail to the server log; sends nothing', 'log', '{@example.com}', '{"default_from": "hr@example.com"}')
19
+ on conflict (name) do nothing;
20
+
21
+ insert into meta.app_mail_service (app_id, service_id, from_address, max_per_day)
22
+ select a.id, s.id, 'hr@example.com', 500
23
+ from meta.app a, meta.mail_service s
24
+ where a.alias = 'hr' and s.name = 'DEVELOPMENT_LOG'
25
+ on conflict (app_id) do nothing;
26
+
27
+ insert into meta.email_template (app_id, name, subject, html_body, text_body)
28
+ select id, 'LEAVE_DECIDED', 'Your leave request was #STATUS#',
29
+ $h$<p>Dear #NAME#,</p>
30
+ <p>Your leave request from <b>#START#</b> to <b>#END#</b> was <b>#STATUS#</b> by #DECIDED_BY#.</p>
31
+ <p>Kind regards,<br>HR</p>$h$,
32
+ $t$Dear #NAME#,
33
+
34
+ Your leave request from #START# to #END# was #STATUS# by #DECIDED_BY#.
35
+
36
+ Kind regards,
37
+ HR$t$
38
+ from meta.app where alias = 'hr';
39
+
40
+ create function hr.mail_leave_decided() returns trigger
41
+ language plpgsql security definer set search_path = hr, pg_catalog as $$
42
+ declare
43
+ v_email text;
44
+ v_name text;
45
+ begin
46
+ if new.status not in ('APPROVED', 'REJECTED') or meta.app_id() is null then
47
+ return new;
48
+ end if;
49
+ select a.email, initcap(e.ename) into v_email, v_name
50
+ from hr.emp e join meta.account a on lower(a.username) = lower(e.username)
51
+ where e.empno = new.empno;
52
+ if v_email is null then
53
+ return new;
54
+ end if;
55
+ perform meta.send_mail_template('LEAVE_DECIDED', jsonb_build_object(
56
+ 'NAME', v_name,
57
+ 'STATUS', lower(new.status),
58
+ 'START', to_char(new.start_date, 'DD Mon YYYY'),
59
+ 'END', to_char(new.end_date, 'DD Mon YYYY'),
60
+ 'DECIDED_BY', coalesce(new.decided_by, 'HR')), v_email);
61
+ return new;
62
+ exception when others then
63
+ raise warning 'hr.mail_leave_decided: %', sqlerrm;
64
+ return new;
65
+ end
66
+ $$;
67
+
68
+ create trigger leave_decided_mail after update of status on hr.leave_request
69
+ for each row when (old.status is distinct from new.status)
70
+ execute function hr.mail_leave_decided();
@@ -0,0 +1,15 @@
1
+ -- =====================================================================
2
+ -- HR example, part 52: feedback (migration 084; docs/guide/03-builder.md,
3
+ -- "Feedback")
4
+ --
5
+ -- Users send feedback about a page from the navigation bar's Feedback link;
6
+ -- developers answer under Application → Activity → Feedback. One answered
7
+ -- example from the user king.
8
+ -- =====================================================================
9
+
10
+ update meta.app set feedback = true where alias = 'hr';
11
+
12
+ insert into meta.feedback (app_id, page_no, username, kind, rating, body, status, response, responded_by, responded_at)
13
+ select id, 2, 'king', 'idea', 4, 'Could the employee list show the hire date as well?', 'done',
14
+ 'Added in the column list: open Actions → Columns and show "Hiredate".', 'admin', now()
15
+ from meta.app where alias = 'hr';
@@ -0,0 +1,108 @@
1
+ -- =====================================================================
2
+ -- HR example, part 53: data and settings (migration 086;
3
+ -- docs/guide/06-processing.md "Application settings", "User preferences",
4
+ -- "Data export"; docs/guide/14-globalization.md "Dynamic translations";
5
+ -- docs/guide/05-items.md "Display image", "Geocoded address")
6
+ --
7
+ -- Application settings SUPPORT_EMAIL and LEAVE_APPROVAL (Shared
8
+ -- Components → Application settings), read with meta.get_app_setting.
9
+ -- Dynamic translations of the department names in Dutch and German:
10
+ -- meta.lang(dname). Page 43 "Data and settings" (Administration):
11
+ -- - P43_DEPTNO, a department (names through meta.lang), kept per user
12
+ -- across sessions ({"persist": "user"});
13
+ -- - P43_EMPNO, an employee of that department, also kept per user, and
14
+ -- P43_PHOTO, the employee's photo from hr.emp.photo (display image);
15
+ -- - P43_ADDRESS, a geocoded address whose position goes into P43_LOCATION
16
+ -- (the "Check address" button shows where the server has GEOCODER_URL);
17
+ -- - Excel and CSV downloads of the department's employees (the download
18
+ -- process with {"export": …}, APEX_DATA_EXPORT).
19
+ -- =====================================================================
20
+
21
+ insert into meta.app_setting (app_id, name, value, description, required, valid_values)
22
+ select a.id, s.name, s.value, s.description, s.required, s.valid_values
23
+ from meta.app a, (values
24
+ ('SUPPORT_EMAIL', 'hr-support@example.com', 'Where users write with questions; shown on page 43.', true, null::text[]),
25
+ ('LEAVE_APPROVAL', 'manager', 'Who approves leave requests: the manager, or the HR department.', true, '{manager,hr}'::text[])
26
+ ) as s (name, value, description, required, valid_values)
27
+ where a.alias = 'hr';
28
+
29
+ insert into meta.dynamic_translation (app_id, language, source, target)
30
+ select a.id, t.language, t.source, t.target
31
+ from meta.app a, (values
32
+ ('nl', 'ACCOUNTING', 'BOEKHOUDING'), ('nl', 'RESEARCH', 'ONDERZOEK'), ('nl', 'SALES', 'VERKOOP'), ('nl', 'OPERATIONS', 'BEDRIJFSVOERING'),
33
+ ('de', 'ACCOUNTING', 'BUCHHALTUNG'), ('de', 'RESEARCH', 'FORSCHUNG'), ('de', 'SALES', 'VERTRIEB'), ('de', 'OPERATIONS', 'BETRIEB')
34
+ ) as t (language, source, target)
35
+ where a.alias = 'hr';
36
+
37
+ insert into meta.page (app_id, page_no, name, title, parent_page)
38
+ select id, 43, 'Data and settings', 'Data and settings', 1 from meta.app where alias = 'hr';
39
+
40
+ insert into meta.region (page_id, seq, title, type, columns, template)
41
+ select p.id, r.seq, r.title, 'static', 12, 'standard'
42
+ from meta.page p join meta.app a on a.id = p.app_id, (values (10, 'Employee'), (20, 'Visit address')) as r (seq, title)
43
+ where a.alias = 'hr' and p.page_no = 43;
44
+
45
+ insert into meta.item (page_id, region_id, seq, name, label, type, lov, help, config)
46
+ select p.id, (select r.id from meta.region r where r.page_id = p.id and r.title = i.region), i.seq, i.name, i.label, i.type, i.lov, i.help, i.config::jsonb
47
+ from meta.page p join meta.app a on a.id = p.app_id, (values
48
+ ('Employee', 10, 'P43_DEPTNO', 'Department', 'select',
49
+ 'select meta.lang(dname), deptno from hr.dept order by 1', 'Remembered for you, also after you sign out.',
50
+ '{"persist": "user", "submit_on_change": true, "null_label": "- All -"}'),
51
+ ('Employee', 20, 'P43_EMPNO', 'Employee', 'select',
52
+ 'select initcap(ename), empno from hr.emp where :P43_DEPTNO is null or deptno = :P43_DEPTNO::int order by 1', null,
53
+ '{"persist": "user", "submit_on_change": true, "null_label": "- Choose -"}'),
54
+ ('Employee', 30, 'P43_PHOTO', 'Photo', 'image', null, null,
55
+ '{"sql": "select photo, photo_mime from hr.emp where empno = :P43_EMPNO::int", "alt": "Photo of the employee", "width": 160}'),
56
+ ('Employee', 40, 'P43_SUPPORT', 'Questions?', 'display', null, 'The application setting SUPPORT_EMAIL.', '{}'),
57
+ ('Visit address', 50, 'P43_ADDRESS', 'Address', 'address', null, 'Type the address and choose it from the list.',
58
+ '{"coordinates_item": "P43_LOCATION"}'),
59
+ ('Visit address', 60, 'P43_LOCATION', 'Position', 'location', null, null, '{}')
60
+ ) as i (region, seq, name, label, type, lov, help, config)
61
+ where a.alias = 'hr' and p.page_no = 43;
62
+
63
+ insert into meta.button (page_id, region_id, seq, name, label, action)
64
+ select p.id, (select r.id from meta.region r where r.page_id = p.id and r.title = 'Employee'), b.seq, b.name, b.label, 'submit'
65
+ from meta.page p join meta.app a on a.id = p.app_id, (values (10, 'EXPORT_XLSX', 'Download Excel'), (20, 'EXPORT_CSV', 'Download CSV')) as b (seq, name, label)
66
+ where a.alias = 'hr' and p.page_no = 43;
67
+
68
+ insert into meta.process (page_id, seq, name, type, point, code, when_button, config)
69
+ select p.id, x.seq, x.name, x.type, x.point, x.code, x.button, x.config::jsonb
70
+ from meta.page p join meta.app a on a.id = p.app_id, (values
71
+ (10, 'Support address', 'sql', 'load', 'select meta.get_app_setting(''SUPPORT_EMAIL'') as p43_support', null, '{}'),
72
+ (20, 'Employees as Excel', 'download', 'submit',
73
+ 'select e.empno, initcap(e.ename) as name, initcap(e.job) as job, e.hiredate, e.sal as salary, meta.lang(d.dname) as department
74
+ from hr.emp e left join hr.dept d on d.deptno = e.deptno
75
+ where :P43_DEPTNO is null or e.deptno = :P43_DEPTNO::int
76
+ order by e.ename', 'EXPORT_XLSX', '{"export": "xlsx", "file_name": "employees", "title": "Employees"}'),
77
+ (30, 'Employees as CSV', 'download', 'submit',
78
+ 'select e.empno, initcap(e.ename) as name, initcap(e.job) as job, e.hiredate, e.sal as salary, meta.lang(d.dname) as department
79
+ from hr.emp e left join hr.dept d on d.deptno = e.deptno
80
+ where :P43_DEPTNO is null or e.deptno = :P43_DEPTNO::int
81
+ order by e.ename', 'EXPORT_CSV', '{"export": "csv", "file_name": "employees"}')
82
+ ) as x (seq, name, type, point, code, button, config)
83
+ where a.alias = 'hr' and p.page_no = 43;
84
+
85
+ insert into meta.nav_entry (app_id, parent_id, seq, label, icon, target_page)
86
+ select a.id, n.id, 40, 'Data and settings', 'settings', 43
87
+ 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
88
+ where a.alias = 'hr';
89
+
90
+ insert into meta.translation (app_id, language, source, target)
91
+ select a.id, 'nl', t.source, t.target
92
+ from meta.app a, (values
93
+ ('Data and settings', 'Gegevens en instellingen'),
94
+ ('Employee', 'Medewerker'),
95
+ ('Visit address', 'Bezoekadres'),
96
+ ('Department', 'Afdeling'),
97
+ ('Photo', 'Foto'),
98
+ ('Questions?', 'Vragen?'),
99
+ ('Address', 'Adres'),
100
+ ('Position', 'Positie'),
101
+ ('Download Excel', 'Excel downloaden'),
102
+ ('Download CSV', 'CSV downloaden'),
103
+ ('Remembered for you, also after you sign out.', 'Wordt voor u onthouden, ook na afmelden.'),
104
+ ('The application setting SUPPORT_EMAIL.', 'De applicatie-instelling SUPPORT_EMAIL.'),
105
+ ('Type the address and choose it from the list.', 'Typ het adres en kies het uit de lijst.')
106
+ ) as t (source, target)
107
+ where a.alias = 'hr'
108
+ on conflict do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.31.0",
3
+ "version": "0.35.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -73,6 +73,7 @@
73
73
  "ldapts": "^9.2.0",
74
74
  "leaflet": "^1.9.4",
75
75
  "lucide-static": "^1.52.0",
76
+ "nodemailer": "^10.0.12",
76
77
  "openai": "^7.28.0",
77
78
  "pdfkit": "^0.20.2",
78
79
  "pg": "^8.23.0",
@@ -81,6 +82,7 @@
81
82
  },
82
83
  "devDependencies": {
83
84
  "@types/node": "^26.6.3",
85
+ "@types/nodemailer": "^8.0.2",
84
86
  "@types/pdfkit": "^0.17.6",
85
87
  "@types/pg": "^8.23.1",
86
88
  "axe-core": "^4.14.0",
package/public/app.css CHANGED
@@ -265,6 +265,9 @@ details.menu > summary::-webkit-details-marker { display: none; }
265
265
  .region-toolbar { display: flex; justify-content: flex-end; margin-bottom: 0.75rem; }
266
266
  .region-static p:first-child { margin-top: 0; }
267
267
  .region-static p:last-child { margin-bottom: 0; }
268
+ /* (078) URL region: the framed page fills the region's width; its height is a class per region (PageCss) */
269
+ .url-frame { display: block; width: 100%; border: 1px solid var(--border, #d0d7de); border-radius: 6px; background: var(--surface, #fff); }
270
+ .url-open { margin: 0.5rem 0 0; font-size: 0.875rem; }
268
271
  .columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 1rem; margin-top: 1rem; }
269
272
  .columns .region { margin: 0; }
270
273
 
@@ -1416,6 +1419,12 @@ td.tc-cell { overflow-wrap: anywhere; }
1416
1419
 
1417
1420
  .qr { display: flex; flex-direction: column; align-items: flex-start; gap: 0.3rem; padding: 0.3rem 0; }
1418
1421
  .qr-code { max-width: 100%; height: auto; border-radius: 4px; }
1422
+ /* (086) display image and address items */
1423
+ .display-image { padding: 0.3rem 0; }
1424
+ .display-image img { max-width: 100%; height: auto; border-radius: 4px; }
1425
+ .geocode-places { list-style: none; margin: 0.3rem 0 0; padding: 0.3rem 0; border: 1px solid var(--border); border-radius: 4px; background: var(--surface); }
1426
+ .geocode-places li { padding: 0.15rem 0.6rem; }
1427
+ .geocode-places .link-button { text-align: start; }
1419
1428
 
1420
1429
  /* ---------- lists (list regions, navigation bar) ---------- */
1421
1430
  .list-links { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.1rem; }
@@ -1550,3 +1559,56 @@ dialog.crop-dialog::backdrop { background: rgb(15 20 30 / 55%); }
1550
1559
  .crop-handle { position: absolute; right: -8px; bottom: -8px; width: 16px; height: 16px; border-radius: 50%; background: #fff; border: 2px solid var(--accent); cursor: nwse-resize; }
1551
1560
  .crop-stage { overflow: hidden; }
1552
1561
  .crop-dialog .buttons { display: flex; justify-content: flex-end; gap: 0.5rem; }
1562
+
1563
+ /* (079) Developer toolbar: shown to developers with a builder session in the same browser.
1564
+ Fixed at the bottom left, collapsible (a <details>, so it works without JavaScript). */
1565
+ .devbar { position: fixed; left: 0.75rem; bottom: 0.75rem; z-index: 900; max-width: calc(100vw - 1.5rem);
1566
+ background: #1b2330; color: #fff; border-radius: var(--radius); box-shadow: 0 4px 16px rgb(0 0 0 / 25%); font-size: 0.8125rem; }
1567
+ .devbar summary { cursor: pointer; padding: 0.4rem 0.75rem; font-weight: 600; list-style: none; }
1568
+ .devbar summary::-webkit-details-marker { display: none; }
1569
+ .devbar summary::before { content: '▸ '; }
1570
+ .devbar[open] summary::before { content: '▾ '; }
1571
+ .devbar ul { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.75rem; list-style: none; margin: 0; padding: 0 0.75rem 0.5rem; }
1572
+ .devbar a { color: #9cc8ff; }
1573
+ .devbar a:focus-visible, .devbar button:focus-visible, .devbar select:focus-visible { outline: 2px solid #9cc8ff; outline-offset: 2px; }
1574
+ .devbar-debug { display: flex; align-items: center; gap: 0.35rem; margin: 0; }
1575
+ .devbar-debug select, .devbar-debug button { font: inherit; font-size: 0.8125rem; padding: 0.1rem 0.3rem; border-radius: 4px; border: 1px solid #5e6a7a; background: #2b3340; color: #fff; }
1576
+ .devbar-debug button { cursor: pointer; }
1577
+ .devbar-page .t-main { max-width: 72rem; margin: 0 auto; padding: 1rem; }
1578
+ .devbar-facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; }
1579
+ .devbar-facts dd { margin: 0; overflow-wrap: anywhere; }
1580
+ .devbar-session code { overflow-wrap: anywhere; }
1581
+ @media print { .devbar { display: none; } }
1582
+
1583
+ /* (080) Search region: one field, results grouped per search configuration */
1584
+ .search-form { margin-bottom: 1rem; }
1585
+ .search-group + .search-group { margin-top: 1rem; }
1586
+ .search-group h3 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 0 0 0.4rem; }
1587
+ .search-hits { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); }
1588
+ .search-hits li + li { border-top: 1px solid var(--border); }
1589
+ .search-hit { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.6rem 0.8rem; color: inherit; text-decoration: none; }
1590
+ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft); }
1591
+ .search-icon { flex: none; color: var(--accent); }
1592
+ .search-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1; overflow-wrap: anywhere; }
1593
+ .search-title { font-weight: 600; }
1594
+ .search-subtitle, .search-description { color: var(--muted); font-size: 0.875rem; }
1595
+ .search-hit mark { background: #fff3a3; color: inherit; border-radius: 2px; padding: 0 1px; }
1596
+ :root[data-theme="dark"] .search-hit mark { background: #6b5a00; }
1597
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .search-hit mark { background: #6b5a00; } }
1598
+ .search-hit .badge-pill { flex: none; }
1599
+ .search-more, .search-empty { margin: 0.4rem 0 0; }
1600
+
1601
+ /* (084) Feedback: the users' page and the builder's list */
1602
+ .feedback-form .choices { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; }
1603
+ .feedback-list, .feedback-admin { list-style: none; margin: 0; padding: 0; }
1604
+ .feedback-list > li, .feedback-admin > li { border-top: 1px solid var(--border); padding: 0.75rem 0; }
1605
+ .feedback-list > li:first-child, .feedback-admin > li:first-child { border-top: 0; }
1606
+ .feedback-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; overflow-wrap: anywhere; }
1607
+ .feedback-body { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.4rem 0; }
1608
+ .feedback-response { background: var(--surface-2); border-radius: var(--radius); padding: 0.5rem 0.75rem; }
1609
+ .feedback-response p { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.25rem 0 0; }
1610
+
1611
+ /* (085) Session timeout warning */
1612
+ .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
+ .session-dialog::backdrop { background: rgb(0 0 0 / 40%); }
1614
+ .session-dialog p { margin: 0 0 1rem; }