pgkiln 0.51.0 → 0.53.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 (124) hide show
  1. package/README.md +1 -1
  2. package/SECURITY.md +10 -1
  3. package/db/migrations/121_ai_agents.sql +94 -0
  4. package/db/migrations/122_team_development.sql +186 -0
  5. package/db/migrations/123_workflow_outcomes.sql +407 -0
  6. package/db/migrations/124_component_details.sql +51 -0
  7. package/db/migrations/125_app_change.sql +23 -0
  8. package/db/migrations/126_mail_queue_network_access.sql +119 -0
  9. package/docs/apex-feature-parity.md +50 -19
  10. package/docs/guide/01-installation.md +8 -4
  11. package/docs/guide/03-builder.md +197 -10
  12. package/docs/guide/04-pages-and-regions.md +71 -21
  13. package/docs/guide/05-items.md +7 -1
  14. package/docs/guide/06-processing.md +129 -9
  15. package/docs/guide/07-dynamic-actions.md +34 -0
  16. package/docs/guide/08-security.md +55 -0
  17. package/docs/guide/09-reference.md +5 -2
  18. package/docs/guide/11-from-apex.md +3 -1
  19. package/docs/guide/12-development.md +87 -16
  20. package/docs/guide/14-globalization.md +49 -1
  21. package/docs/guide/18-cli.md +21 -1
  22. package/docs/guide/19-rest-data-sources.md +12 -0
  23. package/docs/guide/20-ai-agents.md +12 -4
  24. package/examples/hr/README.md +2 -0
  25. package/examples/hr/hr_64_ai_agent.sql +97 -0
  26. package/examples/hr/hr_65_ask_reports.sql +21 -0
  27. package/examples/hr/hr_66_team_development.sql +58 -0
  28. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  29. package/examples/hr/hr_68_component_details.sql +96 -0
  30. package/examples/hr/hr_70_data_security.sql +79 -0
  31. package/package.json +1 -1
  32. package/public/app.css +135 -21
  33. package/public/app.js +134 -20
  34. package/public/builder.css +133 -1
  35. package/public/builder.js +51 -0
  36. package/src/ai/chat.ts +18 -18
  37. package/src/ai/service.ts +35 -11
  38. package/src/ai/types.ts +7 -2
  39. package/src/app.ts +15 -0
  40. package/src/builder/advisor.ts +6 -3
  41. package/src/builder/ai-agents.ts +372 -0
  42. package/src/builder/ai-builder.ts +3 -3
  43. package/src/builder/ai.ts +13 -9
  44. package/src/builder/app-updates.ts +55 -0
  45. package/src/builder/assistant.ts +22 -7
  46. package/src/builder/components.ts +38 -9
  47. package/src/builder/data-security.ts +182 -0
  48. package/src/builder/designer.ts +8 -3
  49. package/src/builder/feedback.ts +6 -1
  50. package/src/builder/home.ts +16 -5
  51. package/src/builder/locks.ts +7 -1
  52. package/src/builder/mail-queue.ts +212 -0
  53. package/src/builder/mail.ts +72 -45
  54. package/src/builder/network-access.ts +140 -0
  55. package/src/builder/object-actions.ts +1 -0
  56. package/src/builder/object-browser.ts +1 -1
  57. package/src/builder/region-help.ts +2 -2
  58. package/src/builder/rest-catalogs.ts +19 -4
  59. package/src/builder/routes.ts +4 -1
  60. package/src/builder/shared.ts +1 -1
  61. package/src/builder/sql.ts +2 -0
  62. package/src/builder/team.ts +991 -0
  63. package/src/builder/themeroller.ts +47 -6
  64. package/src/builder/ui.ts +7 -1
  65. package/src/builder/websources.ts +1 -1
  66. package/src/builder/workflow-migrate.ts +210 -0
  67. package/src/builder/workflows.ts +29 -6
  68. package/src/builder/workspace-home.ts +2 -0
  69. package/src/builder/workspace-mail.ts +121 -0
  70. package/src/builder/workspace-transfer.ts +1 -1
  71. package/src/builder/workspaces.ts +1 -0
  72. package/src/cli/main.ts +25 -3
  73. package/src/cli/mcp.ts +40 -4
  74. package/src/cli/only.ts +52 -0
  75. package/src/cli/replace.ts +11 -1
  76. package/src/datasec.ts +343 -0
  77. package/src/i18n/ar.ts +17 -0
  78. package/src/i18n/cs.ts +17 -0
  79. package/src/i18n/da.ts +17 -0
  80. package/src/i18n/de.ts +17 -0
  81. package/src/i18n/el.ts +17 -0
  82. package/src/i18n/es.ts +17 -0
  83. package/src/i18n/fi.ts +17 -0
  84. package/src/i18n/fr.ts +17 -0
  85. package/src/i18n/he.ts +17 -0
  86. package/src/i18n/it.ts +17 -0
  87. package/src/i18n/ja.ts +17 -0
  88. package/src/i18n/ko.ts +17 -0
  89. package/src/i18n/nb.ts +17 -0
  90. package/src/i18n/pl.ts +17 -0
  91. package/src/i18n/pt.ts +17 -0
  92. package/src/i18n/ru.ts +17 -0
  93. package/src/i18n/sv.ts +17 -0
  94. package/src/i18n/tr.ts +17 -0
  95. package/src/i18n/uk.ts +17 -0
  96. package/src/i18n/zh.ts +17 -0
  97. package/src/i18n.ts +34 -0
  98. package/src/instance.ts +2 -0
  99. package/src/mail/mail.ts +50 -11
  100. package/src/mail/providers.ts +6 -3
  101. package/src/metadata.ts +11 -3
  102. package/src/network-acl.ts +126 -0
  103. package/src/objectstore.ts +3 -0
  104. package/src/runtime/ai-agents.ts +365 -0
  105. package/src/runtime/ai-filter.ts +385 -107
  106. package/src/runtime/assistant.ts +64 -11
  107. package/src/runtime/context.ts +2 -0
  108. package/src/runtime/engine.ts +3 -1
  109. package/src/runtime/ir-phrases.ts +812 -0
  110. package/src/runtime/ir-plan.ts +449 -0
  111. package/src/runtime/items.ts +1 -1
  112. package/src/runtime/regions.ts +6 -2
  113. package/src/runtime/render.ts +5 -3
  114. package/src/runtime/report.ts +1 -1
  115. package/src/runtime/styles.ts +54 -12
  116. package/src/runtime/tasks.ts +63 -12
  117. package/src/task-events.ts +232 -0
  118. package/src/team-samples.ts +74 -0
  119. package/src/team.ts +342 -0
  120. package/src/webclient.ts +45 -4
  121. package/src/webrequests.ts +5 -0
  122. package/src/websources.ts +30 -12
  123. package/src/workflow.ts +135 -18
  124. package/src/workspace-export.ts +16 -1
@@ -0,0 +1,407 @@
1
+ -- =====================================================================
2
+ -- 123: workflows and approvals (APEX 26.1 / 26.2): custom task outcomes,
3
+ -- task events and notifications, draft steps, migrating running instances
4
+ --
5
+ -- Task definitions:
6
+ -- outcomes [{"name": "YES", "label": "Yes", "colour": "success"}, …]: the buttons of the
7
+ -- task and the outcomes a task may end with (stored lower case in meta.task.outcome);
8
+ -- empty or null: the type's own (approval: approved / rejected, action: completed).
9
+ -- Colours: default, primary, success, danger, warning.
10
+ -- events [{"on": "complete" | "expire" | "fail", "type": "sql" | "mail" | "workflow", …}]:
11
+ -- actions when a task is completed, expires, or its completion SQL fails
12
+ -- (src/task-events.ts runs them as the application's role).
13
+ -- fail_recovery after a failing completion SQL: reopen (the decision is undone; the default) or
14
+ -- complete (the decision stands without the SQL's effect).
15
+ -- notifications {"create": "TEMPLATE", "complete": "TEMPLATE", "expire": "TEMPLATE"}: built-in
16
+ -- e-mails from the application's e-mail templates (meta.send_mail_template) to the
17
+ -- potential owners (create) or the initiator (complete; expire: both).
18
+ -- All are columns of meta.task_definition: they travel with export/import as they are.
19
+ --
20
+ -- meta.task_hook: the outbox of task events and notifications (create, complete, expire), filled
21
+ -- by a trigger in the task's transaction and worked off by the server after the commit. Runtime
22
+ -- data like the tasks themselves (no application foreign key: not exported, kept on replace).
23
+ --
24
+ -- Workflows:
25
+ -- a "draft" step type, a placeholder while modelling: allowed in a development version only
26
+ -- (a check on the active steps; activation refuses it);
27
+ -- a "send_mail" step (src/workflow.ts) through meta.send_mail / meta.send_mail_template;
28
+ -- meta.migrate_workflows(): running instances move to a newer version of their definition,
29
+ -- each current step mapped to a step of that version, in one transaction, logged in
30
+ -- meta.workflow_event ("migrated") and meta.workflow_migration (installation data).
31
+ -- =====================================================================
32
+
33
+ -- ---------------------------------------------------------------- outcomes
34
+ /** Why a task definition's outcomes are not valid, or null. */
35
+ create function meta.task_outcomes_problem(p jsonb) returns text
36
+ language plpgsql immutable set search_path = pg_catalog as $$
37
+ declare
38
+ e jsonb;
39
+ v_seen text[] := '{}';
40
+ v_name text;
41
+ begin
42
+ if p is null or p = '[]'::jsonb then
43
+ return null;
44
+ end if;
45
+ if jsonb_typeof(p) <> 'array' then
46
+ return 'Outcomes are a JSON array.';
47
+ end if;
48
+ if jsonb_array_length(p) > 10 then
49
+ return 'At most 10 outcomes.';
50
+ end if;
51
+ for e in select * from jsonb_array_elements(p) loop
52
+ if jsonb_typeof(e) <> 'object' then
53
+ return 'Each outcome is an object {"name": …, "label": …, "colour": …}.';
54
+ end if;
55
+ v_name := lower(e->>'name');
56
+ if v_name is null or v_name !~ '^[a-z][a-z0-9_]{0,29}$' then
57
+ return format('Outcome name %s: letters, digits and underscores (at most 30), e.g. YES.', coalesce(e->>'name', '(none)'));
58
+ end if;
59
+ if v_name in ('cancelled', 'expired', 'default') then
60
+ return format('Outcome name %s is reserved.', e->>'name');
61
+ end if;
62
+ if v_name = any (v_seen) then
63
+ return format('Outcome %s is listed twice.', e->>'name');
64
+ end if;
65
+ v_seen := v_seen || v_name;
66
+ if e ? 'label' and (jsonb_typeof(e->'label') <> 'string' or length(btrim(e->>'label')) not between 1 and 60) then
67
+ return format('Outcome %s: the label is text of 1 to 60 characters.', e->>'name');
68
+ end if;
69
+ if e ? 'colour' and coalesce(e->>'colour', '') not in ('default', 'primary', 'success', 'danger', 'warning') then
70
+ return format('Outcome %s: the colour is default, primary, success, danger or warning.', e->>'name');
71
+ end if;
72
+ end loop;
73
+ return null;
74
+ end
75
+ $$;
76
+
77
+ alter table meta.task_definition
78
+ add column outcomes jsonb constraint task_definition_outcomes_check check (meta.task_outcomes_problem(outcomes) is null),
79
+ add column events jsonb check (events is null or (jsonb_typeof(events) = 'array' and jsonb_array_length(events) <= 20)),
80
+ add column fail_recovery text check (fail_recovery in ('reopen', 'complete')),
81
+ add column notifications jsonb check (notifications is null or (jsonb_typeof(notifications) = 'object'
82
+ and notifications - 'create' - 'complete' - 'expire' = '{}'::jsonb));
83
+ comment on column meta.task_definition.outcomes is 'Custom outcomes (123): [{"name", "label", "colour"}]; empty: the type''s own.';
84
+ comment on column meta.task_definition.events is 'Task events (123): [{"on": complete|expire|fail, "type": sql|mail|workflow, …}].';
85
+ comment on column meta.task_definition.notifications is 'Built-in notifications (123): {"create"|"complete"|"expire": e-mail template name}.';
86
+
87
+ /** The outcomes a task of this definition may end with (lower case). */
88
+ create function meta.task_outcome_names(d meta.task_definition) returns text[]
89
+ language sql immutable set search_path = pg_catalog as $$
90
+ select case when d.outcomes is not null and jsonb_array_length(d.outcomes) > 0
91
+ then array(select lower(e->>'name') from jsonb_array_elements(d.outcomes) e)
92
+ when d.type = 'approval' then array['approved', 'rejected']
93
+ else array['completed'] end
94
+ $$;
95
+
96
+ alter table meta.task drop constraint task_outcome_check;
97
+ alter table meta.task add constraint task_outcome_check check (outcome ~ '^[a-z][a-z0-9_]{0,29}$');
98
+ alter table meta.task_event drop constraint task_event_event_check;
99
+ alter table meta.task_event add constraint task_event_event_check
100
+ check (event in ('created', 'claimed', 'released', 'delegated', 'approved', 'rejected', 'completed', 'cancelled', 'commented', 'closed',
101
+ 'expired', 'renewed', 'escalated', 'failed'));
102
+
103
+ -- Same as 021, plus: the outcomes of the definition (custom ones too). A custom outcome is logged as
104
+ -- "completed" with its label. Returns also the outcome, its label, the fail events and the recovery.
105
+ create or replace function meta.complete_task(p_id bigint, p_outcome text, p_comment text default null) returns jsonb
106
+ language plpgsql security definer set search_path = meta, pg_catalog as $$
107
+ declare
108
+ t meta.task;
109
+ d meta.task_definition;
110
+ v_out text := lower(btrim(coalesce(p_outcome, '')));
111
+ v_label text;
112
+ begin
113
+ t := meta.task_load(p_id, 'act');
114
+ select * into d from task_definition where id = t.definition_id;
115
+ if not v_out = any (meta.task_outcome_names(d)) then
116
+ raise exception 'A task of % cannot end as %.', d.name, left(coalesce(p_outcome, '(none)'), 60);
117
+ end if;
118
+ select e->>'label' into v_label from jsonb_array_elements(coalesce(d.outcomes, '[]')) e where lower(e->>'name') = v_out;
119
+ update task set state = 'completed', outcome = v_out, completed_at = now(), completed_by = meta.app_user(),
120
+ actual_owner = coalesce(actual_owner, meta.app_user())
121
+ where id = p_id;
122
+ if v_out in ('approved', 'rejected', 'completed') then
123
+ perform meta.task_log(p_id, v_out, p_comment);
124
+ else
125
+ perform meta.task_log(p_id, 'completed', coalesce(v_label, upper(v_out)) || coalesce(': ' || nullif(btrim(p_comment), ''), ''));
126
+ end if;
127
+ return jsonb_build_object('action_code', d.action_code, 'detail_pk', t.detail_pk, 'params', t.params, 'initiator', t.initiator,
128
+ 'outcome', v_out, 'label', coalesce(v_label, v_out), 'subject', t.subject, 'definition', d.name,
129
+ 'fail_events', coalesce((select jsonb_agg(e) from jsonb_array_elements(coalesce(d.events, '[]')) e where e->>'on' = 'fail'), '[]'),
130
+ 'fail_recovery', coalesce(d.fail_recovery, 'reopen'));
131
+ end
132
+ $$;
133
+
134
+ -- Same as 021, plus: a custom outcome of the definition.
135
+ create or replace function meta.close_tasks(p_name text, p_detail_pk text, p_outcome text default null) returns int
136
+ language plpgsql security definer set search_path = meta, pg_catalog as $$
137
+ declare
138
+ v_n int := 0;
139
+ v_id bigint;
140
+ d meta.task_definition;
141
+ begin
142
+ select * into d from task_definition where app_id = meta.app_id() and name = upper(p_name);
143
+ if p_outcome is not null and not (found and lower(p_outcome) = any (meta.task_outcome_names(d))) then
144
+ raise exception 'Invalid outcome %.', left(p_outcome, 60);
145
+ end if;
146
+ for v_id in
147
+ update task t set state = case when p_outcome is null then 'cancelled' else 'completed' end,
148
+ outcome = lower(p_outcome), completed_at = now(), completed_by = meta.app_user()
149
+ where t.definition_id = d.id and t.detail_pk = p_detail_pk and t.state in ('unassigned', 'assigned')
150
+ returning t.id
151
+ loop
152
+ perform meta.task_log(v_id, 'closed', p_outcome);
153
+ v_n := v_n + 1;
154
+ end loop;
155
+ return v_n;
156
+ end
157
+ $$;
158
+
159
+ -- The completion SQL of a task failed and its fail events ran (the task list, as the acting user).
160
+ create function meta.log_task_failure(p_id bigint, p_detail text) returns void
161
+ language plpgsql security definer set search_path = meta, pg_catalog as $$
162
+ declare
163
+ t meta.task;
164
+ begin
165
+ select * into t from task where id = p_id and app_id = meta.app_id();
166
+ if not found or not ((meta.task_rights(t)).may_act or (t.state = 'completed' and lower(t.completed_by) = lower(meta.app_user()))) then
167
+ raise exception 'Task % not found.', p_id;
168
+ end if;
169
+ perform meta.task_log(p_id, 'failed', left(p_detail, 4000));
170
+ end
171
+ $$;
172
+ revoke all on function meta.log_task_failure(bigint, text) from public;
173
+ grant execute on function meta.log_task_failure(bigint, text) to public;
174
+
175
+ -- ---------------------------------------------------------------- events and notifications: the outbox
176
+ create table meta.task_hook (
177
+ id bigserial primary key,
178
+ task_id bigint not null references meta.task on delete cascade,
179
+ event text not null check (event in ('create', 'complete', 'expire')),
180
+ created_at timestamptz not null default now(),
181
+ attempts int not null default 0,
182
+ done_at timestamptz,
183
+ error text check (length(error) <= 2000)
184
+ );
185
+ create index task_hook_pending on meta.task_hook (id) where done_at is null;
186
+ revoke all on meta.task_hook from public;
187
+ comment on table meta.task_hook is 'Task events and notifications to run after the commit (123); runtime data, not exported.';
188
+
189
+ create function meta.task_hook_enqueue() returns trigger
190
+ language plpgsql security definer set search_path = meta, pg_catalog as $$
191
+ declare
192
+ d meta.task_definition;
193
+ v_event text := case when tg_op = 'INSERT' then 'create' when new.state = 'completed' then 'complete' when new.state = 'expired' then 'expire' end;
194
+ begin
195
+ if v_event is null then
196
+ return new;
197
+ end if;
198
+ select * into d from task_definition where id = new.definition_id;
199
+ if coalesce(d.notifications ? v_event, false)
200
+ or exists (select 1 from jsonb_array_elements(coalesce(d.events, '[]')) e where e->>'on' = v_event) then
201
+ insert into task_hook (task_id, event) values (new.id, v_event);
202
+ perform pg_notify('pgkiln_workflow', 'task');
203
+ end if;
204
+ return new;
205
+ end
206
+ $$;
207
+ create trigger task_hook_create after insert on meta.task for each row execute function meta.task_hook_enqueue();
208
+ create trigger task_hook_state after update of state on meta.task
209
+ for each row when (old.state is distinct from new.state and new.state in ('completed', 'expired')) execute function meta.task_hook_enqueue();
210
+
211
+ /**
212
+ * The e-mail addresses of a task's people (accounts with an address): its potential owners (named users,
213
+ * the actual owner, the application's users with one of its owner roles) and/or its initiator.
214
+ */
215
+ create function meta.task_addresses(p_task_id bigint, p_owners boolean, p_initiator boolean) returns text[]
216
+ language sql stable security definer set search_path = meta, pg_catalog as $$
217
+ select coalesce(array_agg(distinct ac.email order by ac.email), '{}')
218
+ from task t
219
+ join account ac on ac.active and ac.email is not null
220
+ where t.id = p_task_id
221
+ and ((p_initiator and lower(ac.username) = lower(t.initiator))
222
+ or (p_owners and (lower(ac.username) = any (select lower(u) from unnest(t.owner_users) u)
223
+ or lower(ac.username) = lower(t.actual_owner)
224
+ or exists (select 1 from app_access aa
225
+ where aa.app_id = t.app_id and aa.account_id = ac.id
226
+ and exists (select 1 from unnest(aa.roles) r where lower(r) = any (select lower(x) from unnest(t.owner_roles) x))))))
227
+ $$;
228
+ revoke all on function meta.task_addresses(bigint, boolean, boolean) from public;
229
+
230
+ -- ---------------------------------------------------------------- draft steps
231
+ alter table meta.workflow_definition add constraint workflow_definition_no_draft
232
+ check (not jsonb_path_exists(steps, '$[*] ? (@.type == "draft")'));
233
+
234
+ -- Same as 027, plus: a version with draft steps is not activated.
235
+ create or replace function meta.activate_workflow_version(p_definition_id int) returns text
236
+ language plpgsql set search_path = meta, pg_catalog as $$
237
+ declare
238
+ d meta.workflow_definition;
239
+ v_drafts text;
240
+ begin
241
+ select * into d from workflow_definition where id = p_definition_id for update;
242
+ if not found then
243
+ raise exception 'Workflow definition % does not exist.', p_definition_id;
244
+ end if;
245
+ if d.dev_version is null then
246
+ raise exception 'Workflow % has no version in development.', d.name;
247
+ end if;
248
+ if jsonb_array_length(d.dev_steps) = 0 then
249
+ raise exception 'Version % of workflow % has no steps.', d.dev_version, d.name;
250
+ end if;
251
+ select string_agg(s->>'name', ', ') into v_drafts from jsonb_array_elements(d.dev_steps) s where s->>'type' = 'draft';
252
+ if v_drafts is not null then
253
+ raise exception 'Version % of workflow % has draft steps (%): replace them before activating it.', d.dev_version, d.name, v_drafts;
254
+ end if;
255
+ update workflow_definition
256
+ set inactive_versions = inactive_versions || jsonb_build_array(jsonb_build_object(
257
+ 'version', version, 'steps', steps, 'activated_at', activated_at, 'deactivated_at', now())),
258
+ version = dev_version, steps = dev_steps, activated_at = now(),
259
+ dev_version = null, dev_steps = null
260
+ where id = d.id;
261
+ return d.dev_version;
262
+ end
263
+ $$;
264
+ revoke all on function meta.activate_workflow_version(int) from public;
265
+
266
+ -- ---------------------------------------------------------------- migrating running instances
267
+ alter table meta.workflow_event drop constraint workflow_event_event_check;
268
+ alter table meta.workflow_event add constraint workflow_event_event_check
269
+ check (event in ('started', 'step', 'task', 'waiting', 'resumed', 'completed', 'terminated', 'faulted', 'retried', 'split', 'joined', 'cancelled', 'migrated'));
270
+
271
+ create table meta.workflow_migration (
272
+ id bigserial primary key,
273
+ app_id int not null references meta.app on delete cascade,
274
+ definition_name text not null,
275
+ to_version text not null,
276
+ -- current step → step of the new version
277
+ mapping jsonb not null,
278
+ workflow_ids bigint[] not null,
279
+ -- per instance: {"id", "from_version", "from_step", "to_step"}
280
+ detail jsonb not null default '[]',
281
+ migrated_by text not null,
282
+ migrated_at timestamptz not null default now()
283
+ );
284
+ create index on meta.workflow_migration (app_id, migrated_at);
285
+ revoke all on meta.workflow_migration from public;
286
+ comment on table meta.workflow_migration is 'Log of migrations of running workflow instances to a newer version (123); installation data, not exported.';
287
+
288
+ /**
289
+ * Move running instances (p_ids) of a definition to a newer version (p_to_version: the active version or a
290
+ * later inactive one), each at the step p_map gives for its current step. Returns one row per instance with
291
+ * the problem that stops it (null: it can move). With p_apply, nothing changes unless every instance can
292
+ * move; then all move in this transaction, with a "migrated" event each and a row in meta.workflow_migration.
293
+ */
294
+ create function meta.migrate_workflows(p_definition_id int, p_to_version text, p_ids bigint[], p_map jsonb, p_apply boolean, p_by text)
295
+ returns table (workflow_id bigint, from_version text, from_step text, to_step text, problem text)
296
+ language plpgsql set search_path = meta, pg_catalog as $$
297
+ declare
298
+ d meta.workflow_definition;
299
+ v_order text[];
300
+ v_target int;
301
+ v_steps jsonb;
302
+ v_to jsonb;
303
+ v_from jsonb;
304
+ w record;
305
+ v_rows jsonb := '[]';
306
+ v_bad text[] := '{}';
307
+ v_id bigint;
308
+ r jsonb;
309
+ begin
310
+ select * into d from workflow_definition where id = p_definition_id;
311
+ if not found then
312
+ raise exception 'Workflow definition % does not exist.', p_definition_id;
313
+ end if;
314
+ if coalesce(cardinality(p_ids), 0) = 0 then
315
+ raise exception 'Choose the instances to migrate.';
316
+ end if;
317
+ if jsonb_typeof(coalesce(p_map, '{}')) <> 'object' then
318
+ raise exception 'The step mapping is a JSON object.';
319
+ end if;
320
+ -- versions from the oldest to the active one
321
+ v_order := array(select x->>'version' from jsonb_array_elements(d.inactive_versions) with ordinality o(x, i) order by i) || d.version;
322
+ v_target := array_position(v_order, p_to_version);
323
+ if v_target is null then
324
+ raise exception 'Workflow % has no version % to migrate to (the active version or an inactive one).', d.name, p_to_version;
325
+ end if;
326
+ v_steps := case when p_to_version = d.version then d.steps
327
+ else (select x->'steps' from jsonb_array_elements(d.inactive_versions) x where x->>'version' = p_to_version limit 1) end;
328
+
329
+ foreach v_id in array (select array_agg(distinct x order by x) from unnest(p_ids) x) loop
330
+ select wf.id, wf.app_id, wf.definition_id, coalesce(wf.version, '1') as version, wf.state, wf.current_step, wf.steps, wf.waiting_task, wf.wait_until,
331
+ (select td.name from task tk join task_definition td on td.id = tk.definition_id where tk.id = wf.waiting_task) as task_name,
332
+ exists (select 1 from workflow_branch b where b.workflow_id = wf.id and b.state in ('active', 'waiting', 'faulted')) as branches
333
+ into w from workflow wf where wf.id = v_id for update;
334
+ workflow_id := v_id;
335
+ from_version := w.version;
336
+ from_step := w.current_step;
337
+ to_step := case when w.current_step is not null then p_map->>w.current_step end;
338
+ problem := null;
339
+ v_to := (select s from jsonb_array_elements(v_steps) s where s->>'name' = to_step limit 1);
340
+ v_from := (select s from jsonb_array_elements(w.steps) s where s->>'name' = w.current_step limit 1);
341
+ if w.id is null or w.definition_id is distinct from d.id or w.app_id <> d.app_id then
342
+ problem := 'not an instance of this workflow';
343
+ from_version := null;
344
+ from_step := null;
345
+ to_step := null;
346
+ elsif w.state not in ('active', 'waiting', 'faulted') then
347
+ problem := format('is %s', w.state);
348
+ elsif coalesce(array_position(v_order, w.version), 0) >= v_target then
349
+ problem := format('runs version %s, which is not older than %s', w.version, p_to_version);
350
+ elsif w.branches then
351
+ problem := 'runs parallel branches: let them reach their join first';
352
+ elsif to_step is null or to_step = '' then
353
+ problem := format('no step of version %s is mapped to %s', p_to_version, coalesce(w.current_step, '(none)'));
354
+ elsif v_to is null then
355
+ problem := format('version %s has no step %s', p_to_version, to_step);
356
+ elsif v_to->>'type' = 'draft' then
357
+ problem := format('%s is a draft step', to_step);
358
+ elsif w.waiting_task is not null and v_to->>'type' <> 'task' then
359
+ problem := format('waits for a task: %s must be a task step', to_step);
360
+ elsif w.waiting_task is not null and upper(v_to->>'task') is distinct from w.task_name then
361
+ problem := format('waits for a task of %s, but %s creates %s', w.task_name, to_step, coalesce(v_to->>'task', '(none)'));
362
+ elsif w.state = 'waiting' and w.waiting_task is null and v_from->>'type' = 'invoke_api' then
363
+ problem := 'is calling a web service: try again when the call is done';
364
+ elsif w.state = 'waiting' and w.waiting_task is null and v_from->>'type' = 'wait' and v_to->>'type' <> 'wait' then
365
+ problem := format('waits at a wait step: %s must be a wait step', to_step);
366
+ elsif w.state = 'waiting' and w.waiting_task is null and v_from->>'type' = 'join' and v_to->>'type' <> 'join' then
367
+ problem := format('waits at a join: %s must be a join step', to_step);
368
+ end if;
369
+ if problem is not null then
370
+ v_bad := v_bad || format('%s %s', v_id, problem);
371
+ end if;
372
+ v_rows := v_rows || jsonb_build_object('id', v_id, 'from_version', from_version, 'from_step', from_step, 'to_step', to_step);
373
+ return next;
374
+ end loop;
375
+
376
+ if not p_apply then
377
+ return;
378
+ end if;
379
+ if cardinality(v_bad) > 0 then
380
+ raise exception 'Nothing was migrated: instance %.', array_to_string(v_bad, '; instance ');
381
+ end if;
382
+ for r in select * from jsonb_array_elements(v_rows) loop
383
+ update workflow set steps = v_steps, version = p_to_version, current_step = r->>'to_step', updated_at = now()
384
+ where id = (r->>'id')::bigint;
385
+ perform meta.workflow_log((r->>'id')::bigint, r->>'to_step', 'migrated',
386
+ format('version %s → %s: %s → %s (by %s)', r->>'from_version', p_to_version, r->>'from_step', r->>'to_step', p_by));
387
+ end loop;
388
+ insert into workflow_migration (app_id, definition_name, to_version, mapping, workflow_ids, detail, migrated_by)
389
+ values (d.app_id, d.name, p_to_version, coalesce(p_map, '{}'), array(select (x->>'id')::bigint from jsonb_array_elements(v_rows) x), v_rows, p_by);
390
+ -- active ones go on with their new step
391
+ perform pg_notify('pgkiln_workflow', 'migrated');
392
+ end
393
+ $$;
394
+ revoke all on function meta.migrate_workflows(int, text, bigint[], jsonb, boolean, text) from public;
395
+
396
+ -- Same as 072, plus the definition's custom outcomes (the task list's buttons).
397
+ create or replace view meta.tasks with (security_barrier) as
398
+ select t.id, t.app_id, d.name as definition, d.type, t.subject, t.detail_pk, t.params, t.state, t.outcome,
399
+ t.priority, t.initiator, t.actual_owner, t.owner_users, t.owner_roles, t.created_at, t.due_at,
400
+ t.completed_at, t.completed_by, d.details_page, d.details_item,
401
+ t.due_at is not null and t.due_at < now() and t.state in ('unassigned', 'assigned') as overdue,
402
+ (r).is_initiator, (r).is_owner, (r).is_admin, (r).may_act, (r).may_claim, (r).may_release, (r).may_delegate, (r).may_cancel,
403
+ t.tenant_id, d.outcomes
404
+ from (select t, meta.task_rights(t) as r from meta.task t where t.app_id = meta.app_id()) x
405
+ cross join lateral (select (x.t).*) t
406
+ join meta.task_definition d on d.id = t.definition_id
407
+ where (x.r).may_see;
@@ -0,0 +1,51 @@
1
+ -- =====================================================================
2
+ -- 124: component details of APEX 26.1 (sprint 115, WS-11)
3
+ --
4
+ -- Dynamic actions:
5
+ -- * event "menu_entry" (APEX: Menu entry chosen): a menu button's entry
6
+ -- {"label": "…", "action": "NAME"} runs the dynamic actions of event
7
+ -- menu_entry whose trigger names the entry (NAME) or the menu button
8
+ -- (any of its entries; the entry's name is the value a client-side
9
+ -- condition tests).
10
+ -- * actions "wait_popup" and "remove_wait_popup" (APEX: Show / Remove
11
+ -- Wait Popup): a modal "please wait" overlay (Message is its text)
12
+ -- until remove_wait_popup or the next page; e.g. before_submit →
13
+ -- wait_popup around a long process, or wait_popup, execute_sql,
14
+ -- remove_wait_popup around an Ajax callback.
15
+ -- Menu entries: exactly one of "page", "request" or "action" (new).
16
+ -- No new tables or columns: the button's menu jsonb and the dynamic
17
+ -- action's event/action travel with export_app / import_app unchanged.
18
+ -- =====================================================================
19
+
20
+ alter table meta.dynamic_action drop constraint dynamic_action_event_check;
21
+ alter table meta.dynamic_action add constraint dynamic_action_event_check check (event in (
22
+ 'change', 'click', 'load', 'dialog_closed', 'after_refresh', 'selection_change', 'focus', 'blur', 'key_release', 'before_submit', 'custom',
23
+ 'menu_entry'));
24
+
25
+ alter table meta.dynamic_action drop constraint dynamic_action_action_check;
26
+ alter table meta.dynamic_action add constraint dynamic_action_action_check check (action in (
27
+ 'show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit',
28
+ 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin',
29
+ 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print',
30
+ 'current_position', 'share', 'wait_popup', 'remove_wait_popup'));
31
+
32
+ -- A menu entry: {"label": "…", "page": 3, "items": {"P3_ID": "&P2_ID."}} (a link),
33
+ -- {"label": "…", "request": "ARCHIVE"} (submits the page with that request) or
34
+ -- {"label": "…", "action": "EXPORT"} (runs the dynamic actions of event menu_entry);
35
+ -- optional "confirm", "authz" and "icon". At most 20 entries.
36
+ create or replace function meta.button_menu_ok(p_menu jsonb) returns boolean
37
+ language sql immutable set search_path = meta, pg_catalog as $$
38
+ select jsonb_typeof(p_menu) = 'array' and jsonb_array_length(p_menu) <= 20
39
+ and not exists (
40
+ select 1 from jsonb_array_elements(p_menu) e
41
+ where jsonb_typeof(e) <> 'object'
42
+ or jsonb_typeof(e->'label') is distinct from 'string' or length(e->>'label') not between 1 and 100
43
+ or (e ? 'page')::int + (e ? 'request')::int + (e ? 'action')::int <> 1
44
+ or (e ? 'page' and (jsonb_typeof(e->'page') <> 'number' or (e->>'page')::numeric <= 0 or (e->>'page')::numeric <> floor((e->>'page')::numeric)))
45
+ or (e ? 'request' and coalesce(e->>'request', '') !~ '^[A-Z][A-Z0-9_]*$')
46
+ or (e ? 'action' and (jsonb_typeof(e->'action') <> 'string' or e->>'action' !~ '^[A-Z][A-Z0-9_]{0,59}$'))
47
+ or (e ? 'items' and jsonb_typeof(e->'items') <> 'object')
48
+ or (e ? 'confirm' and jsonb_typeof(e->'confirm') <> 'string')
49
+ or (e ? 'icon' and coalesce(e->>'icon', '') !~ '^[a-z][a-z0-9-]{0,40}$')
50
+ or (e ? 'authz' and coalesce(e->>'authz', '') !~ '^!?[A-Z][A-Z0-9_]*$'))
51
+ $$;
@@ -0,0 +1,23 @@
1
+ -- =====================================================================
2
+ -- 125: who changed an application last (sprint 116, WS-12)
3
+ --
4
+ -- APEX's Applications report shows Updated By next to Updated. The builder
5
+ -- records the developer and the time of the last successful change to an
6
+ -- application (any builder POST under /builder/apps/:id or /pages/:pid,
7
+ -- except locks, comments and other actions that change nothing; and the
8
+ -- application a Create or Import made) in meta.app_change: one row per
9
+ -- application. The App Builder list shows it, with the number of working
10
+ -- copies (meta.working_copy) and the application lock (meta.builder_lock).
11
+ --
12
+ -- Builder state of this installation, like meta.builder_lock: written by the
13
+ -- builder as the owner, never read by the runtime; not part of an
14
+ -- application's export (kept by pgkiln import --replace). Not a column of
15
+ -- meta.app on purpose: export_app exports meta.app's columns.
16
+ -- =====================================================================
17
+
18
+ create table meta.app_change (
19
+ app_id int primary key references meta.app on delete cascade,
20
+ updated_by text not null check (length(updated_by) between 1 and 200),
21
+ updated_at timestamptz not null default now()
22
+ );
23
+ comment on table meta.app_change is 'builder: the developer who changed an application last, and when (App Builder list: Updated by)';
@@ -0,0 +1,119 @@
1
+ -- =====================================================================
2
+ -- 126: Administration 26.1 / 26.2 (sprint 118, WS-9): the mail queue,
3
+ -- a workspace's own mail service and its network access list
4
+ --
5
+ -- meta.workspace.network_restricted / network_hosts: a workspace's
6
+ -- outbound allow-list (APEX 26.2: workspace-level network ACLs). When
7
+ -- restricted, every outgoing web call made for one of its applications
8
+ -- (meta.web_request, REST data sources, invoke API, web credential token
9
+ -- endpoints, object storage, REST source catalogs) must match an entry
10
+ -- here AND the installation's list (PGKILN_REST_ALLOWED_HOSTS /
11
+ -- PGKILN_REST_PRIVATE_HOSTS): the workspace's list only narrows. Entries:
12
+ -- "api.example.com", "*.example.com" (subdomains), "host:8443" (one
13
+ -- port). Restricted with no entries: no outgoing calls at all. Checked in
14
+ -- src/webclient.ts on every hop (redirects too); installation data like
15
+ -- isolation (not in the workspace export).
16
+ -- meta.mail_service.workspace_id: a workspace's own mail service (APEX
17
+ -- 26.1: workspace-level SMTP credentials), managed by the workspace's
18
+ -- administrators; null = an installation service (administrators). Its
19
+ -- secret is encrypted like a web credential's and never shown or
20
+ -- exported. Applications of the workspace without a service of their own
21
+ -- send through it. Its SMTP host is checked like a web request's host.
22
+ -- meta.mail_queue.last_attempt_at / duration_ms / provider: when the last
23
+ -- attempt to hand a mail over ended, how long the provider took, and which
24
+ -- provider it was (Administration → Mail queue).
25
+ -- meta.network_refusal: outgoing calls refused by the allow-lists, per
26
+ -- workspace and application (Administration → Network access), kept 7
27
+ -- days. Installation data: not exported, kept by an in-place import.
28
+ -- =====================================================================
29
+
30
+ -- one entry of a network access list: host, *.domain, host:port, [IPv6]:port (lower case)
31
+ create function meta.network_host_ok(p text) returns boolean
32
+ language sql immutable set search_path = pg_catalog as $$
33
+ select p is not null and length(p) <= 260 and (
34
+ p ~ '^(\*\.)?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*(:[1-9][0-9]{0,4})?$'
35
+ or p ~ '^\[[0-9a-f:.]{2,45}\](:[1-9][0-9]{0,4})?$')
36
+ and coalesce(nullif(substring(p from ':([0-9]+)$'), '')::int, 1) between 1 and 65535
37
+ $$;
38
+
39
+ create function meta.network_hosts_ok(p text[]) returns boolean
40
+ language sql immutable set search_path = pg_catalog as $$
41
+ select p is not null and cardinality(p) <= 200 and coalesce(bool_and(meta.network_host_ok(h)), true) from unnest(p) h
42
+ $$;
43
+ revoke all on function meta.network_host_ok(text), meta.network_hosts_ok(text[]) from public;
44
+
45
+ alter table meta.workspace
46
+ add column network_restricted boolean not null default false,
47
+ add column network_hosts text[] not null default '{}' constraint workspace_network_hosts_ok check (meta.network_hosts_ok(network_hosts));
48
+ comment on column meta.workspace.network_restricted is 'Outgoing web calls of the workspace''s applications are limited to network_hosts (126), within the installation''s list';
49
+ comment on column meta.workspace.network_hosts is 'The workspace''s network access list (126): host, *.domain or host:port entries';
50
+
51
+ -- a workspace's own mail service (one at most); null: an installation service
52
+ alter table meta.mail_service add column workspace_id int references meta.workspace on delete cascade;
53
+ create unique index mail_service_workspace_key on meta.mail_service (workspace_id) where workspace_id is not null;
54
+ comment on column meta.mail_service.workspace_id is 'A workspace''s own mail service (126), managed by its administrators; null = the installation''s';
55
+
56
+ alter table meta.mail_queue
57
+ add column last_attempt_at timestamptz,
58
+ add column duration_ms int,
59
+ add column provider text;
60
+ create index mail_queue_status_attempt on meta.mail_queue (status, last_attempt_at);
61
+ comment on column meta.mail_queue.duration_ms is 'How long the provider took to accept or refuse the last attempt (126)';
62
+
63
+ -- The mail service an application sends through: the one an administrator gave it (an installation
64
+ -- service, or its own workspace's), else its workspace's own service. Null: none.
65
+ create function meta.mail_service_for_app(p_app_id int) returns int
66
+ language sql stable security definer set search_path = meta, pg_catalog as $$
67
+ select coalesce(
68
+ (select x.service_id from meta.app_mail_service x join meta.mail_service s on s.id = x.service_id
69
+ where x.app_id = p_app_id and (s.workspace_id is null or s.workspace_id = meta.app_workspace(p_app_id))),
70
+ case when not exists (select 1 from meta.app_mail_service x where x.app_id = p_app_id)
71
+ then (select s.id from meta.mail_service s where s.workspace_id = meta.app_workspace(p_app_id)) end)
72
+ $$;
73
+ revoke all on function meta.mail_service_for_app(int) from public;
74
+
75
+ -- (081) the calling application and its mail service; now also its workspace's own service
76
+ create or replace function meta.mail_caller(out app_id int, out service_id int, out from_address text, out max_per_day int, out senders text[])
77
+ language plpgsql stable security definer set search_path = meta, pg_catalog as $$
78
+ declare
79
+ v_role text;
80
+ begin
81
+ app_id := meta.app_id();
82
+ if app_id is null then
83
+ raise exception 'meta.send_mail: no current application (call it from application code)';
84
+ end if;
85
+ select a.db_role into v_role from meta.app a where a.id = app_id;
86
+ if not found then
87
+ raise exception 'meta.send_mail: no current application (call it from application code)';
88
+ end if;
89
+ if v_role is not null and current_setting('role') is distinct from v_role then
90
+ raise exception 'meta.send_mail: called outside the application''s own database role';
91
+ end if;
92
+ select s.id, coalesce(x.from_address, nullif(s.settings->>'default_from', '')), x.max_per_day, s.senders
93
+ into service_id, from_address, max_per_day, senders
94
+ from meta.mail_service s left join meta.app_mail_service x on x.app_id = mail_caller.app_id and x.service_id = s.id
95
+ where s.id = meta.mail_service_for_app(mail_caller.app_id) and s.enabled;
96
+ if not found then
97
+ raise exception 'meta.send_mail: this application may not send e-mail (an administrator chooses its mail service under Workspace utilities → Mail services, or the workspace''s administrators set up the workspace''s own)';
98
+ end if;
99
+ end
100
+ $$;
101
+ revoke all on function meta.mail_caller() from public;
102
+
103
+ create table meta.network_refusal (
104
+ id bigint generated always as identity primary key,
105
+ at timestamptz not null default now(),
106
+ workspace_id int references meta.workspace on delete cascade,
107
+ app_id int references meta.app on delete cascade,
108
+ -- what made the call: web request, REST data source, invoke API, token endpoint, object storage, REST catalog, mail
109
+ what text not null check (length(what) <= 100),
110
+ -- host[:port] and the URL without its query string (it may carry keys)
111
+ host text not null check (length(host) <= 300),
112
+ url text check (length(url) <= 1000),
113
+ -- installation (not on the installation's list) or workspace (not on the workspace's)
114
+ list text not null check (list in ('installation', 'workspace')),
115
+ message text check (length(message) <= 1000)
116
+ );
117
+ create index network_refusal_ws on meta.network_refusal (workspace_id, at desc);
118
+ revoke all on meta.network_refusal from public;
119
+ comment on table meta.network_refusal is 'Outgoing web calls refused by the network access lists (126), kept 7 days; installation data, not exported';