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
package/README.md CHANGED
@@ -17,7 +17,7 @@ them.
17
17
  > pgkiln is an independent open source project and is not affiliated with Oracle. Oracle and APEX are trademarks of Oracle.
18
18
 
19
19
  <p align="center">
20
- <img src="docs/images/builder-page-designer.png" alt="The page designer: component tree, layout and property editor" width="100%">
20
+ <img src="https://raw.githubusercontent.com/pgkiln/pgkiln/main/docs/images/builder-page-designer.png" alt="The page designer: component tree, layout and property editor" width="100%">
21
21
  </p>
22
22
 
23
23
  ## Screenshots
@@ -37,7 +37,7 @@ screenshots are made by `npm run screenshots`.
37
37
  | **App Builder** home | **SQL Workshop**: object browser with RLS and grants |
38
38
 
39
39
  <p align="center">
40
- <img src="docs/images/app-phone.png" alt="The dashboard on a phone" width="260"><br>
40
+ <img src="https://raw.githubusercontent.com/pgkiln/pgkiln/main/docs/images/app-phone.png" alt="The dashboard on a phone" width="260"><br>
41
41
  <em>Responsive on phones and tablets, and installable as a Progressive Web App</em>
42
42
  </p>
43
43
 
package/SECURITY.md CHANGED
@@ -76,6 +76,16 @@ first version (MVP). Every finding below has a regression test in
76
76
  | Plug-ins (0.31.0) | Only developers (session, CSRF, workspace guard, app locks) import, remove and install plug-ins; `meta.import_plugin` is not executable by the runtime role, which can only read `meta.plugin`. Plug-in files are checked before anything is stored: type, names, static file names and types, base64 contents, attributes, a region's template against the template allow-list, a process function as a `schema.function` identifier (checked again by the database). Install SQL never runs on import: a developer reads and runs it as the application's role, in one transaction. Pages get only a plug-in's name and attribute values (escaped JSON in `data-plugin-attrs`, or in the page's JSON), and only for a plug-in of the matching type; its JavaScript comes from same-origin static files. A plug-in's code runs with the application's rights: install only what you trust. |
77
77
  | Zips and parsing in SQL (0.31.0) | `meta.unpacked_file`/`meta.unpacked_entry` are closed to applications; the functions that read them are security definer and only return entries of content the caller already holds (looked up by its SHA-256). The server unpacks only zip/xlsx it receives, at most 2,000 files and 200 MB (declared sizes checked before inflating, actual sizes after), kept 24 hours. Zip entry names are relative paths (no `..`, no leading `/`). XML with a DTD or entity declarations is refused; a row selector is an element name or path, never XPath. |
78
78
  | Object storage (0.31.0) | Bucket requests go through the web client: allow-list, private-address checks at connect time, no secrets on cross-origin redirects; the credential (type `aws_sigv4`, secret encrypted) must be valid for the bucket URL and is used only for object storage. Keys are generated by the server (`<prefix><uuid>/<cleaned name>`); prefixes can't contain `..`. Downloads keep the checksum, page access and RLS checks (the row is read as the app's role before the object is fetched) and the sandboxing headers; users never see the bucket or credential. |
79
+ | Lost update detection (sprint 50) | `meta.row_version()` runs with the caller's rights (the app's role: grants and RLS apply); the key is a typed parameter and column names are quoted identifiers. A posted row version that is not an MD5 hex string is ignored; changing it only weakens the user's own protection. |
80
+ | URL region (sprint 50) | The origin must be written out in the source (https, http only for localhost, or a path on this server; no user:password, no substitution before the path); `&ITEM.` values are URL-encoded and the result is checked to have the same origin. The CSP gets `frame-src` for exactly the origins of the page's visible URL regions; the frame is sandboxed (`allow-top-navigation` is never offered) with `referrerpolicy=strict-origin-when-cross-origin`. |
81
+ | Search region (sprint 50) | The search text is a query parameter (`ILIKE … ESCAPE`, wildcards escaped, at most 8 words of 200 characters); configuration column names are checked and quoted, table sources resolved with `regclass`; queries run as the app's role in savepoints. Only the application's own configurations count; results link with checksums; list sources show only entries the user may see. The region endpoint serves search regions only after page access and the region's checks. |
82
+ | Developer toolbar (sprint 50) | Shown only when the cookie `pgkiln_devbar` (path `/a`, HttpOnly, SameSite=Lax; a random token whose SHA-256 is on the builder session, separate from the builder's token) belongs to a live builder session of a developer who may open the application (administrator or workspace member); otherwise its routes are 404. Its POSTs need the app session's CSRF token. The viewer never shows password items. Debug per session is set only by the toolbar (`__DEBUG_LEVEL` can't be posted or put in a URL), and `meta.debug_save` stores a session's log only for a known level of a session of that application. |
83
+ | E-mail (sprint 50) | The mail tables and the internal functions are closed to application and runtime roles. `meta.send_mail` and friends act for `meta.app_id()` only when the caller's role (`current_setting('role')`, unchanged by SECURITY DEFINER) is that application's database role, so application code can't send in another application's name or after `RESET ROLE`. Addresses are plain addresses (no CR/LF, no display names), the subject one line, at most 50 recipients, bodies 1 MB, attachments 20 × 10 MB per mail and only for a mail queued in the same transaction; From must be one of the service's allowed senders; a daily limit per application. Template placeholders are filled in one pass and HTML-escaped unless `!RAW`. Service secrets are AES-256-GCM encrypted with `PGKILN_SECRET_KEY`, write-only, never exported, logged or put in error messages; only administrators manage services. nodemailer runs with file and URL access off. |
84
+ | Checked imports (sprint 51) | The Advisor runs in the import's transaction inside a savepoint that is rolled back (its `SET LOCAL ROLE` and settings with it): SQL is only planned (`EXPLAIN` without ANALYZE), PL/pgSQL blocks compiled into a `pg_temp` function, nothing is executed. A refused import rolls back entirely, its backup included. |
85
+ | Backups (sprint 51) | `meta.app_backup` and `meta.backup_app` are closed to the runtime and application roles; the builder serves a backup only under its own application's URL (developer session, CSRF for restore and delete, the application's lock and Run only respected for a restore). A backup is an export: it holds no secrets. |
86
+ | Availability and Run only (sprint 51) | Checked on every page request, file download, REST module call and, through `meta.api_check`, every PostgREST request; "developers only" needs the developer toolbar's token of a live builder session (never an API token); restricted compares user names case-insensitively. Run only is enforced on the server for every builder POST of the application (423 for JSON), except its availability, backups, locks, comments and feedback answers. |
87
+ | Feedback (sprint 51) | Needs the application's setting, the page's access rules and the session's CSRF token; at most 10 per session and hour, 4000 characters, escaped everywhere; users see only their own feedback; the runtime role may only insert and read `meta.feedback`; answers need a developer session and are matched to the application in the URL. |
88
+ | Page behaviour (sprint 52) | Ajax callbacks need the CSRF token and page access, run only processes with point *ajax* of this page or application (SQL only) with their authorization, condition and build option, as the app's role; posted values set only items the user may change, `x01`–`x10` are binds `:X01`–`:X10` and can't replace the built-in binds; at most 1000 rows come back. The download action runs only the download process its own (visible) dynamic action names. The error handling function runs as the app's role in its own transaction with a 2 s limit; its failure keeps the original message. *Stay signed in* needs that application's signed-in session and its CSRF token. |
79
89
  | Session state protection | Item values in URLs, and the row keys of interactive grids, carry an HMAC checksum bound to app, page and user. Hidden, display, read-only and unauthorized items cannot be set by a form post. |
80
90
  | Request integrity | A CSRF token on every POST (pages, AJAX, login, logout). Buttons are re-validated on submit. |
81
91
  | Output | Auto-escaping HTML templates, strict CSP (no inline scripts, no inline styles: the page's one `<style>` carries a per-response nonce), `X-Frame-Options`, `nosniff`, `no-store`. Images may also come from the map tile server (`MAP_TILE_URL`, an http(s) origin only); map data travels as JSON in a `<script type="application/json">` element with `<` escaped. A map area in a report URL (`r<id>_bb`) is parsed into four range-checked numbers before it reaches SQL; anything else is ignored. The browser fetches tiles from that server directly, so it sees users' IP addresses, map areas and the site's origin (sent as `Referer`, which OpenStreetMap requires; never the page's path or query): self-host tiles where that matters. |
@@ -0,0 +1,50 @@
1
+ -- =====================================================================
2
+ -- 076: lost update detection for forms (APEX: "Prevent Lost Updates" and
3
+ -- "Lock Row" of the Form - Automatic Row Processing (DML) process)
4
+ --
5
+ -- When a form region fetches its row, the page also gets a checksum of the
6
+ -- row's form columns (meta.row_version). An update or delete through the
7
+ -- form_dml process locks the row (FOR UPDATE), computes the checksum again
8
+ -- and refuses the save when it differs: someone else changed the row since
9
+ -- the page was shown.
10
+ --
11
+ -- The checksum is computed under fixed settings (UTC, ISO dates, all float
12
+ -- digits), so it does not depend on the user's time zone or the session's
13
+ -- formats. The function runs with the caller's rights: the application's
14
+ -- role, its grants and its row level security policies.
15
+ -- =====================================================================
16
+
17
+ create function meta.row_version(p_table regclass, p_pk_column text, p_pk text, p_columns text[], p_lock boolean default false)
18
+ returns text
19
+ language plpgsql
20
+ set timezone = 'UTC'
21
+ set datestyle = 'ISO, YMD'
22
+ set intervalstyle = 'iso_8601'
23
+ set extra_float_digits = 3
24
+ set search_path = pg_catalog
25
+ as $$
26
+ declare
27
+ v_cols text;
28
+ v_type text;
29
+ v_out text;
30
+ begin
31
+ -- the key's own type, so the lookup uses the primary key's index
32
+ select format_type(a.atttypid, a.atttypmod) into v_type
33
+ from pg_attribute a where a.attrelid = p_table and a.attname = p_pk_column and a.attnum > 0 and not a.attisdropped;
34
+ if v_type is null then
35
+ raise exception 'column % does not exist in %', p_pk_column, p_table;
36
+ end if;
37
+ -- each column as text in the row, in the given order
38
+ select string_agg(format('%I::text', c), ', ' order by o) into v_cols
39
+ from unnest(p_columns) with ordinality as x(c, o);
40
+ execute format('select md5(row(%s)::text) from %s where %I = $1::%s%s',
41
+ coalesce(v_cols, format('%I::text', p_pk_column)), p_table, p_pk_column, v_type,
42
+ case when p_lock then ' for update' else '' end)
43
+ into v_out using p_pk;
44
+ return v_out;
45
+ end
46
+ $$;
47
+ grant execute on function meta.row_version(regclass, text, text, text[], boolean) to public;
48
+
49
+ comment on function meta.row_version(regclass, text, text, text[], boolean) is
50
+ 'A checksum of a row''s columns (lost update detection of forms, 076); NULL when the row is not found or not visible';
@@ -0,0 +1,41 @@
1
+ -- =====================================================================
2
+ -- 077: warn on unsaved changes (APEX: the page attribute "Warn on Unsaved
3
+ -- Changes" and the item attribute "Warn on Unsaved Changes: Ignore")
4
+ --
5
+ -- With meta.page.warn_unsaved the browser asks before the user leaves the
6
+ -- page (a link, a redirect button, closing or reloading the tab) while an
7
+ -- item of the page form has a changed value. Submitting the page doesn't
8
+ -- ask. An item is left out with {"warn_unsaved": false} in its attributes.
9
+ -- Off for existing pages; the create page wizards switch it on for forms.
10
+ -- =====================================================================
11
+
12
+ alter table meta.page add column warn_unsaved boolean not null default false;
13
+
14
+ -- an export made before 077 has no warn_unsaved: NULL becomes false
15
+ create function meta.page_warn_unsaved_default() returns trigger
16
+ language plpgsql set search_path = pg_catalog as $$
17
+ begin
18
+ new.warn_unsaved := coalesce(new.warn_unsaved, false);
19
+ return new;
20
+ end $$;
21
+ create trigger page_warn_unsaved before insert or update on meta.page
22
+ for each row execute function meta.page_warn_unsaved_default();
23
+
24
+ comment on column meta.page.warn_unsaved is 'Ask before leaving the page while items have unsaved changes (077)';
25
+
26
+ -- The create page wizards switch it on for the form pages they make (APEX's default
27
+ -- for new pages): meta.wizard_form (047) is wrapped.
28
+ alter function meta.wizard_form(meta.app, regclass, int, text, int, boolean, text[]) rename to wizard_form_047;
29
+ create function meta.wizard_form(
30
+ p_app meta.app, p_table regclass, p_page int, p_label text, p_return int, p_modal boolean, p_columns text[] default null
31
+ ) returns int
32
+ language plpgsql as $$
33
+ declare
34
+ v_page int := meta.wizard_form_047(p_app, p_table, p_page, p_label, p_return, p_modal, p_columns);
35
+ begin
36
+ update meta.page set warn_unsaved = true where id = v_page;
37
+ return v_page;
38
+ end
39
+ $$;
40
+ -- like the original (047): for the builder only, not for applications
41
+ revoke all on function meta.wizard_form(meta.app, regclass, int, text, int, boolean, text[]) from public;
@@ -0,0 +1,12 @@
1
+ -- =====================================================================
2
+ -- 078: the URL region (APEX: URL region): another site's page in the
3
+ -- region, in a sandboxed iframe. The source is the URL; its origin is
4
+ -- literal (an item can't choose the host), and the page's
5
+ -- Content-Security-Policy frames exactly the origins of its URL regions
6
+ -- (src/runtime/url-region.ts).
7
+ -- =====================================================================
8
+
9
+ alter table meta.region drop constraint region_type_check;
10
+ alter table meta.region add constraint region_type_check check (type in (
11
+ 'report', 'form', 'chart', 'cards', 'static', 'grid', 'calendar', 'dynamic', 'facets', 'tasks', 'workflows', 'map', 'tree',
12
+ 'template_component', 'smart_filters', 'display_selector', 'list', 'data_reporter', 'ai_assistant', 'plugin', 'url'));
@@ -0,0 +1,53 @@
1
+ -- =====================================================================
2
+ -- 079: the developer toolbar (APEX: Runtime Developer Toolbar) and the
3
+ -- session state viewer
4
+ --
5
+ -- A builder session hands the browser a second, random token in the cookie
6
+ -- pgkiln_devbar (path /a, HttpOnly). Pages of an application show the
7
+ -- developer toolbar when that token belongs to a live builder session of a
8
+ -- developer who may open the application (src/runtime/devbar.ts). The token
9
+ -- only shows the toolbar: it is not the builder session's token.
10
+ -- =====================================================================
11
+
12
+ alter table meta.session add column devbar_hash text;
13
+ create unique index session_devbar_hash on meta.session (devbar_hash) where devbar_hash is not null;
14
+
15
+ comment on column meta.session.devbar_hash is 'Builder sessions: sha256 of the developer toolbar token (079)';
16
+
17
+ -- Debug messages of a session the toolbar put in debug (state __DEBUG_LEVEL, set only by
18
+ -- POST /a/<alias>/<page>/devbar/debug for a developer) are stored even when the
19
+ -- application is not in debug. Same as 051 otherwise.
20
+ create or replace function meta.debug_save(p_app_id int, p_page_no int, p_username text, p_session_id uuid, p_method text, p_path text,
21
+ p_status int, p_level int, p_started_at timestamptz, p_elapsed_ms numeric, p_entries jsonb)
22
+ returns bigint
23
+ language plpgsql security definer set search_path = meta, pg_catalog as $$
24
+ declare
25
+ v_id bigint;
26
+ v_level int;
27
+ begin
28
+ -- only for an application that is in debug now, or (079) a session the developer toolbar put in debug
29
+ select debug_level into v_level from meta.app where id = p_app_id;
30
+ if coalesce(v_level, 0) = 0 then
31
+ select (s.state->>'__DEBUG_LEVEL')::int into v_level
32
+ from meta.session s
33
+ where s.id = p_session_id and s.app_id = p_app_id and s.state->>'__DEBUG_LEVEL' in ('1', '2', '4', '6', '9');
34
+ end if;
35
+ if coalesce(v_level, 0) = 0 then
36
+ return null;
37
+ end if;
38
+ insert into meta.debug_view (app_id, page_no, username, session_id, method, path, status, level, started_at, elapsed_ms, entries)
39
+ values (p_app_id, p_page_no, left(p_username, 200), p_session_id, left(p_method, 10), left(p_path, 500), p_status,
40
+ least(greatest(coalesce(p_level, v_level), 1), 9), coalesce(p_started_at, now()), p_elapsed_ms,
41
+ least(coalesce(jsonb_array_length(p_entries), 0), 2000))
42
+ returning id into v_id;
43
+ insert into meta.debug_message (view_id, seq, elapsed_ms, duration_ms, level, component, message)
44
+ select v_id, e.ord::int, coalesce((e.v->>'ms')::numeric(10, 1), 0), (e.v->>'dur')::numeric(10, 1),
45
+ least(greatest(coalesce((e.v->>'level')::int, 4), 1), 9), left(e.v->>'component', 100), left(coalesce(e.v->>'text', ''), 4000)
46
+ from jsonb_array_elements(coalesce(p_entries, '[]')) with ordinality as e(v, ord)
47
+ where e.ord <= 2000;
48
+ if random() < 0.02 then
49
+ perform meta.debug_purge();
50
+ end if;
51
+ return v_id;
52
+ end
53
+ $$;
@@ -0,0 +1,326 @@
1
+ -- =====================================================================
2
+ -- 080: search configurations and the search region (APEX 22.2: Search
3
+ -- Configurations in Shared Components, the Search region)
4
+ --
5
+ -- A search configuration says what one source contributes to a search: a
6
+ -- table or view, a SELECT, or a list (Shared Components → Lists); the
7
+ -- columns searched; and which columns make each result's title, subtitle,
8
+ -- description, badge and icon, and where a result links to. A region of
9
+ -- type "search" searches the configurations it names with one search
10
+ -- field (or a page item) and shows the results grouped per configuration
11
+ -- (src/runtime/search.ts). The searches run as the application's role, the
12
+ -- search text is a query parameter.
13
+ --
14
+ -- export_app / import_app are redefined from 069 to carry search_configs.
15
+ -- =====================================================================
16
+
17
+ create table meta.search_config (
18
+ id serial primary key,
19
+ app_id int not null references meta.app (id) on delete cascade,
20
+ name text not null check (name ~ '^[A-Z][A-Z0-9_]{0,59}$'),
21
+ label text not null check (length(label) between 1 and 200),
22
+ seq int not null default 10,
23
+ source_type text not null default 'table' check (source_type in ('table', 'sql', 'list')),
24
+ -- a table or view (schema.name), a SELECT, or the name of a list
25
+ source text not null,
26
+ -- the columns the search text is looked for in (empty: title, subtitle and description)
27
+ searchable_columns text[] not null default '{}',
28
+ title_column text,
29
+ subtitle_column text,
30
+ description_column text,
31
+ badge_column text,
32
+ -- an icon name, or #COLUMN# for a column that holds icon names
33
+ icon text,
34
+ -- where a result links to: {"page": 3, "items": {"P3_ID": "#ID#"}}
35
+ link jsonb,
36
+ max_results int not null default 10 check (max_results between 1 and 100),
37
+ build_option text,
38
+ unique (app_id, name)
39
+ );
40
+ grant select on meta.search_config to pgkiln_runtime;
41
+
42
+ -- an export made before 080 has none; one made with fewer columns gets the defaults
43
+ create function meta.search_config_defaults() returns trigger
44
+ language plpgsql set search_path = pg_catalog as $$
45
+ begin
46
+ new.seq := coalesce(new.seq, 10);
47
+ new.source_type := coalesce(new.source_type, 'table');
48
+ new.searchable_columns := coalesce(new.searchable_columns, '{}');
49
+ new.max_results := coalesce(new.max_results, 10);
50
+ return new;
51
+ end $$;
52
+ create trigger search_config_defaults before insert or update on meta.search_config
53
+ for each row execute function meta.search_config_defaults();
54
+
55
+ comment on table meta.search_config is 'Search configurations (080): what a search region searches, exported with the application';
56
+
57
+ alter table meta.region drop constraint region_type_check;
58
+ alter table meta.region add constraint region_type_check check (type in (
59
+ 'report', 'form', 'chart', 'cards', 'static', 'grid', 'calendar', 'dynamic', 'facets', 'tasks', 'workflows', 'map', 'tree',
60
+ 'template_component', 'smart_filters', 'display_selector', 'list', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'));
61
+
62
+ -- ---------------------------------------------------------------- export / import
63
+ -- Same as 069, plus search_configs.
64
+ create or replace function meta.export_app(p_alias text) returns jsonb
65
+ language sql stable set search_path = meta, pg_catalog as $$
66
+ select jsonb_build_object(
67
+ 'format', 'pgkiln/2',
68
+ 'app', to_jsonb(a) - 'id' - 'created_at' - 'updated_at',
69
+ 'authz_schemes', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.authz_scheme x where x.app_id = a.id), '[]'),
70
+ 'app_items', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.app_item x where x.app_id = a.id), '[]'),
71
+ 'app_processes', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.seq, x.id) from meta.app_process x where x.app_id = a.id), '[]'),
72
+ 'lovs', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.lov x where x.app_id = a.id), '[]'),
73
+ 'group_roles', coalesce((select jsonb_agg(to_jsonb(x) - 'app_id' order by x.group_name, x.role) from meta.app_group_role x where x.app_id = a.id), '[]'),
74
+ 'text_messages', coalesce((select jsonb_agg(to_jsonb(x) - 'app_id' order by x.name, x.language) from meta.text_message x where x.app_id = a.id), '[]'),
75
+ 'translations', coalesce((select jsonb_agg(to_jsonb(x) - 'app_id' order by x.language, x.source) from meta.translation x where x.app_id = a.id), '[]'),
76
+ 'report_layouts', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'logo' || jsonb_build_object('logo', encode(x.logo, 'base64')) order by x.name)
77
+ from meta.report_layout x where x.app_id = a.id), '[]'),
78
+ 'automations', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'next_run_at' - 'last_run_at' - 'last_status' - 'code' order by x.name)
79
+ from meta.automation x where x.app_id = a.id), '[]'),
80
+ -- (044) the actions of the automations, by automation name
81
+ 'automation_actions', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.automation_name collate "C", x.seq, x.name collate "C")
82
+ from meta.automation_action x where x.app_id = a.id), '[]'),
83
+ 'document_templates', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.document_template x where x.app_id = a.id), '[]'),
84
+ 'task_definitions', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.task_definition x where x.app_id = a.id), '[]'),
85
+ 'workflow_definitions', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.workflow_definition x where x.app_id = a.id), '[]'),
86
+ 'rest_modules', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.rest_module x where x.app_id = a.id), '[]'),
87
+ 'template_components', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.static_id) from meta.template_component x where x.app_id = a.id), '[]'),
88
+ 'build_options', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.build_option x where x.app_id = a.id), '[]'),
89
+ -- (030) secrets never leave the installation: they are entered again after an import
90
+ -- (050) nor do the password and refresh token of the OAuth2 flows, or the token state
91
+ 'web_credentials', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'secret_enc' - 'password_enc' - 'refresh_token_enc' - 'token_refreshed_at' order by x.name)
92
+ from meta.web_credential x where x.app_id = a.id), '[]'),
93
+ -- (050) the synchronisation's state (next and last run) belongs to the installation
94
+ 'rest_sources', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'sync_next_at' - 'sync_last_at' - 'sync_last_status' order by x.name)
95
+ from meta.rest_source x where x.app_id = a.id), '[]'),
96
+ -- (041) data load definitions
97
+ 'data_load_definitions', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.data_load_def x where x.app_id = a.id), '[]'),
98
+ -- (042) lists; entries keep their ids so parents can be remapped on import
99
+ 'lists', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.list x where x.app_id = a.id), '[]'),
100
+ 'list_entries', coalesce((select jsonb_agg(to_jsonb(x) - 'app_id' order by x.list_name, x.parent_id nulls first, x.seq, x.id) from meta.list_entry x where x.app_id = a.id), '[]'),
101
+ -- (042) supporting objects: install, upgrade and deinstall scripts (never run on import)
102
+ 'supporting_scripts', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.supporting_script x where x.app_id = a.id), '[]'),
103
+ -- (069) plug-ins (their files are static files, their templates template components)
104
+ 'plugins', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.plugin x where x.app_id = a.id), '[]'),
105
+ -- (080) search configurations
106
+ 'search_configs', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' order by x.name) from meta.search_config x where x.app_id = a.id), '[]'),
107
+ -- (068) static application files (JavaScript, CSS, images, fonts) as base64
108
+ 'static_files', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'app_id' - 'content' - 'updated_at' || jsonb_build_object('content', translate(encode(x.content, 'base64'), E'\n', '')) order by x.name)
109
+ from meta.static_file x where x.app_id = a.id), '[]'),
110
+ -- nav entries and regions keep their ids, so parents and references can be remapped on import
111
+ 'nav', coalesce((select jsonb_agg(to_jsonb(x) - 'app_id' order by x.parent_id nulls first, x.seq, x.id) from meta.nav_entry x where x.app_id = a.id), '[]'),
112
+ 'pages', coalesce((
113
+ select jsonb_agg(to_jsonb(p) - 'id' - 'app_id' || jsonb_build_object(
114
+ 'regions', coalesce((select jsonb_agg(to_jsonb(r) - 'page_id' order by r.seq, r.id) from meta.region r where r.page_id = p.id), '[]'),
115
+ 'items', coalesce((select jsonb_agg(to_jsonb(i) - 'id' - 'page_id' order by i.seq, i.id) from meta.item i where i.page_id = p.id), '[]'),
116
+ 'buttons', coalesce((select jsonb_agg(to_jsonb(b) - 'id' - 'page_id' order by b.seq, b.id) from meta.button b where b.page_id = p.id), '[]'),
117
+ 'dynamic_actions', coalesce((select jsonb_agg(to_jsonb(d) - 'id' - 'page_id' order by d.seq, d.id) from meta.dynamic_action d where d.page_id = p.id), '[]'),
118
+ 'validations', coalesce((select jsonb_agg(to_jsonb(v) - 'id' - 'page_id' order by v.seq, v.id) from meta.validation v where v.page_id = p.id), '[]'),
119
+ 'processes', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'page_id' order by x.seq, x.id) from meta.process x where x.page_id = p.id), '[]'),
120
+ 'computations', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'page_id' order by x.seq, x.id) from meta.computation x where x.page_id = p.id), '[]'),
121
+ 'branches', coalesce((select jsonb_agg(to_jsonb(x) - 'id' - 'page_id' order by x.seq, x.id) from meta.branch x where x.page_id = p.id), '[]')
122
+ ) order by p.page_no)
123
+ from meta.page p where p.app_id = a.id), '[]'))
124
+ from meta.app a
125
+ where a.alias = p_alias
126
+ $$;
127
+
128
+ create or replace function meta.import_app(p_doc jsonb, p_alias text default null) returns int
129
+ language plpgsql set search_path = meta, pg_catalog as $$
130
+ declare
131
+ v_app_id int;
132
+ v_page_id int;
133
+ v_page jsonb;
134
+ v_e jsonb;
135
+ v_rmap jsonb;
136
+ v_nmap jsonb := '{}';
137
+ v_lmap jsonb := '{}';
138
+ v_new_id int;
139
+ begin
140
+ if p_doc->>'format' is distinct from 'pgkiln/2' then
141
+ raise exception 'unsupported export format %', coalesce(p_doc->>'format', '(none)');
142
+ end if;
143
+
144
+ insert into meta.app
145
+ select (jsonb_populate_record(null::meta.app, p_doc->'app' || jsonb_build_object(
146
+ 'id', nextval('meta.app_id_seq'),
147
+ 'alias', coalesce(p_alias, p_doc->'app'->>'alias'),
148
+ 'created_at', now(), 'updated_at', now()))).*
149
+ returning id into v_app_id;
150
+
151
+ insert into meta.authz_scheme
152
+ select (jsonb_populate_record(null::meta.authz_scheme, e || jsonb_build_object('id', nextval('meta.authz_scheme_id_seq'), 'app_id', v_app_id))).*
153
+ from jsonb_array_elements(coalesce(p_doc->'authz_schemes', '[]')) e;
154
+ insert into meta.app_item
155
+ select (jsonb_populate_record(null::meta.app_item, e || jsonb_build_object('id', nextval('meta.app_item_id_seq'), 'app_id', v_app_id))).*
156
+ from jsonb_array_elements(coalesce(p_doc->'app_items', '[]')) e;
157
+ insert into meta.app_process
158
+ select (jsonb_populate_record(null::meta.app_process, e || jsonb_build_object('id', nextval('meta.app_process_id_seq'), 'app_id', v_app_id))).*
159
+ from jsonb_array_elements(coalesce(p_doc->'app_processes', '[]')) e;
160
+ insert into meta.lov
161
+ select (jsonb_populate_record(null::meta.lov, e || jsonb_build_object('id', nextval('meta.lov_id_seq'), 'app_id', v_app_id))).*
162
+ from jsonb_array_elements(coalesce(p_doc->'lovs', '[]')) e;
163
+ insert into meta.app_group_role
164
+ select (jsonb_populate_record(null::meta.app_group_role, e || jsonb_build_object('app_id', v_app_id))).*
165
+ from jsonb_array_elements(coalesce(p_doc->'group_roles', '[]')) e;
166
+ insert into meta.text_message
167
+ select (jsonb_populate_record(null::meta.text_message, e || jsonb_build_object('app_id', v_app_id))).*
168
+ from jsonb_array_elements(coalesce(p_doc->'text_messages', '[]')) e;
169
+ insert into meta.translation
170
+ select (jsonb_populate_record(null::meta.translation, e || jsonb_build_object('app_id', v_app_id))).*
171
+ from jsonb_array_elements(coalesce(p_doc->'translations', '[]')) e;
172
+ insert into meta.report_layout
173
+ select (jsonb_populate_record(null::meta.report_layout, (e - 'logo') || jsonb_build_object(
174
+ 'id', nextval('meta.report_layout_id_seq'), 'app_id', v_app_id,
175
+ 'logo', null))).*
176
+ from jsonb_array_elements(coalesce(p_doc->'report_layouts', '[]')) e;
177
+ update meta.report_layout l
178
+ set logo = decode(e->>'logo', 'base64')
179
+ from jsonb_array_elements(coalesce(p_doc->'report_layouts', '[]')) e
180
+ where l.app_id = v_app_id and l.name = e->>'name' and e->>'logo' is not null;
181
+ -- imported automations start switched off: the copy must not run the original's jobs unasked.
182
+ -- (044) a file with actions: the automations' code is ignored; an older file: code becomes the single action
183
+ insert into meta.automation
184
+ select (jsonb_populate_record(null::meta.automation, '{"error_handling": "stop"}'::jsonb
185
+ || jsonb_strip_nulls(case when p_doc ? 'automation_actions' then e - 'code' else e end)
186
+ || jsonb_build_object('id', nextval('meta.automation_id_seq'), 'app_id', v_app_id, 'enabled', false))).*
187
+ from jsonb_array_elements(coalesce(p_doc->'automations', '[]')) e;
188
+ insert into meta.automation_action
189
+ select (jsonb_populate_record(null::meta.automation_action, '{"seq": 10}'::jsonb || jsonb_strip_nulls(e)
190
+ || jsonb_build_object('id', nextval('meta.automation_action_id_seq'), 'app_id', v_app_id))).*
191
+ from jsonb_array_elements(coalesce(p_doc->'automation_actions', '[]')) e;
192
+ insert into meta.document_template
193
+ select (jsonb_populate_record(null::meta.document_template, e || jsonb_build_object('id', nextval('meta.document_template_id_seq'), 'app_id', v_app_id))).*
194
+ from jsonb_array_elements(coalesce(p_doc->'document_templates', '[]')) e;
195
+ insert into meta.task_definition
196
+ select (jsonb_populate_record(null::meta.task_definition, e || jsonb_build_object('id', nextval('meta.task_definition_id_seq'), 'app_id', v_app_id))).*
197
+ from jsonb_array_elements(coalesce(p_doc->'task_definitions', '[]')) e;
198
+ insert into meta.workflow_definition
199
+ select (jsonb_populate_record(null::meta.workflow_definition, e || jsonb_build_object('id', nextval('meta.workflow_definition_id_seq'), 'app_id', v_app_id))).*
200
+ from jsonb_array_elements(coalesce(p_doc->'workflow_definitions', '[]')) e;
201
+ insert into meta.rest_module
202
+ select (jsonb_populate_record(null::meta.rest_module, e || jsonb_build_object('id', nextval('meta.rest_module_id_seq'), 'app_id', v_app_id))).*
203
+ from jsonb_array_elements(coalesce(p_doc->'rest_modules', '[]')) e;
204
+ -- (028) template components: regions and report columns refer to them by static id
205
+ insert into meta.template_component
206
+ select (jsonb_populate_record(null::meta.template_component, e || jsonb_build_object('id', nextval('meta.template_component_id_seq'), 'app_id', v_app_id))).*
207
+ from jsonb_array_elements(coalesce(p_doc->'template_components', '[]')) e;
208
+ -- (029) build options (components name them by name)
209
+ insert into meta.build_option
210
+ select (jsonb_populate_record(null::meta.build_option, e || jsonb_build_object('id', nextval('meta.build_option_id_seq'), 'app_id', v_app_id))).*
211
+ from jsonb_array_elements(coalesce(p_doc->'build_options', '[]')) e;
212
+ -- (030) web credentials arrive without a secret, whatever the document holds
213
+ -- (050) nor a password or refresh token
214
+ insert into meta.web_credential
215
+ select (jsonb_populate_record(null::meta.web_credential, '{"type": "basic", "valid_for": [], "grant_type": "client_credentials"}'::jsonb
216
+ || jsonb_strip_nulls(e - 'secret_enc' - 'password_enc' - 'refresh_token_enc' - 'token_refreshed_at')
217
+ || jsonb_build_object('id', nextval('meta.web_credential_id_seq'), 'app_id', v_app_id))).*
218
+ from jsonb_array_elements(coalesce(p_doc->'web_credentials', '[]')) e;
219
+ -- (050) write-back operations and synchronisation; an imported synchronisation starts switched off
220
+ insert into meta.rest_source
221
+ select (jsonb_populate_record(null::meta.rest_source, '{"method": "GET", "headers": {}, "params": [], "columns": [], "cache_seconds": 0, "timeout_s": 10, "max_rows": 1000,
222
+ "operations": {}, "key_columns": [], "sync_mode": "merge", "sync_delete": false, "sync_time_zone": "UTC"}'::jsonb
223
+ || jsonb_strip_nulls(e - 'sync_next_at' - 'sync_last_at' - 'sync_last_status')
224
+ || jsonb_build_object('id', nextval('meta.rest_source_id_seq'), 'app_id', v_app_id, 'sync_enabled', false))).*
225
+ from jsonb_array_elements(coalesce(p_doc->'rest_sources', '[]')) e;
226
+
227
+ -- (041) data load definitions (the data_load process names them)
228
+ insert into meta.data_load_def
229
+ select (jsonb_populate_record(null::meta.data_load_def, '{"format": "auto", "headers": true, "mode": "append", "skip_errors": false, "columns": []}'::jsonb
230
+ || jsonb_strip_nulls(e) || jsonb_build_object('id', nextval('meta.data_load_def_id_seq'), 'app_id', v_app_id))).*
231
+ from jsonb_array_elements(coalesce(p_doc->'data_load_definitions', '[]')) e;
232
+
233
+ -- (042) lists and their entries, supporting scripts. Entries are inserted
234
+ -- without a parent first and the parents set afterwards, so the order of
235
+ -- the entries in the document doesn't matter (a moved entry may have a
236
+ -- higher id than its children).
237
+ insert into meta.list
238
+ select (jsonb_populate_record(null::meta.list, '{"type": "static"}'::jsonb || jsonb_strip_nulls(e) || jsonb_build_object('id', nextval('meta.list_id_seq'), 'app_id', v_app_id))).*
239
+ from jsonb_array_elements(coalesce(p_doc->'lists', '[]')) e;
240
+ for v_e in select * from jsonb_array_elements(coalesce(p_doc->'list_entries', '[]')) loop
241
+ insert into meta.list_entry
242
+ select (jsonb_populate_record(null::meta.list_entry, '{"target_items": {}}'::jsonb || jsonb_strip_nulls(v_e) || jsonb_build_object(
243
+ 'id', nextval('meta.list_entry_id_seq'), 'app_id', v_app_id, 'parent_id', null))).*
244
+ returning id into v_new_id;
245
+ if v_e->>'id' is not null then
246
+ v_lmap := v_lmap || jsonb_build_object(v_e->>'id', v_new_id);
247
+ end if;
248
+ end loop;
249
+ update meta.list_entry x
250
+ set parent_id = (v_lmap->>(e->>'parent_id'))::int
251
+ from jsonb_array_elements(coalesce(p_doc->'list_entries', '[]')) e
252
+ where e->>'parent_id' is not null and v_lmap ? (e->>'parent_id') and v_lmap ? (e->>'id')
253
+ and x.id = (v_lmap->>(e->>'id'))::int;
254
+ insert into meta.supporting_script
255
+ select (jsonb_populate_record(null::meta.supporting_script, '{"kind": "install", "seq": 10, "script": ""}'::jsonb || jsonb_strip_nulls(e)
256
+ || jsonb_build_object('id', nextval('meta.supporting_script_id_seq'), 'app_id', v_app_id))).*
257
+ from jsonb_array_elements(coalesce(p_doc->'supporting_scripts', '[]')) e;
258
+
259
+ -- (069) plug-ins
260
+ insert into meta.plugin
261
+ select (jsonb_populate_record(null::meta.plugin, e || jsonb_build_object('id', nextval('meta.plugin_id_seq'), 'app_id', v_app_id))).*
262
+ from jsonb_array_elements(coalesce(p_doc->'plugins', '[]')) e;
263
+
264
+ -- (080) search configurations (search regions name them)
265
+ insert into meta.search_config
266
+ select (jsonb_populate_record(null::meta.search_config, '{"seq": 10, "source_type": "table", "searchable_columns": [], "max_results": 10}'::jsonb
267
+ || jsonb_strip_nulls(e) || jsonb_build_object('id', nextval('meta.search_config_id_seq'), 'app_id', v_app_id))).*
268
+ from jsonb_array_elements(coalesce(p_doc->'search_configs', '[]')) e;
269
+
270
+ -- (068) static application files
271
+ insert into meta.static_file (app_id, name, mime, content, updated_at)
272
+ select v_app_id, e->>'name', e->>'mime', decode(coalesce(e->>'content', ''), 'base64'), now()
273
+ from jsonb_array_elements(coalesce(p_doc->'static_files', '[]')) e;
274
+
275
+ for v_e in select * from jsonb_array_elements(coalesce(p_doc->'nav', '[]')) loop
276
+ insert into meta.nav_entry
277
+ select (jsonb_populate_record(null::meta.nav_entry, v_e || jsonb_build_object(
278
+ 'id', nextval('meta.nav_entry_id_seq'), 'app_id', v_app_id,
279
+ 'parent_id', v_nmap->>(v_e->>'parent_id')))).*
280
+ returning id into v_new_id;
281
+ v_nmap := v_nmap || jsonb_build_object(v_e->>'id', v_new_id);
282
+ end loop;
283
+
284
+ for v_page in select * from jsonb_array_elements(coalesce(p_doc->'pages', '[]')) loop
285
+ insert into meta.page
286
+ select (jsonb_populate_record(null::meta.page, v_page || jsonb_build_object('id', nextval('meta.page_id_seq'), 'app_id', v_app_id))).*
287
+ returning id into v_page_id;
288
+
289
+ v_rmap := '{}';
290
+ for v_e in select * from jsonb_array_elements(coalesce(v_page->'regions', '[]')) loop
291
+ insert into meta.region
292
+ select (jsonb_populate_record(null::meta.region, v_e || jsonb_build_object('id', nextval('meta.region_id_seq'), 'page_id', v_page_id))).*
293
+ returning id into v_new_id;
294
+ v_rmap := v_rmap || jsonb_build_object(v_e->>'id', v_new_id);
295
+ end loop;
296
+ -- facet and map regions point at their report region by id
297
+ update meta.region r
298
+ set config = jsonb_set(r.config, '{report}', to_jsonb((v_rmap->>(r.config->>'report'))::int))
299
+ where r.page_id = v_page_id and r.type in ('facets', 'smart_filters', 'map') and v_rmap ? (r.config->>'report');
300
+
301
+ insert into meta.item
302
+ select (jsonb_populate_record(null::meta.item, e || jsonb_build_object('id', nextval('meta.item_id_seq'), 'page_id', v_page_id, 'region_id', v_rmap->>(e->>'region_id')))).*
303
+ from jsonb_array_elements(coalesce(v_page->'items', '[]')) e;
304
+ insert into meta.button
305
+ select (jsonb_populate_record(null::meta.button, e || jsonb_build_object('id', nextval('meta.button_id_seq'), 'page_id', v_page_id, 'region_id', v_rmap->>(e->>'region_id')))).*
306
+ from jsonb_array_elements(coalesce(v_page->'buttons', '[]')) e;
307
+ insert into meta.dynamic_action
308
+ select (jsonb_populate_record(null::meta.dynamic_action, e || jsonb_build_object('id', nextval('meta.dynamic_action_id_seq'), 'page_id', v_page_id, 'affected_region_id', v_rmap->>(e->>'affected_region_id')))).*
309
+ from jsonb_array_elements(coalesce(v_page->'dynamic_actions', '[]')) e;
310
+ insert into meta.validation
311
+ select (jsonb_populate_record(null::meta.validation, e || jsonb_build_object('id', nextval('meta.validation_id_seq'), 'page_id', v_page_id))).*
312
+ from jsonb_array_elements(coalesce(v_page->'validations', '[]')) e;
313
+ insert into meta.process
314
+ select (jsonb_populate_record(null::meta.process, e || jsonb_build_object('id', nextval('meta.process_id_seq'), 'page_id', v_page_id, 'region_id', v_rmap->>(e->>'region_id')))).*
315
+ from jsonb_array_elements(coalesce(v_page->'processes', '[]')) e;
316
+ insert into meta.computation
317
+ select (jsonb_populate_record(null::meta.computation, e || jsonb_build_object('id', nextval('meta.computation_id_seq'), 'page_id', v_page_id))).*
318
+ from jsonb_array_elements(coalesce(v_page->'computations', '[]')) e;
319
+ insert into meta.branch
320
+ select (jsonb_populate_record(null::meta.branch, e || jsonb_build_object('id', nextval('meta.branch_id_seq'), 'page_id', v_page_id))).*
321
+ from jsonb_array_elements(coalesce(v_page->'branches', '[]')) e;
322
+ end loop;
323
+
324
+ return v_app_id;
325
+ end
326
+ $$;