pgkiln 0.84.0 → 0.86.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 (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -11,8 +11,16 @@
11
11
  -- page 6 content patterns: alerts, badges and tags, colour classes,
12
12
  -- text styles, lists, media lists, timelines, comments, avatars,
13
13
  -- metric cards and empty states;
14
- -- page 7 page patterns: a modal dialog (page 8), a drawer (page 9) and a
15
- -- page with a side column (page 10).
14
+ -- page 7 page patterns: a modal dialog (page 8), a drawer (page 9), a
15
+ -- page with a side column (page 10) and an embedded page (a URL
16
+ -- region);
17
+ -- page 11 more item types: autocomplete, multi-select, URL and percent
18
+ -- graph, and people (pages 12 and 13) with a picture uploaded by
19
+ -- an Image Upload item into the table;
20
+ -- page 14 display positions: banner, body, right side column and footer
21
+ -- (page template Right side column);
22
+ -- page 15 button positions: every slot of a region's header and bottom bar;
23
+ -- page 16 a sign-in region on a page with the template Minimal.
16
24
  -- Every page says which template, option or class it uses.
17
25
  --
18
26
  -- Installed by an administrator from the App Gallery, or with
@@ -34,15 +42,19 @@ create table ux_patterns.person (
34
42
  role text not null,
35
43
  status text not null check (status in ('Active', 'Away', 'Left')),
36
44
  joined date not null,
37
- note text
45
+ note text,
46
+ website text check (website ~ '^https?://'),
47
+ photo bytea check (octet_length(photo) <= 2 * 1024 * 1024),
48
+ photo_name text,
49
+ photo_mime text
38
50
  );
39
51
 
40
- insert into ux_patterns.person (name, role, status, joined, note) values
41
- ('Alex Morgan', 'Product owner', 'Active', current_date - 400, 'Sets the priorities of the team.'),
42
- ('Sam Lee', 'Developer', 'Active', current_date - 200, 'Builds the reports.'),
43
- ('Robin Diaz', 'Designer', 'Away', current_date - 90, 'Back next week.'),
44
- ('Kim Novak', 'Tester', 'Active', current_date - 30, 'Writes the browser tests.'),
45
- ('Jo Park', 'Analyst', 'Left', current_date - 700, 'Moved to another team.');
52
+ insert into ux_patterns.person (name, role, status, joined, note, website) values
53
+ ('Alex Morgan', 'Product owner', 'Active', current_date - 400, 'Sets the priorities of the team.', 'https://example.com/alex'),
54
+ ('Sam Lee', 'Developer', 'Active', current_date - 200, 'Builds the reports.', 'https://example.com/sam'),
55
+ ('Robin Diaz', 'Designer', 'Away', current_date - 90, 'Back next week.', null),
56
+ ('Kim Novak', 'Tester', 'Active', current_date - 30, 'Writes the browser tests.', null),
57
+ ('Jo Park', 'Analyst', 'Left', current_date - 700, 'Moved to another team.', null);
46
58
 
47
59
  -- 2. The application's database role ----------------------------------------
48
60
  do $$ begin
@@ -51,6 +63,8 @@ end $$;
51
63
  grant app_ux_patterns to pgkiln_runtime;
52
64
  grant usage on schema ux_patterns to app_ux_patterns;
53
65
  grant select on all tables in schema ux_patterns to app_ux_patterns;
66
+ -- the people form of page 13 edits a person and uploads a picture
67
+ grant insert, update, delete on ux_patterns.person to app_ux_patterns;
54
68
 
55
69
  -- 3. The application ----------------------------------------------------------
56
70
  insert into meta.app (alias, name, home_page, authentication, db_role)
@@ -71,7 +85,11 @@ select a.id, p.no, p.name, p.name, p.parent, p.mode, p.pos, p.size, p.tpl
71
85
  (7, 'Page patterns', 1, 'normal', 'center', 'medium', 'standard'),
72
86
  (8, 'A modal dialog', 7, 'modal', 'center', 'small', 'standard'),
73
87
  (9, 'A drawer', 7, 'modal', 'right', 'medium', 'standard'),
74
- (10, 'A side column', 7, 'normal', 'center', 'medium', 'left_side_column')
88
+ (10, 'A side column', 7, 'normal', 'center', 'medium', 'left_side_column'),
89
+ (11, 'More item types', 1, 'normal', 'center', 'medium', 'standard'),
90
+ (14, 'Display positions', 1, 'normal', 'center', 'medium', 'right_side_column'),
91
+ (15, 'Button positions', 1, 'normal', 'center', 'medium', 'standard'),
92
+ (16, 'Sign-in region', 1, 'normal', 'center', 'medium', 'minimal')
75
93
  ) as p (no, name, parent, mode, pos, size, tpl)
76
94
  where a.alias = 'ux-patterns';
77
95
 
@@ -79,7 +97,9 @@ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
79
97
  select a.id, n.seq, n.label, n.icon, n.page
80
98
  from meta.app a, (values
81
99
  (10, 'Home', 'home', 1), (20, 'Region templates', 'layers', 2), (30, 'Region options', 'sliders', 3), (40, 'Buttons', 'target', 4),
82
- (50, 'Items and forms', 'edit', 5), (60, 'Content patterns', 'grid', 6), (70, 'Page patterns', 'file', 7)
100
+ (50, 'Items and forms', 'edit', 5), (60, 'Content patterns', 'grid', 6), (70, 'Page patterns', 'file', 7),
101
+ (80, 'More item types', 'list-plus', 11), (90, 'Display positions', 'layout-panel-left', 14),
102
+ (100, 'Button positions', 'mouse-pointer-click', 15), (110, 'Sign-in region', 'log-in', 16)
83
103
  ) as n (seq, label, icon, page)
84
104
  where a.alias = 'ux-patterns';
85
105
 
@@ -100,7 +120,11 @@ select a.id, e.list, e.seq, e.label, e.icon, e.page, e.badge, e.description
100
120
  ('UX_TOPICS', 30, 'Buttons', 'target', 4, null, 'Hot and normal, small and large, pill, outline, link, full width, success and danger, a menu and a badge.'),
101
121
  ('UX_TOPICS', 40, 'Items and forms', 'edit', 5, null, 'Every common item type in a form, and the item template options.'),
102
122
  ('UX_TOPICS', 50, 'Content patterns', 'grid', 6, null, 'Alerts, badges, colours, text, lists, media lists, timelines, comments, avatars, metrics and empty states.'),
103
- ('UX_TOPICS', 60, 'Page patterns', 'file', 7, null, 'A modal dialog, a drawer and a page with a side column.'),
123
+ ('UX_TOPICS', 60, 'Page patterns', 'file', 7, null, 'A modal dialog, a drawer, a page with a side column and an embedded page.'),
124
+ ('UX_TOPICS', 70, 'More item types', 'list-plus', 11, null, 'Autocomplete, multi-select, URL, percent graph and a picture uploaded into a table.'),
125
+ ('UX_TOPICS', 80, 'Display positions', 'layout-panel-left', 14, null, 'Banner, body, right side column and footer.'),
126
+ ('UX_TOPICS', 90, 'Button positions', 'mouse-pointer-click', 15, null, 'Create, edit, help and up in the header; previous, close, delete and next in the bottom bar.'),
127
+ ('UX_TOPICS', 100, 'Sign-in region', 'log-in', 16, null, 'The Login region on a page without navigation, as on an own login page.'),
104
128
  ('UX_STEPS', 10, 'Choose', null, 2, null, null),
105
129
  ('UX_STEPS', 20, 'Details', null, 5, null, null),
106
130
  ('UX_STEPS', 30, 'Confirm', null, 7, null, null),
@@ -216,7 +240,7 @@ select pg_temp.page_id('ux-patterns', r.page), r.seq, r.title, r.type, r.cols, r
216
240
 
217
241
  -- 7 Page patterns
218
242
  (7, 5, 'About this page', 'static', 12, 'plain', '{}',
219
- '<p class="lead">A page''s <strong>mode</strong> and <strong>dialog position</strong> make it a modal dialog or a drawer; its <strong>page template</strong> can add a side column. The buttons open examples of each.</p>',
243
+ '<p class="lead">A page''s <strong>mode</strong> and <strong>dialog position</strong> make it a modal dialog or a drawer; its <strong>page template</strong> can add a side column. The buttons open examples of each. Under them a <strong>URL region</strong> (type <code>url</code>) shows the modal dialog page in a sandboxed frame: its source is a URL, here a page of this server (<code>/a/ux-patterns/8</code>).</p>',
220
244
  '{}', null, null),
221
245
  (7, 10, 'Dialogs, drawers and columns', 'static', 12, 'buttons_container', '{}', null, '{}', 'p-buttons', null),
222
246
  (8, 10, 'A modal dialog', 'static', 12, 'plain', '{}',
@@ -230,7 +254,56 @@ select pg_temp.page_id('ux-patterns', r.page), r.seq, r.title, r.type, r.cols, r
230
254
  '{}', null, 'left'),
231
255
  (10, 20, 'The body', 'static', 12, 'standard', '{}',
232
256
  '<p>The regions of the body use the 12-column grid as usual. A faceted search beside its report is the typical use of a side column.</p>',
233
- '{}', null, null)
257
+ '{}', null, null),
258
+ (7, 20, 'An embedded page (URL region)', 'url', 12, 'standard', '{}', '/a/ux-patterns/8',
259
+ '{"height": 240}', null, null),
260
+
261
+ -- 11 More item types
262
+ (11, 5, 'About this page', 'static', 12, 'plain', '{}',
263
+ '<p class="lead">More item types, each named in its label. <strong>Autocomplete</strong> is a text field with suggestions from its list of values (any text may be typed), a <strong>multi-select</strong> returns its values colon separated, a <strong>URL</strong> field opens the phone''s URL keyboard and a <strong>percent graph</strong> shows a number from 0 to 100 as a bar. The people of this catalog (page 12) have a picture: their form (page 13) uploads it with an <strong>Image Upload</strong> item into the table <code>ux_patterns.person</code>.</p>',
264
+ '{}', null, null),
265
+ (11, 10, 'Text with suggestions and choices', 'static', 12, 'standard', '{}', null, '{}', 'm-input', null),
266
+ (11, 20, 'Display only', 'static', 12, 'standard', '{}', null, '{}', 'm-display', null),
267
+
268
+ -- 14 Display positions (page template right_side_column)
269
+ (14, 1, 'Banner (position banner)', 'static', 12, 'standard', '{to-accent,to-compact}',
270
+ '<p>A region with the position <code>banner</code> shows at the top of the page content, above the body: a notice for everyone who opens the page.</p>',
271
+ '{}', null, 'banner'),
272
+ (14, 5, 'About this page', 'static', 12, 'plain', '{}',
273
+ '<p class="lead">A page has <strong>display positions</strong> and each region chooses one with its <strong>Position</strong> (Page Designer → region → Layout). This page has the page template <em>Right side column</em> (<code>right_side_column</code>), so the regions with the position <code>right</code> show in a column beside the body; on a narrow screen under it.</p>',
274
+ '{}', null, null),
275
+ (14, 10, 'The body (position body)', 'static', 12, 'standard', '{}',
276
+ '<p>Regions without a position, or with <code>body</code>, are the body: the 12-column grid of every page.</p>',
277
+ '{}', null, 'body'),
278
+ (14, 20, 'People (body)', 'report', 12, 'standard', '{}',
279
+ E'select name, role, status from ux_patterns.person order by name', '{"searchable": false}', null, null),
280
+ (14, 30, 'Right column (position right)', 'static', 12, 'standard', '{}',
281
+ '<p>This region has the position <code>right</code>: on this page it is the side column. On a page with another template it would show in the body, in sequence.</p>',
282
+ '{}', null, 'right'),
283
+ (14, 40, 'Topics (right)', 'list', 12, 'standard', '{}', null, '{"list": "UX_TOPICS", "template": "links"}', null, 'right'),
284
+ (14, 50, 'Footer (position footer)', 'static', 12, 'plain', '{}',
285
+ '<p class="muted">A region with the position <code>footer</code> comes after the body and the side column: small print, links, a last update.</p>',
286
+ '{}', null, 'footer'),
287
+
288
+ -- 15 Button positions
289
+ (15, 5, 'About this page', 'static', 12, 'plain', '{}',
290
+ '<p class="lead">A region''s button has a <strong>Button position</strong> (Page Designer → button → Layout; <code>slot</code> in the files), as APEX''s button positions. <em>Create, Edit, Change, Copy, Help</em> and <em>Up</em> go into the region''s header after the title; <em>Previous, Close</em> and <em>Delete</em> to the left end of the bottom bar and <em>Next</em> to the right end; <em>Region</em> (or none) with the region''s other buttons. Each button is labelled with its position. Previous and Next go to the neighbouring pages, Up and Close to the home page, Help opens an inline dialog; the others do nothing.</p>',
291
+ '{}', null, null),
292
+ (15, 10, 'Every button position', 'static', 12, 'standard', '{}',
293
+ '<p>The buttons of this region use every position. They apply to the <code>standard</code>, <code>collapsible</code> and <code>plain</code> templates; the other templates place every button where they always do.</p>',
294
+ '{}', 'slots', null),
295
+ (15, 20, 'In a collapsible region', 'static', 12, 'collapsible', '{}',
296
+ '<p>The same positions in a collapsible region: <em>Edit</em> in the header, <em>Previous</em> and <em>Next</em> in the bottom bar.</p>',
297
+ '{}', 'slots-collapsible', null),
298
+ (15, 30, 'Help', 'static', 12, 'inline_dialog', '{}',
299
+ '<p>A button with the position <code>help</code> sits in the header, like APEX''s Help button. This one opens an inline dialog with a dynamic action <em>Open region</em>.</p>',
300
+ '{}', 'slot-help', null),
301
+
302
+ -- 16 Sign-in region (page template minimal)
303
+ (16, 5, 'About this page', 'static', 12, 'plain', '{}',
304
+ '<p class="lead">This page has the page template <em>Minimal (no navigation)</em> (<code>minimal</code>): no header, navigation menu or navigation bar, the title and the regions only, centred. The region below is a <strong>Login</strong> region: the application''s sign-in form, as on an own login page (Application settings → Sign-in methods → Create a login page). It posts to the application''s sign-in, so throttling and every sign-in method work as on the built-in page. You are signed in already: signing in again as demo / demo starts a new session and opens the home page. A wrong password shows the built-in sign-in page with its message, because this page is not the application''s login page.</p>',
305
+ '{}', 'about-login', null),
306
+ (16, 10, 'Sign in', 'login', 12, 'standard', '{}', null, '{}', null, null)
234
307
  ) as r (page, seq, title, type, cols, template, topts, source, config, sid, pos);
235
308
 
236
309
  -- buttons
@@ -262,10 +335,36 @@ select pg_temp.page_id('ux-patterns', b.page), pg_temp.region_id('ux-patterns',
262
335
  (7, 'p-buttons', 30, 'SIDE', 'A page with a side column', 'redirect', false, 10, '{}', null, null)
263
336
  ) as b (page, region, seq, name, label, action, hot, target, topts, menu, badge);
264
337
 
338
+ -- button positions (page 15) and the other new pages' buttons: harmless (navigate, open the help, or nothing)
339
+ insert into meta.button (page_id, region_id, seq, name, label, action, hot, target_page, template_options, slot)
340
+ select pg_temp.page_id('ux-patterns', b.page), pg_temp.region_id('ux-patterns', b.page, b.region), b.seq, b.name, b.label, b.action, b.hot, b.target,
341
+ b.topts::text[], b.slot
342
+ from (values
343
+ (15, 'slots', 10, 'CREATE', 'Create (create)', 'da', true, null, '{}', 'create'),
344
+ (15, 'slots', 20, 'EDIT', 'Edit (edit)', 'da', false, null, '{}', 'edit'),
345
+ (15, 'slots', 30, 'CHANGE', 'Change (change)', 'da', false, null, '{}', 'change'),
346
+ (15, 'slots', 40, 'COPY', 'Copy (copy)', 'da', false, null, '{}', 'copy'),
347
+ (15, 'slots', 50, 'HELP', 'Help (help)', 'da', false, null, '{}', 'help'),
348
+ (15, 'slots', 60, 'UP', 'Up (up)', 'redirect', false, 1, '{}', 'up'),
349
+ (15, 'slots', 70, 'PREVIOUS', 'Previous (previous)', 'redirect', false, 14, '{}', 'previous'),
350
+ (15, 'slots', 80, 'CLOSE', 'Close (close)', 'redirect', false, 1, '{}', 'close'),
351
+ (15, 'slots', 90, 'DELETE', 'Delete (delete)', 'da', false, null, '{to-danger}', 'delete'),
352
+ (15, 'slots', 100, 'NEXT', 'Next (next)', 'redirect', true, 16, '{}', 'next'),
353
+ (15, 'slots', 110, 'IN_REGION', 'With the others (region)', 'da', false, null, '{}', 'region'),
354
+ (15, 'slots-collapsible', 10, 'C_EDIT', 'Edit (edit)', 'da', false, null, '{}', 'edit'),
355
+ (15, 'slots-collapsible', 20, 'C_PREVIOUS', 'Previous (previous)', 'redirect', false, 14, '{}', 'previous'),
356
+ (15, 'slots-collapsible', 30, 'C_NEXT', 'Next (next)', 'redirect', true, 16, '{}', 'next'),
357
+ (11, 'm-display', 10, 'PEOPLE', 'People and their pictures', 'redirect', true, 12, '{}', null),
358
+ (16, 'about-login', 10, 'HOME', 'Back to the catalog', 'redirect', false, 1, '{}', null)
359
+ ) as b (page, region, seq, name, label, action, hot, target, topts, slot);
360
+
265
361
  insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_region_id)
266
362
  select pg_temp.page_id('ux-patterns', 2), d.seq, d.name, 'click', d.button, 'open_region', pg_temp.region_id('ux-patterns', 2, d.region)
267
363
  from (values (10, 'Open the inline dialog', 'OPEN_DIALOG', 'dialog'), (20, 'Open the inline drawer', 'OPEN_DRAWER', 'drawer')) as d (seq, name, button, region);
268
364
 
365
+ insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_region_id)
366
+ select pg_temp.page_id('ux-patterns', 15), 10, 'Open the help', 'click', 'HELP', 'open_region', pg_temp.region_id('ux-patterns', 15, 'slot-help');
367
+
269
368
  -- items
270
369
  insert into meta.item (page_id, region_id, seq, name, label, type, lov, default_value, required, help, config, template_options)
271
370
  select pg_temp.page_id('ux-patterns', 5), pg_temp.region_id('ux-patterns', 5, i.region), i.seq, i.name, i.label, i.type, i.lov, i.def, i.req, i.help,
@@ -293,3 +392,44 @@ select pg_temp.page_id('ux-patterns', 5), pg_temp.region_id('ux-patterns', 5, i.
293
392
  ('f-options', 40, 'P5_BOLD', 'Bold value (to-bold)', 'display', null, 'A bold value', false, null, '{}', '{to-bold}'),
294
393
  ('f-options', 50, 'P5_HIDDEN_LABEL', 'Hidden label (to-hide-label)', 'text', null, 'The label is only for screen readers', false, null, '{}', '{to-hide-label}')
295
394
  ) as i (region, seq, name, label, type, lov, def, req, help, config, topts);
395
+
396
+ -- page 11: more item types
397
+ insert into meta.item (page_id, region_id, seq, name, label, type, lov, default_value, help, config)
398
+ select pg_temp.page_id('ux-patterns', 11), pg_temp.region_id('ux-patterns', 11, i.region), i.seq, i.name, i.label, i.type, i.lov, i.def, i.help, i.config::jsonb
399
+ from (values
400
+ ('m-input', 10, 'P11_ROLE', 'Role (autocomplete)', 'autocomplete', 'select distinct role from ux_patterns.person order by 1', null,
401
+ 'Suggestions from the roles of the people; type anything else as well.', '{}'),
402
+ ('m-input', 20, 'P11_DAYS', 'Days (multiselect)', 'multiselect', 'STATIC:Monday;MON,Tuesday;TUE,Wednesday;WED,Thursday;THU,Friday;FRI', 'MON:WED',
403
+ 'Several values: the item''s value is their return values, colon separated (MON:WED).', '{}'),
404
+ ('m-input', 30, 'P11_WEBSITE', 'Website (url)', 'url', null, 'https://example.com', 'A URL field: the phone shows its URL keyboard.', '{}'),
405
+ ('m-display', 10, 'P11_ACTIVE_PCT', 'People active (percent_graph)', 'percent_graph', null, null,
406
+ 'A computation counts the active people; the bar reads out as the number.', '{}'),
407
+ ('m-display', 20, 'P11_DONE_PCT', 'A full bar (percent_graph, 100)', 'percent_graph', null, null, null, '{}')
408
+ ) as i (region, seq, name, label, type, lov, def, help, config);
409
+
410
+ insert into meta.computation (page_id, seq, item_name, point, type, expression)
411
+ values (pg_temp.page_id('ux-patterns', 11), 10, 'P11_ACTIVE_PCT', 'before_header', 'sql_query',
412
+ E'select round(100.0 * count(*) filter (where status = ''Active'') / greatest(count(*), 1)) from ux_patterns.person'),
413
+ (pg_temp.page_id('ux-patterns', 11), 20, 'P11_DONE_PCT', 'before_header', 'static', '100');
414
+
415
+ -- pages 12 and 13: the people, with a picture (Report and form wizard)
416
+ select meta.generate_page('ux-patterns', 'report_form', 'ux_patterns.person', 12, '{"label": "People", "form_page": 13, "nav": false}');
417
+ update meta.page set parent_page = 11 where id = pg_temp.page_id('ux-patterns', 12);
418
+ insert into meta.region (page_id, seq, title, type, columns, template, source)
419
+ values (pg_temp.page_id('ux-patterns', 12), 1, 'About this page', 'static', 12, 'plain',
420
+ '<p class="lead">Made with the <em>Report and form</em> wizard (<code>meta.generate_page</code>) on <code>ux_patterns.person</code>. Open a person: the form has a <strong>Picture</strong> item of type <code>image_upload</code> that crops the picture to a square in the browser and saves it into the column <code>photo</code> (with its name and type in <code>photo_name</code> and <code>photo_mime</code>), a <strong>Role</strong> with <code>autocomplete</code> and a <strong>Website</strong> of type <code>url</code>.</p>');
421
+ update meta.region set source = 'select id, name, role, status, website, photo_name from ux_patterns.person order by name',
422
+ config = config || '{"headings": {"photo_name": "Picture"}}'
423
+ where page_id = pg_temp.page_id('ux-patterns', 12) and type = 'report';
424
+ delete from meta.item where page_id = pg_temp.page_id('ux-patterns', 13) and name in ('P13_PHOTO_NAME', 'P13_PHOTO_MIME');
425
+ update meta.item set type = 'image_upload', label = 'Picture (image_upload)',
426
+ help = 'Image Upload: a picture only, cropped to a square and made smaller in the browser before it is saved into the column photo.',
427
+ config = '{"filename_column": "photo_name", "mime_column": "photo_mime", "accept": "image/png,image/jpeg,image/webp", "max_mb": 2,
428
+ "crop": "1:1", "max_px": 600}'
429
+ where page_id = pg_temp.page_id('ux-patterns', 13) and name = 'P13_PHOTO';
430
+ update meta.item set type = 'autocomplete', label = 'Role (autocomplete)', lov = 'select distinct role from ux_patterns.person order by 1'
431
+ where page_id = pg_temp.page_id('ux-patterns', 13) and name = 'P13_ROLE';
432
+ update meta.item set type = 'select', lov = 'STATIC:Active,Away,Left'
433
+ where page_id = pg_temp.page_id('ux-patterns', 13) and name = 'P13_STATUS';
434
+ update meta.item set type = 'url', label = 'Website (url)', help = 'Starts with https://.'
435
+ where page_id = pg_temp.page_id('ux-patterns', 13) and name = 'P13_WEBSITE';
@@ -44,6 +44,8 @@ Then open http://127.0.0.1:3100/a/hr and sign in as `king`, `blake`, `jones`, `a
44
44
  | `hr_68_component_details.sql` | *Component details* (page 47): a popup LOV over 6,000 rows that loads more as the dialog scrolls, a wait popup around a two-second process (*Recalculate*) and around an Ajax call (*Check in the background*), and an *Actions* menu whose entries run dynamic actions (event `menu_entry`) |
45
45
  | `hr_71_fulltext_search.sql` | Full-text search: `hr.policy` gets a generated `search_vector` column (title weight A, body B, english) with a GIN index, and the search configuration `POLICIES` (search type *fulltext*, *Did you mean* when `pg_trgm` is installed) joins page 42 *Search*: "works home" finds *Working from home*, the matched words highlighted |
46
46
  | `hr_72_vector_search.sql` | Search by meaning: `hr.policy` gets *Relocation leave* and a managers-only *Salary bands* with row level security (`audience`); with pgvector, the vector provider `POLICY_EMBEDDINGS` (a stand-in function, `hr.policy_embedding`, eight topics), `hr.policy.embedding vector(8)` with an HNSW index and a trigger, the hybrid search configuration `POLICIES_MEANING` on page 42 (*time off for a family move* finds *Relocation leave*), and *Policy questions* (page 49): the agent `POLICY_AGENT` with the Retrieve tool `policy_search`. Needs an AI service named `HR_ASSISTANT` for page 49 |
47
+ | `hr_80_notifications.sql` | The notification bell and live badges: HR's own notifications (`hr.notification`: leave requested and decided, workflow, automation and task messages) also go on the bell through `meta.notify`, a leave notification opening the request (page 7); *Mark all read* clears the bell too; badges from SQL (`badge_query`) on the navigation bar (*Leave requests* pending, *My tasks* you can act on), the *Pending requests* shortcut and the *Mark all read* button, refreshed every 30 seconds without a reload; a `timer` dynamic action refreshes *My notifications*. Sign in as jones, and as scott in another browser: ask for leave and watch jones's bell |
48
+ | `hr_81_vector_search_type.sql` | *Policies by meaning only* on page 42: the search type `vector` next to part 72's hybrid one (only with pgvector) |
47
49
 
48
50
  The files run in order, each once (recorded in `public.pgkiln_seed`), so new ones can be added
49
51
  later. pgkiln's own tests use this application as their fixture (`npm test` installs it first).
@@ -0,0 +1,125 @@
1
+ -- =====================================================================
2
+ -- HR example, part 80: the notification bell and live badges (migration 154)
3
+ --
4
+ -- HR has had its own notifications since hr.sql: rows in hr.notification
5
+ -- (leave requested and decided, the workflows' and automations' messages),
6
+ -- listed under "My notifications" on the home page. This part puts them on
7
+ -- pgkiln's notification bell as well:
8
+ --
9
+ -- * Settings → User Interface: Notifications on, Live badges every 30 s
10
+ -- * hr.notification gets the page a notification opens (page, items) and
11
+ -- an icon; a trigger hands every new row to meta.notify(), so the bell
12
+ -- shows it and opening it goes to that page (a leave request opens the
13
+ -- request on page 7, the link signed for the recipient)
14
+ -- * "Mark all read" on the home page also marks the bell's notifications read
15
+ -- * badges from SQL (badge_query), refreshed in place by the live badges:
16
+ -- the navigation bar's "Leave requests" (pending requests you can see),
17
+ -- "My tasks" (tasks you can act on), the Shortcuts list's "Pending
18
+ -- requests" and the "Mark all read" button (your unread notifications)
19
+ -- * a timer dynamic action refreshes "My notifications" every 30 seconds
20
+ --
21
+ -- Sign in as jones, open the home page, and in another browser sign in as
22
+ -- scott (a clerk of jones's department) and ask for leave: jones's bell and
23
+ -- badges change without a reload.
24
+ -- =====================================================================
25
+
26
+ update meta.app set notifications = true, live_badges_s = 30 where alias = 'hr';
27
+
28
+ -- the page a notification opens, and its icon
29
+ alter table hr.notification add column page int, add column items jsonb, add column icon text;
30
+
31
+ -- the leave notifications open the request (page 7)
32
+ create or replace function hr.notify_leave_requested() returns trigger
33
+ language plpgsql security definer set search_path = hr, pg_catalog as $$
34
+ begin
35
+ insert into hr.notification (username, message, page, items, icon)
36
+ select m.username, format('%s requested %s day(s) of leave from %s.',
37
+ e.ename, new.days, to_char(new.start_date, 'DD Mon YYYY')),
38
+ 7, jsonb_build_object('P7_ID', new.id), 'calendar'
39
+ from hr.emp e join hr.emp m on m.empno = e.mgr
40
+ where e.empno = new.empno and m.username is not null;
41
+ return new;
42
+ end
43
+ $$;
44
+
45
+ create or replace function hr.notify_leave_decided() returns trigger
46
+ language plpgsql security definer set search_path = hr, pg_catalog as $$
47
+ begin
48
+ if new.status in ('APPROVED', 'REJECTED') then
49
+ insert into hr.notification (username, message, page, items, icon)
50
+ select e.username, format('Your leave from %s to %s was %s by %s.',
51
+ to_char(new.start_date, 'DD Mon'), to_char(new.end_date, 'DD Mon'),
52
+ lower(new.status), new.decided_by),
53
+ 7, jsonb_build_object('P7_ID', new.id), case new.status when 'APPROVED' then 'check' else 'x' end
54
+ from hr.emp e
55
+ where e.empno = new.empno and e.username is not null;
56
+ end if;
57
+ return new;
58
+ end
59
+ $$;
60
+
61
+ -- Every new hr.notification also goes on the bell. Only from the running
62
+ -- application (meta.app_id() is HR, notifications on); a row added through
63
+ -- the API or SQL Workshop stays in hr.notification only. The bell never
64
+ -- stops the change that wrote the notification.
65
+ create function hr.notification_to_bell() returns trigger
66
+ language plpgsql security definer set search_path = hr, pg_catalog as $$
67
+ begin
68
+ if exists (select 1 from meta.app a where a.id = meta.app_id() and a.alias = 'hr' and a.notifications) then
69
+ perform meta.notify(
70
+ p_user => new.username,
71
+ p_title => left(new.message, 200),
72
+ p_page => new.page,
73
+ p_items => coalesce(new.items, '{}'::jsonb),
74
+ p_icon => new.icon);
75
+ end if;
76
+ return new;
77
+ exception when others then
78
+ raise warning 'hr.notification_to_bell: %', sqlerrm;
79
+ return new;
80
+ end
81
+ $$;
82
+ revoke execute on function hr.notification_to_bell() from public;
83
+
84
+ create trigger notification_to_bell after insert on hr.notification
85
+ for each row execute function hr.notification_to_bell();
86
+
87
+ -- "Mark all read" on the home page: the bell's notifications too
88
+ insert into meta.process (page_id, seq, name, type, code, when_button)
89
+ select p.id, 20, 'Mark the bell read', 'sql', 'select meta.mark_notifications_read()', 'MARK_READ'
90
+ from meta.page p join meta.app a on a.id = p.app_id where a.alias = 'hr' and p.page_no = 1;
91
+
92
+ -- the button shows how many of your notifications are unread
93
+ update meta.button b set badge_query = 'select nullif(count(*), 0) from hr.notification where read_at is null'
94
+ from meta.page p join meta.app a on a.id = p.app_id
95
+ where b.page_id = p.id and a.alias = 'hr' and p.page_no = 1 and b.name = 'MARK_READ';
96
+
97
+ -- badges from SQL in the navigation bar and the Shortcuts list (row level
98
+ -- security applies: a manager counts their department's requests, an
99
+ -- employee their own)
100
+ insert into meta.list_entry (app_id, list_name, seq, label, icon, target_page, description, badge_query)
101
+ select a.id, 'HR_NAVBAR', e.seq, e.label, e.icon, e.page, e.description, e.badge_query
102
+ from meta.app a, (values
103
+ (20, 'Leave requests', 'calendar', 6, 'Pending leave requests you can see',
104
+ 'select nullif(count(*), 0) from hr.leave_request where status = ''PENDING'''),
105
+ (30, 'My tasks', 'check', 14, 'Tasks you can act on',
106
+ 'select nullif(count(*), 0) from meta.tasks where app_id = meta.app_id() and may_act and state in (''unassigned'', ''assigned'')')
107
+ ) as e (seq, label, icon, page, description, badge_query)
108
+ where a.alias = 'hr';
109
+
110
+ update meta.list_entry l set badge_query = 'select count(*) from hr.leave_request where status = ''PENDING'''
111
+ from meta.app a
112
+ where l.app_id = a.id and a.alias = 'hr' and l.list_name = 'HR_SHORTCUTS' and l.label = 'Pending requests';
113
+
114
+ -- a timer dynamic action: "My notifications" refreshes every 30 seconds while the page is visible
115
+ insert into meta.dynamic_action (page_id, seq, name, event, trigger_element, action, affected_region_id)
116
+ select p.id, 50, 'Refresh my notifications', 'timer', '30', 'refresh_region', r.id
117
+ from meta.page p join meta.app a on a.id = p.app_id join meta.region r on r.page_id = p.id and r.title = 'My notifications'
118
+ where a.alias = 'hr' and p.page_no = 1;
119
+
120
+ -- Dutch labels for the new navigation bar entries (part 4's translations)
121
+ insert into meta.translation (app_id, language, source, target)
122
+ select a.id, 'nl', t.src, t.nl
123
+ from meta.app a, (values ('Leave requests', 'Verlofaanvragen'), ('My tasks', 'Mijn taken')) as t (src, nl)
124
+ where a.alias = 'hr'
125
+ on conflict do nothing;
@@ -0,0 +1,33 @@
1
+ -- =====================================================================
2
+ -- HR example, part 81: search by meaning alone (search type vector)
3
+ --
4
+ -- Part 72 added POLICIES_MEANING, a hybrid search (full-text and vector
5
+ -- similarity combined). This part adds POLICIES_VECTOR to the same search
6
+ -- page (42): the vector similarity alone, so the two can be compared ("time
7
+ -- off for a family move" finds Relocation leave in both; a word that only
8
+ -- full-text matches finds nothing here). Only with pgvector, like part 72.
9
+ -- =====================================================================
10
+
11
+ do $$
12
+ begin
13
+ if not exists (select from information_schema.columns where table_schema = 'hr' and table_name = 'policy' and column_name = 'embedding') then
14
+ raise notice 'HR part 81: hr.policy has no vector column (pgvector is not installed): no vector search.';
15
+ return;
16
+ end if;
17
+ insert into meta.search_config (app_id, name, label, seq, source_type, source, search_type,
18
+ vector_provider, vector_column, key_column, distance, min_similarity,
19
+ title_column, description_column, icon, max_results)
20
+ select id, 'POLICIES_VECTOR', 'Policies by meaning only', 27, 'table', 'hr.policy', 'vector',
21
+ 'POLICY_EMBEDDINGS', 'embedding', 'id', 'cosine', 0.6,
22
+ 'title', 'body', 'sparkles', 3
23
+ from meta.app where alias = 'hr';
24
+ update meta.region r
25
+ set config = jsonb_set(r.config, '{configs}', (r.config->'configs') || '["POLICIES_VECTOR"]'::jsonb)
26
+ from meta.page p join meta.app a on a.id = p.app_id
27
+ where r.page_id = p.id and a.alias = 'hr' and p.page_no = 42 and r.type = 'search';
28
+ end $$;
29
+
30
+ insert into meta.translation (app_id, language, source, target)
31
+ select a.id, 'nl', 'Policies by meaning only', 'Regelingen alleen op betekenis'
32
+ from meta.app a where a.alias = 'hr'
33
+ on conflict do nothing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.84.0",
3
+ "version": "0.86.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
@@ -426,6 +426,12 @@ dialog.region-inline_drawer:target { inset: 0 0 0 auto; }
426
426
  .region-collapsible summary.region-header { cursor: pointer; list-style: none; }
427
427
  .region-collapsible summary.region-header::-webkit-details-marker { display: none; }
428
428
  .region-collapsible details:not([open]) summary.region-header { border-bottom: 0; }
429
+ /* (166) header buttons of a collapsible region: over the header row, outside the summary (the toggle) */
430
+ .region-collapsible.has-header-buttons { position: relative; }
431
+ .region-collapsible > .region-header-buttons { position: absolute; top: 0; right: 1rem; z-index: 1; max-width: calc(min(50%, 22rem) - 1rem); min-height: 50px; display: flex; align-items: center; justify-content: flex-end; }
432
+ .region-collapsible > .region-header-buttons .buttons { margin: 0; flex-wrap: wrap; justify-content: flex-end; }
433
+ .region-collapsible.has-header-buttons > details > summary.region-header { padding-right: min(50%, 22rem); }
434
+ .region.to-compact > .region-header-buttons { min-height: 0; top: 0.35rem; }
429
435
  .region-body { padding: 1rem; }
430
436
  .region-footer { display: flex; padding: 0.75rem 1rem; border-top: 1px solid var(--border); background: var(--surface-2); border-radius: 0 0 var(--radius) var(--radius); }
431
437
  .region-plain > .region-footer { background: none; padding: 1rem 0 0; }
@@ -1815,7 +1821,8 @@ td.tc-cell { overflow-wrap: anywhere; }
1815
1821
  .t-navbar a[aria-current="page"] { background: rgb(255 255 255 / 16%); }
1816
1822
  .t-navbar-menu a { display: flex; align-items: center; gap: 0.45rem; padding: 0.4rem 0.9rem; }
1817
1823
  @media (max-width: 1024px) {
1818
- .t-navbar .nb-icon > span:not(.badge) { display: none; }
1824
+ /* (166) visually hidden, still the link's name: an aria-label would leave out the badge (label-content-name-mismatch) */
1825
+ .t-navbar .nb-icon > span:not(.badge) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1819
1826
  }
1820
1827
  @media (max-width: 640px) {
1821
1828
  .list-badges { grid-template-columns: repeat(2, minmax(0, 1fr)); }
package/public/app.js CHANGED
@@ -595,9 +595,16 @@ const pgkilnUnsaved = (() => {
595
595
  // (156) stops the actions after it; the triggering event's own effect is stopped by its handler (cancels())
596
596
  case 'cancel_event':
597
597
  return STOP;
598
- case 'set_style':
599
- for (const el of targets(da)) for (const [k, v] of Object.entries(da.styles || {})) el.style.setProperty(k, v);
598
+ case 'set_style': {
599
+ // (166) an item's own field (input, select, text area), as in APEX; its wrapper only when it has none
600
+ const field = (n) => {
601
+ const f = document.getElementById(n);
602
+ return f && f.matches('input, select, textarea') ? f : wrapperOf(n);
603
+ };
604
+ const els = [...da.items.map(field).filter(Boolean), ...(da.region ? [document.getElementById(`R${da.region}`)].filter(Boolean) : [])];
605
+ for (const el of els) for (const [k, v] of Object.entries(da.styles || {})) el.style.setProperty(k, v);
600
606
  return;
607
+ }
601
608
  case 'collapse_tree':
602
609
  case 'expand_tree':
603
610
  for (const el of targets(da)) el.querySelectorAll('.tree-view details').forEach((d) => (d.open = da.action === 'expand_tree'));
@@ -615,7 +622,12 @@ const pgkilnUnsaved = (() => {
615
622
  }
616
623
  return;
617
624
  case 'trigger_geocoding':
618
- for (const n of da.items) document.querySelector(`[data-geocode="${CSS.escape(n)}"]`)?.click();
625
+ for (const n of da.items) {
626
+ // (166) not for the address just chosen from the list (a change action on the address item itself would reopen it)
627
+ const field = document.getElementById(n);
628
+ if (field && field.dataset.geocoded !== undefined && field.value === field.dataset.geocoded) continue;
629
+ document.querySelector(`[data-geocode="${CSS.escape(n)}"]`)?.click();
630
+ }
619
631
  return;
620
632
  case 'open_report_dialog':
621
633
  for (const el of targets(da)) {
@@ -1015,12 +1027,17 @@ const pgkilnUnsaved = (() => {
1015
1027
  e.stopImmediatePropagation();
1016
1028
  const submitter = e.submitter;
1017
1029
  if (!(await runChain(run))) return;
1018
- submitting = true;
1019
- try {
1020
- form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
1021
- } finally {
1022
- submitting = false;
1023
- }
1030
+ // (166) in a new task: for a submit the user started (a button, Enter) the browser is still firing
1031
+ // this submit event when the actions end, and ignores a requestSubmit made then. requestSubmit
1032
+ // dispatches its submit event synchronously, so the flag lets exactly that one through.
1033
+ setTimeout(() => {
1034
+ submitting = true;
1035
+ try {
1036
+ form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
1037
+ } finally {
1038
+ submitting = false;
1039
+ }
1040
+ });
1024
1041
  }, true);
1025
1042
 
1026
1043
  // ------------------------------------------------------------ cascading lists of values
@@ -3082,6 +3099,8 @@ const liveBadges = (() => {
3082
3099
  b.textContent = p.label;
3083
3100
  b.addEventListener('click', () => {
3084
3101
  input.value = p.label;
3102
+ // (166) the address chosen: trigger_geocoding on this item's change doesn't look it up again
3103
+ input.dataset.geocoded = p.label;
3085
3104
  input.dispatchEvent(new Event('change', { bubbles: true }));
3086
3105
  const target = btn.dataset.coords && document.getElementById(btn.dataset.coords);
3087
3106
  if (target) {
package/src/app.ts CHANGED
@@ -106,6 +106,7 @@ import { runtimeScope } from './db.ts';
106
106
  import { startTelemetry } from './telemetry/export.ts';
107
107
  import { telemetryHooks, telemetryRoutes } from './telemetry/http.ts';
108
108
  import { telemetryBuilderRoutes } from './builder/telemetry.ts';
109
+ import { inRequestScope } from './builder/i18n.ts';
109
110
 
110
111
  export async function buildApp(opts: { logger?: boolean } = {}) {
111
112
  // a database older than the code: apply the migrations (MIGRATE_ON_START=true)
@@ -209,6 +210,14 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
209
210
  // the page's URL carries the package version, so the file never changes under it
210
211
  return reply.header('cache-control', 'public, max-age=31536000, immutable').type('image/svg+xml').send(svg);
211
212
  });
213
+ // (sprint 164) builder handlers run in a request scope: scopedTx (src/builder/i18n.ts) takes the developer's language from it
214
+ app.addHook('onRoute', (opts) => {
215
+ if (!opts.url.startsWith('/builder')) return;
216
+ const handler = opts.handler;
217
+ opts.handler = function (this: unknown, req, reply) {
218
+ return inRequestScope(req, () => (handler as (this: unknown, ...a: unknown[]) => unknown).call(this, req, reply));
219
+ } as typeof opts.handler;
220
+ });
212
221
  await app.register(runtimeRoutes);
213
222
  await app.register(accountRoutes);
214
223
  await app.register(notificationRoutes);