pgkiln 0.80.0 → 0.82.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 (85) hide show
  1. package/db/migrations/146_database_model_providers.sql +53 -0
  2. package/db/migrations/147_condition_types.sql +80 -0
  3. package/db/migrations/148_validation_types.sql +44 -0
  4. package/db/migrations/149_dynamic_action_events.sql +45 -0
  5. package/db/migrations/150_process_types.sql +23 -0
  6. package/db/migrations/151_item_types.sql +16 -0
  7. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  8. package/docs/apex-feature-parity.md +12 -10
  9. package/docs/guide/02-concepts.md +3 -3
  10. package/docs/guide/03-builder.md +13 -3
  11. package/docs/guide/04-pages-and-regions.md +17 -3
  12. package/docs/guide/05-items.md +5 -0
  13. package/docs/guide/06-processing.md +104 -17
  14. package/docs/guide/07-dynamic-actions.md +16 -1
  15. package/docs/guide/11-from-apex.md +7 -0
  16. package/examples/hr/hr_79_condition_types.sql +17 -0
  17. package/package.json +1 -1
  18. package/public/app.css +12 -0
  19. package/public/app.js +157 -9
  20. package/src/ai/embeddings.ts +20 -7
  21. package/src/appfiles.ts +4 -4
  22. package/src/builder/advisor.ts +43 -7
  23. package/src/builder/code-editor.ts +14 -10
  24. package/src/builder/components.ts +51 -21
  25. package/src/builder/conditions.ts +64 -0
  26. package/src/builder/db-users.ts +124 -118
  27. package/src/builder/designer.ts +9 -1
  28. package/src/builder/home.ts +42 -40
  29. package/src/builder/i18n/de.ts +464 -0
  30. package/src/builder/i18n/en.ts +464 -0
  31. package/src/builder/i18n/es.ts +464 -0
  32. package/src/builder/i18n/fr.ts +464 -0
  33. package/src/builder/i18n/it.ts +464 -0
  34. package/src/builder/i18n/ja.ts +464 -0
  35. package/src/builder/i18n/ko.ts +464 -0
  36. package/src/builder/i18n/nl.ts +464 -0
  37. package/src/builder/i18n/pt.ts +464 -0
  38. package/src/builder/i18n/zh-cn.ts +464 -0
  39. package/src/builder/i18n/zh-tw.ts +464 -0
  40. package/src/builder/instance.ts +27 -23
  41. package/src/builder/ldap.ts +50 -47
  42. package/src/builder/mail-queue.ts +50 -45
  43. package/src/builder/network-access.ts +46 -43
  44. package/src/builder/region-help.ts +3 -1
  45. package/src/builder/region-settings.ts +30 -5
  46. package/src/builder/vector-column.ts +3 -3
  47. package/src/builder/vector-providers.ts +16 -5
  48. package/src/i18n/ar.ts +5 -0
  49. package/src/i18n/cs.ts +5 -0
  50. package/src/i18n/da.ts +5 -0
  51. package/src/i18n/de.ts +5 -0
  52. package/src/i18n/el.ts +5 -0
  53. package/src/i18n/es.ts +5 -0
  54. package/src/i18n/fi.ts +5 -0
  55. package/src/i18n/fr.ts +5 -0
  56. package/src/i18n/he.ts +5 -0
  57. package/src/i18n/it.ts +5 -0
  58. package/src/i18n/ja.ts +5 -0
  59. package/src/i18n/ko.ts +5 -0
  60. package/src/i18n/nb.ts +5 -0
  61. package/src/i18n/pl.ts +5 -0
  62. package/src/i18n/pt.ts +5 -0
  63. package/src/i18n/ru.ts +5 -0
  64. package/src/i18n/sv.ts +5 -0
  65. package/src/i18n/tr.ts +5 -0
  66. package/src/i18n/uk.ts +5 -0
  67. package/src/i18n/zh.ts +5 -0
  68. package/src/i18n.ts +10 -0
  69. package/src/metadata.ts +72 -9
  70. package/src/runtime/authz.ts +9 -5
  71. package/src/runtime/context.ts +4 -0
  72. package/src/runtime/da-styles.ts +37 -0
  73. package/src/runtime/documents.ts +3 -2
  74. package/src/runtime/engine.ts +95 -13
  75. package/src/runtime/items.ts +33 -0
  76. package/src/runtime/logic.ts +79 -8
  77. package/src/runtime/maps.ts +50 -4
  78. package/src/runtime/process-types.ts +139 -0
  79. package/src/runtime/processes.ts +2 -0
  80. package/src/runtime/regions.ts +4 -0
  81. package/src/runtime/render.ts +13 -2
  82. package/src/runtime/routes.ts +2 -2
  83. package/src/runtime/workflow-diagram.ts +45 -0
  84. package/src/vector.ts +3 -3
  85. package/src/workflow.ts +10 -2
@@ -60,10 +60,35 @@ interface Point {
60
60
  href: string | null;
61
61
  weight: number;
62
62
  }
63
+ /** (159, CD-6) a shape layer's look: fixed in the layer's settings, or per row from columns of the same names */
64
+ export interface ShapeStyle {
65
+ fill_color?: string;
66
+ stroke_color?: string;
67
+ stroke_width?: number;
68
+ opacity?: number;
69
+ }
70
+ const COLOR = /^#[0-9a-fA-F]{6}$/;
71
+ /** The checked parts of a style (anything else is left out). */
72
+ export function shapeStyle(x: unknown): ShapeStyle {
73
+ const s = (x && typeof x === 'object' ? x : {}) as Record<string, unknown>;
74
+ const out: ShapeStyle = {};
75
+ if (typeof s.fill_color === 'string' && COLOR.test(s.fill_color)) out.fill_color = s.fill_color;
76
+ if (typeof s.stroke_color === 'string' && COLOR.test(s.stroke_color)) out.stroke_color = s.stroke_color;
77
+ const w = Number(s.stroke_width);
78
+ if (s.stroke_width !== undefined && s.stroke_width !== null && Number.isFinite(w) && w >= 0 && w <= 20) out.stroke_width = w;
79
+ const o = Number(s.opacity);
80
+ if (s.opacity !== undefined && s.opacity !== null && Number.isFinite(o) && o >= 0 && o <= 1) out.opacity = o;
81
+ return out;
82
+ }
83
+ const SHAPE_KINDS = new Set(['lines', 'polygons', 'extruded']);
84
+ const kindOf = (v: unknown): LayerDef['kind'] => (v === 'heat' || SHAPE_KINDS.has(v as string) ? (v as LayerDef['kind']) : 'markers');
85
+
63
86
  export interface LayerDef {
64
87
  name: string;
65
88
  source: string;
66
- kind: 'markers' | 'heat';
89
+ /** (159) lines, polygons: GeoJSON / PostGIS shapes with a style; extruded: polygons shaded by a height column */
90
+ kind: 'markers' | 'heat' | 'lines' | 'polygons' | 'extruded';
91
+ style: ShapeStyle;
67
92
  cluster: boolean;
68
93
  link: Link | undefined;
69
94
  hidden: boolean;
@@ -78,7 +103,8 @@ export function layerDefs(r: Region): LayerDef[] {
78
103
  const first: LayerDef = {
79
104
  name: typeof r.config.name === 'string' && r.config.name.trim() ? r.config.name.trim() : (r.title ?? 'Map'),
80
105
  source: r.source ?? '',
81
- kind: r.config.layer === 'heat' ? 'heat' : 'markers',
106
+ kind: kindOf(r.config.layer),
107
+ style: shapeStyle(r.config.style),
82
108
  cluster: r.config.cluster === true,
83
109
  link: r.config.link,
84
110
  hidden: false,
@@ -91,7 +117,8 @@ export function layerDefs(r: Region): LayerDef[] {
91
117
  .map((l: any, i: number): LayerDef => ({
92
118
  name: typeof l.name === 'string' && l.name.trim() ? l.name.trim() : `Layer ${i + 2}`,
93
119
  source: l.source,
94
- kind: l.layer === 'heat' ? 'heat' : 'markers',
120
+ kind: kindOf(l.layer),
121
+ style: shapeStyle(l.style),
95
122
  cluster: l.cluster === true,
96
123
  link: l.link && typeof l.link === 'object' && Number.isInteger(l.link.page) ? l.link : undefined,
97
124
  hidden: l.hidden === true,
@@ -151,12 +178,31 @@ async function loadLayer(ctx: PageContext, def: LayerDef, g: PostGis | null, opt
151
178
  if (geometry?.type === 'Point' && Array.isArray(geometry.coordinates) && (Number.isNaN(lat) || Number.isNaN(lng)))
152
179
  // a point is a place (a marker, a heat spot, part of a cluster)
153
180
  [lng, lat] = geometry.coordinates.map(Number);
154
- else if (geometry && typeof geometry === 'object') shapes.push({ type: 'Feature', geometry, properties: { title, body, href } });
181
+ else if (geometry && typeof geometry === 'object') {
182
+ // (159) a shape layer's style: the layer's, then the row's own columns
183
+ const style = SHAPE_KINDS.has(def.kind)
184
+ ? { ...def.style, ...shapeStyle({ fill_color: col(row, 'fill_color'), stroke_color: col(row, 'stroke_color'), stroke_width: col(row, 'stroke_width'), opacity: col(row, 'opacity') }) }
185
+ : undefined;
186
+ const h = num(col(row, 'height'));
187
+ shapes.push({ type: 'Feature', geometry, properties: { title, body, href, ...(style ? { style } : {}), ...(def.kind === 'extruded' && Number.isFinite(h) ? { height: h } : {}) } });
188
+ }
155
189
  }
190
+ // a shape layer draws only shapes
191
+ if (SHAPE_KINDS.has(def.kind)) continue;
156
192
  const w = num(col(row, 'weight'));
157
193
  const weight = Number.isFinite(w) && w > 0 ? w : Number.isNaN(w) ? 1 : 0;
158
194
  if (Math.abs(lat) <= 90 && Math.abs(lng) <= 180) points.push({ lat, lng, title, body, href, weight });
159
195
  }
196
+ // (159) extruded: each polygon shaded by its height (from light to strong; no 3D view)
197
+ if (def.kind === 'extruded') {
198
+ const hs = shapes.map((f: any) => f.properties.height).filter((h): h is number => typeof h === 'number');
199
+ const [lo, hi] = hs.length ? [Math.min(...hs), Math.max(...hs)] : [0, 0];
200
+ for (const f of shapes as any[]) {
201
+ const h = f.properties.height;
202
+ const share = typeof h === 'number' && hi > lo ? (h - lo) / (hi - lo) : 0.5;
203
+ f.properties.style = { ...f.properties.style, opacity: Math.round((0.2 + 0.7 * share) * 100) / 100 };
204
+ }
205
+ }
160
206
  return { points, shapes, truncated };
161
207
  }
162
208
 
@@ -0,0 +1,139 @@
1
+ // (sprint 157, CD-4) Declarative process types APEX applications rely on (migration 150). Each one calls what pgkiln
2
+ // already has: session state, user preferences, the geocoder, document templates, human tasks.
3
+ import type { Process } from '../metadata.ts';
4
+ import { geocode, geocoderUrl } from '../geocode.ts';
5
+ import { substitute, toState, type PageContext } from './context.ts';
6
+ import { renderDocument } from './documents.ts';
7
+
8
+ const ITEM = /^[A-Z][A-Z0-9_]{0,99}$/;
9
+ const NAME = /^[A-Z][A-Z0-9_]{0,59}$/;
10
+ const isItem = (x: unknown) => typeof x === 'string' && ITEM.test(x);
11
+ const items = (x: unknown) => Array.isArray(x) && x.every(isItem);
12
+ const text = (x: unknown) => x === undefined || typeof x === 'string';
13
+
14
+ export const DECLARATIVE_PROCESS_TYPES = new Set(['close_dialog', 'clear_session_state', 'reset_pagination', 'user_preferences', 'server_geocoding',
15
+ 'print_report', 'human_task_create', 'human_task_manage', 'form_init']);
16
+ const TASK_ACTIONS = ['approve', 'reject', 'complete', 'claim', 'release', 'delegate', 'cancel'];
17
+
18
+ /** Problems with a declarative process's config (the builder shows them; running refuses them). */
19
+ export function declarativeProblems(type: string, conf: unknown): string[] {
20
+ const c = (conf ?? {}) as Record<string, any>;
21
+ const out: string[] = [];
22
+ switch (type) {
23
+ case 'close_dialog':
24
+ if (c.items !== undefined && !items(c.items)) out.push('"items" is a list of item names, e.g. ["P5_ID"].');
25
+ break;
26
+ case 'clear_session_state':
27
+ if (c.items !== undefined && !items(c.items)) out.push('"items" is a list of item names.');
28
+ if (c.pages !== undefined && !(Array.isArray(c.pages) && c.pages.every((n: unknown) => Number.isInteger(n) && (n as number) > 0))) out.push('"pages" is a list of page numbers.');
29
+ if (c.all !== undefined && typeof c.all !== 'boolean') out.push('"all" is true or false.');
30
+ if (!c.items && !c.pages && !c.all) out.push('Name "items", "pages" or "all": true.');
31
+ break;
32
+ case 'user_preferences':
33
+ if (!['set', 'reset'].includes(c.action ?? 'set')) out.push('"action" is set or reset.');
34
+ if (typeof c.preference !== 'string' || !NAME.test(c.preference.toUpperCase())) out.push('"preference" is a name, e.g. DEFAULT_DEPT.');
35
+ if ((c.action ?? 'set') === 'set' && !isItem(c.item)) out.push('"item" names the item whose value is stored.');
36
+ break;
37
+ case 'server_geocoding':
38
+ if (!isItem(c.address_item)) out.push('"address_item" is the item with the address.');
39
+ if (!isItem(c.coordinates_item)) out.push('"coordinates_item" is the item that gets "lat,lng".');
40
+ if (c.country !== undefined && !(typeof c.country === 'string' && /^[a-z]{2}(,[a-z]{2})*$/i.test(c.country))) out.push('"country" is a country code, e.g. nl.');
41
+ break;
42
+ case 'print_report':
43
+ if (typeof c.document !== 'string' || !NAME.test(c.document.toUpperCase())) out.push('"document" names a document template or report query.');
44
+ break;
45
+ case 'human_task_create':
46
+ if (typeof c.definition !== 'string' || !NAME.test(c.definition.toUpperCase())) out.push('"definition" names a task definition, e.g. LEAVE_APPROVAL.');
47
+ if (!text(c.detail_pk)) out.push('"detail_pk" is text, e.g. "&P7_ID.".');
48
+ if (c.parameters !== undefined && (typeof c.parameters !== 'object' || c.parameters === null || Array.isArray(c.parameters) || Object.values(c.parameters).some((v) => typeof v !== 'string')))
49
+ out.push('"parameters" maps parameter names to values, e.g. {"DAYS": "&P7_DAYS."}.');
50
+ if (c.id_item !== undefined && !isItem(c.id_item)) out.push('"id_item" is an item name.');
51
+ break;
52
+ case 'human_task_manage':
53
+ if (!TASK_ACTIONS.includes(c.action)) out.push(`"action" is one of ${TASK_ACTIONS.join(', ')}.`);
54
+ if (typeof c.task !== 'string' || !c.task) out.push('"task" is the task id, e.g. "&P8_TASK_ID.".');
55
+ if (c.action === 'complete' && (typeof c.outcome !== 'string' || !NAME.test(c.outcome.toUpperCase()))) out.push('"outcome" names the outcome, e.g. APPROVED.');
56
+ if (c.action === 'delegate' && (typeof c.to !== 'string' || !c.to)) out.push('"to" is the user it goes to, e.g. "&P8_TO.".');
57
+ if (!text(c.comment)) out.push('"comment" is text.');
58
+ break;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** Run a declarative process; its success message (or a default) is the answer. */
64
+ export async function runDeclarative(ctx: PageContext, p: Process, assignable: Set<string>): Promise<string | null> {
65
+ const conf = (p.config ?? {}) as Record<string, any>;
66
+ const problems = declarativeProblems(p.type, conf);
67
+ if (problems.length) throw new Error(`Process "${p.name}": ${problems.join(' ')}`);
68
+ const c = ctx.client!;
69
+ const value = (s: unknown) => (typeof s === 'string' ? substitute(s, ctx, (x) => x) : null);
70
+ const state = ctx.session.state;
71
+ const assign = (name: string, v: string | null) => {
72
+ if (!assignable.has(name)) throw new Error(`Process "${p.name}": ${name} is not an item of this page or an application item.`);
73
+ state[name] = v;
74
+ };
75
+ switch (p.type) {
76
+ case 'form_init':
77
+ return null;
78
+ case 'close_dialog':
79
+ ctx.closeDialog = Object.fromEntries(((conf.items ?? []) as string[]).map((n) => [n, state[n] ?? '']));
80
+ return p.success_message;
81
+ case 'reset_pagination':
82
+ ctx.resetPagination = true;
83
+ return p.success_message;
84
+ case 'clear_session_state': {
85
+ const names = new Set<string>(conf.items ?? []);
86
+ const prefixes = ((conf.pages ?? []) as number[]).map((n) => `P${n}_`);
87
+ for (const k of Object.keys(state)) {
88
+ if (k.startsWith('__')) continue;
89
+ if (names.has(k) || prefixes.some((x) => k.startsWith(x)) || (conf.all === true && (/^P\d+_/.test(k) || ctx.app.app_items.includes(k)))) state[k] = null;
90
+ }
91
+ return p.success_message;
92
+ }
93
+ case 'user_preferences': {
94
+ // the public user has no preferences (as meta.get_preference): nothing to store
95
+ if (ctx.user === 'nobody') return null;
96
+ const name = String(conf.preference).toUpperCase();
97
+ if ((conf.action ?? 'set') === 'reset') await c.query('select meta.remove_preference($1)', [name]);
98
+ else await c.query('select meta.set_preference($1, $2)', [name, state[conf.item] ?? null]);
99
+ return p.success_message;
100
+ }
101
+ case 'server_geocoding': {
102
+ const address = (state[conf.address_item] ?? '').trim();
103
+ if (!address) return null;
104
+ if (!geocoderUrl()) throw new Error(ctx.locale.t('process.no_geocoder'));
105
+ const [first] = await geocode(address, { language: ctx.locale.lang, country: conf.country ?? null });
106
+ if (!first) throw new Error(ctx.locale.t('process.address_not_found', { address }));
107
+ assign(conf.coordinates_item, `${first.lat.toFixed(6)},${first.lon.toFixed(6)}`);
108
+ return p.success_message;
109
+ }
110
+ case 'print_report': {
111
+ const doc = await renderDocument(ctx, String(conf.document), true);
112
+ // the first file of a request wins (as for download processes)
113
+ ctx.download ??= 'download' in doc ? doc.download : { content: doc.file, name: doc.name, type: doc.type, inline: false };
114
+ return null;
115
+ }
116
+ case 'human_task_create': {
117
+ const params = Object.fromEntries(Object.entries((conf.parameters ?? {}) as Record<string, string>).map(([k, v]) => [k.toUpperCase(), value(v)]));
118
+ const id = (await c.query('select meta.create_task($1, $2, $3::jsonb)::text as id', [String(conf.definition).toUpperCase(), value(conf.detail_pk) || null, JSON.stringify(params)])).rows[0].id as string;
119
+ if (conf.id_item) assign(conf.id_item, toState(id));
120
+ return p.success_message ?? ctx.locale.t('process.task_created', { id });
121
+ }
122
+ case 'human_task_manage': {
123
+ const id = (value(conf.task) ?? '').trim();
124
+ if (!/^\d{1,18}$/.test(id)) throw new Error(ctx.locale.t('process.no_task'));
125
+ const comment = value(conf.comment) || null;
126
+ switch (conf.action) {
127
+ case 'approve': await c.query('select meta.complete_task($1::bigint, $2, $3)', [id, 'APPROVED', comment]); break;
128
+ case 'reject': await c.query('select meta.complete_task($1::bigint, $2, $3)', [id, 'REJECTED', comment]); break;
129
+ case 'complete': await c.query('select meta.complete_task($1::bigint, $2, $3)', [id, String(conf.outcome).toUpperCase(), comment]); break;
130
+ case 'claim': await c.query('select meta.claim_task($1::bigint)', [id]); break;
131
+ case 'release': await c.query('select meta.release_task($1::bigint)', [id]); break;
132
+ case 'delegate': await c.query('select meta.delegate_task($1::bigint, $2)', [id, value(conf.to)]); break;
133
+ case 'cancel': await c.query('select meta.cancel_task($1::bigint, $2)', [id, comment]); break;
134
+ }
135
+ return p.success_message;
136
+ }
137
+ }
138
+ return null;
139
+ }
@@ -1,3 +1,4 @@
1
+ import { declarativeProblems, DECLARATIVE_PROCESS_TYPES } from './process-types.ts';
1
2
  import { zipSync, type Zippable } from 'fflate';
2
3
  import { applyBinds } from '../binds.ts';
3
4
  import { esc } from '../html.ts';
@@ -248,6 +249,7 @@ export function processProblems(type: string, conf: unknown): string[] {
248
249
  if (type === 'workflow') return workflowProblems(conf);
249
250
  if (type === 'send_push') return sendPushProblems(conf);
250
251
  if (type === 'send_email') return sendEmailProblems(conf);
252
+ if (DECLARATIVE_PROCESS_TYPES.has(type)) return declarativeProblems(type, conf);
251
253
  return [];
252
254
  }
253
255
 
@@ -1,3 +1,4 @@
1
+ import { renderWorkflowDiagram } from './workflow-diagram.ts';
1
2
  import type { QueryResult } from 'pg';
2
3
  import { ganttEditable } from './gantt-move.ts';
3
4
  import { applyBinds } from '../binds.ts';
@@ -379,6 +380,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
379
380
  case 'workflows':
380
381
  body = await renderWorkflows(ctx, r);
381
382
  break;
383
+ case 'workflow_diagram':
384
+ body = await renderWorkflowDiagram(ctx, r);
385
+ break;
382
386
  case 'map':
383
387
  body = await renderMap(ctx, r);
384
388
  break;
@@ -1,3 +1,4 @@
1
+ import { compareValues, safeStyles } from './da-styles.ts';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { urlChecksum } from '../security.ts';
3
4
  import { pwaBody, pwaHead } from './pwa.ts';
@@ -50,6 +51,9 @@ function dynamicActionsJson(ctx: PageContext) {
50
51
  fn: d.action === 'execute_javascript' && /^[A-Za-z_$][\w$.-]{0,99}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null,
51
52
  // plugin: the plug-in's name and attribute values (only a plug-in of the application)
52
53
  ...(d.action === 'plugin' ? pluginAction(ctx, d.code, d.config) : {}),
54
+ // (156) set_style: checked properties and values; open_report_dialog: the dialog's key (checked by the database)
55
+ ...(d.action === 'set_style' ? { styles: safeStyles(d.config) } : {}),
56
+ ...(d.action === 'open_report_dialog' ? { dialog: /^[a-z]{3,10}$/.test(d.code?.trim() ?? '') ? d.code!.trim() : null } : {}),
53
57
  }));
54
58
 
55
59
  function pluginAction(ctx: PageContext, name: string | null, config: Record<string, any> | null | undefined) {
@@ -65,6 +69,12 @@ function conditionHolds(type: string | null, expected: string | null, value: str
65
69
  case 'in_list': return (expected ?? '').split(',').map((x) => x.trim()).includes(value);
66
70
  case 'is_null': return value === '';
67
71
  case 'is_not_null': return value !== '';
72
+ // (156) greater / less: numbers as numbers, else text (ISO dates sort as text); not_in_list
73
+ case 'greater': return value !== '' && compareValues(value, expected ?? '') > 0;
74
+ case 'greater_equal': return value !== '' && compareValues(value, expected ?? '') >= 0;
75
+ case 'less': return value !== '' && compareValues(value, expected ?? '') < 0;
76
+ case 'less_equal': return value !== '' && compareValues(value, expected ?? '') <= 0;
77
+ case 'not_in_list': return !(expected ?? '').split(',').map((x) => x.trim()).includes(value);
68
78
  default: return true;
69
79
  }
70
80
  }
@@ -418,12 +428,13 @@ export async function renderPage(ctx: PageContext) {
418
428
  }
419
429
 
420
430
  /** Response to a successful submit inside a dialog: tells the opener to close it. */
421
- export function dialogClosePage(ctx: PageContext) {
431
+ /** (157) `items`: the values a close_dialog process hands to the page below (items of the same name there are set). */
432
+ export function dialogClosePage(ctx: PageContext, items?: Record<string, string>) {
422
433
  return documentShell(
423
434
  ctx.app.name,
424
435
  html`<main class="t-dialog-main"><p>${ctx.locale.t('dialog.done')} <a href="${ctx.base}/${ctx.app.home_page}">${ctx.locale.t('dialog.continue')}</a></p></main>`,
425
436
  't-dialog-page',
426
- { 'data-dialog-close': '1', 'data-dialog-page': String(ctx.page.page_no) },
437
+ { 'data-dialog-close': '1', 'data-dialog-page': String(ctx.page.page_no), ...(items && Object.keys(items).length ? { 'data-dialog-items': JSON.stringify(items) } : {}) },
427
438
  '',
428
439
  { lang: ctx.locale.lang, dir: ctx.locale.dir, theme: ctx.locale.theme, style: ctx.locale.style },
429
440
  );
@@ -630,9 +630,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
630
630
  }
631
631
  if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
632
632
  await saveState(ctx.session);
633
- if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx));
633
+ if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx, ctx.closeDialog));
634
634
  // (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
635
- const back = branchTo || button.target_page ? '' : gridReturn(ctx, button.name);
635
+ const back = branchTo || button.target_page || ctx.resetPagination ? '' : gridReturn(ctx, button.name);
636
636
  return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : back ? `${self}${self.includes('?') ? '&' : '?'}${back}` : self), 303);
637
637
  });
638
638
 
@@ -0,0 +1,45 @@
1
+ // (sprint 160, CD-7) Workflow Diagram region (APEX 24.2+): one workflow instance drawn as its steps, the ones it went
2
+ // through (green), where it is now (blue) and a failed step (red); each step's tooltip says when it was reached and
3
+ // what happened. config {"instance_item": "P9_WORKFLOW_ID"}. The instance comes through meta.workflows, as the
4
+ // application's role: only workflows the user started or administers (the Workflow Console's rule). Server-rendered SVG.
5
+ import { owner, savepoint } from '../db.ts';
6
+ import { html, type Raw } from '../html.ts';
7
+ import type { Region } from '../metadata.ts';
8
+ import { workflowDiagram, type Step } from '../workflow.ts';
9
+ import { publicError, type PageContext } from './context.ts';
10
+ import { cell } from './report.ts';
11
+
12
+ export async function renderWorkflowDiagram(ctx: PageContext, r: Region): Promise<Raw> {
13
+ const t = ctx.locale.t;
14
+ const itemName = typeof r.config.instance_item === 'string' ? r.config.instance_item.toUpperCase() : '';
15
+ const id = (itemName ? (ctx.session.state[itemName] ?? '') : '').trim();
16
+ if (!/^\d{1,18}$/.test(id)) return html`<p class="empty">${r.config.empty ?? t('workflows.none')}</p>`;
17
+ const c = ctx.client!;
18
+ let w: { id: string; title: string; state: string; current_step: string | null; error: string | null; active_steps: string[] } | undefined;
19
+ let events: { at: string; step: string | null; event: string; detail: string | null }[] = [];
20
+ try {
21
+ w = (await savepoint(c, () => c.query('select id::text, title, state, current_step, error, active_steps from meta.workflows where id = $1::bigint', [id]))).rows[0];
22
+ if (w) events = (await savepoint(c, () => c.query('select at::text, step, event, detail from meta.workflow_events where workflow_id = $1::bigint order by at, id', [id]))).rows;
23
+ } catch (e) {
24
+ return html`<div class="alert alert-error" role="alert">${await publicError(ctx, e, `workflow diagram "${r.title ?? r.id}"`)}</div>`;
25
+ }
26
+ // not one the user may see (or none): the same as no instance
27
+ if (!w) return html`<p class="empty">${r.config.empty ?? t('workflows.none')}</p>`;
28
+ // the steps of its version: only after the view let this user see the instance (the steps hold SQL)
29
+ const steps = (await owner.query<{ steps: Step[] }>('select steps from meta.workflow where id = $1::bigint and app_id = $2', [id, ctx.app.id])).rows[0]?.steps ?? [];
30
+ const reached = new Set(events.filter((e) => e.step).map((e) => e.step!));
31
+ const active = w.state === 'completed' || w.state === 'terminated' ? [] : w.active_steps.length ? w.active_steps : w.current_step ? [w.current_step] : [];
32
+ const failed = w.state === 'faulted' ? active : [];
33
+ const done = [...reached].filter((s) => !active.includes(s));
34
+ const notes = new Map<string, string>();
35
+ // events without a step (started, terminated) belong to the first step's history
36
+ for (const e of events) {
37
+ const step = e.step ?? steps[0]?.name;
38
+ if (step) notes.set(step, [notes.get(step), `${cell(e.at, 1184, ctx.locale.format)}: ${e.event}${e.detail ? ` (${e.detail})` : ''}`].filter(Boolean).join('\n'));
39
+ }
40
+ return html`<div class="wf-wrap wf-instance" data-state="${w.state}">
41
+ <p class="muted u-mt0">${w.title} · ${t(`workflows.state.${w.state}`)}</p>
42
+ ${w.error ? html`<div class="alert alert-error" role="alert">${w.error}</div>` : ''}
43
+ ${workflowDiagram(steps, { active: failed.length ? [] : active, failed, done, notes, id: `r${r.id}` })}
44
+ </div>`;
45
+ }
package/src/vector.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import pg from 'pg';
2
- import { embedTexts, loadVectorProviders, vectorLiteral, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, type VectorProvider } from './ai/embeddings.ts';
2
+ import { embedsInDatabase, embedTexts, loadVectorProviders, vectorLiteral, vectorProviderProblems, DEFAULT_BATCH, DEFAULT_PER_MINUTE, type VectorProvider } from './ai/embeddings.ts';
3
3
  import { isAiError } from './ai/types.ts';
4
4
  import { owner } from './db.ts';
5
5
  import { devDbForApp } from './workspace-db.ts';
@@ -127,8 +127,8 @@ async function runBatch(g: Group, p: VectorProvider): Promise<number> {
127
127
  const text = `meta.embedding_text(to_jsonb(t), $2::text[])`;
128
128
  try {
129
129
  let written = 0;
130
- if (p.type === 'function') {
131
- // a function provider: embedded in the database, as the developer connection (the trigger's own function)
130
+ if (embedsInDatabase(p)) {
131
+ // a function or database model provider: embedded in the database, as the developer connection (the trigger's own function)
132
132
  await db.query(
133
133
  `update ${table} t set ${id(g.vector_column)} = translate(meta.embed(${text}, $3, $4)::text, '{}', '[]')::text::${await vectorTypeOf(db)}
134
134
  where t.${id(g.key_column)}::text = any($1::text[])`, [rows.map((r) => r.key_value), g.source_columns, p.name, g.app_id]);
package/src/workflow.ts CHANGED
@@ -917,12 +917,16 @@ export async function startWorkflowRunner() {
917
917
  * marks the steps an instance is at (several with parallel branches); `id`
918
918
  * keeps the arrow marker unique when a page shows several diagrams.
919
919
  */
920
- export function workflowDiagram(steps: Step[], opts: { active?: string[]; id?: string; standalone?: { title: string } } = {}): Raw {
920
+ export function workflowDiagram(steps: Step[], opts: { active?: string[]; id?: string; standalone?: { title: string };
921
+ /** (160) the steps the instance went through, a step that failed, a tooltip per step (who and when) */
922
+ done?: string[]; failed?: string[]; notes?: Map<string, string> } = {}): Raw {
921
923
  if (!Array.isArray(steps) || !steps.length || steps.some((s) => !s || typeof s !== 'object' || typeof s.name !== 'string')) return html``;
922
924
  // (123) the export is an XML file: no control characters (the template escapes the rest)
923
925
  const txt = (v: unknown) => String(v ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '');
924
926
  const marker = `wf-arrow${opts.id ? `-${opts.id}` : ''}`;
925
927
  const active = new Set(opts.active ?? []);
928
+ const done = new Set(opts.done ?? []);
929
+ const failed = new Set(opts.failed ?? []);
926
930
  // levels: the longest path from the first step (cycles are cut)
927
931
  const level = new Map<string, number>([[steps[0].name, 0]]);
928
932
  const edges: { from: string; to: string; label: string; branch?: boolean }[] = [];
@@ -977,7 +981,9 @@ export function workflowDiagram(steps: Step[], opts: { active?: string[]; id?: s
977
981
  })}
978
982
  ${steps.map((s) => {
979
983
  const p = pos.get(s.name)!;
980
- return html`<g class="wf-node wf-${TYPES.includes(s.type) ? s.type : 'unknown'}${active.has(s.name) ? ' wf-active' : ''}"><rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="${s.type === 'parallel' || s.type === 'join' ? 20 : 8}"/>
984
+ const note = opts.notes?.get(s.name);
985
+ const mark = failed.has(s.name) ? ' wf-failed' : active.has(s.name) ? ' wf-active' : done.has(s.name) ? ' wf-done' : '';
986
+ return html`<g class="wf-node wf-${TYPES.includes(s.type) ? s.type : 'unknown'}${mark}">${note ? html`<title>${txt(note)}</title>` : ''}<rect x="${p.x}" y="${p.y}" width="${W}" height="${H}" rx="${s.type === 'parallel' || s.type === 'join' ? 20 : 8}"/>
981
987
  <text x="${p.x + W / 2}" y="${p.y + 18}" class="wf-name">${txt(s.name)}</text>
982
988
  <text x="${p.x + W / 2}" y="${p.y + 34}" class="wf-type">${txt(typeText(s))}</text></g>`;
983
989
  })}
@@ -996,6 +1002,8 @@ const SVG_STYLE = `
996
1002
  .wf-send_mail rect { stroke: #17794a; stroke-width: 2; }
997
1003
  .wf-draft rect { fill: #fafafa; stroke: #8a8f98; stroke-width: 1.5; stroke-dasharray: 6 4; }
998
1004
  .wf-active rect { fill: #e6efff; stroke: #2f6fde; stroke-width: 3; }
1005
+ .wf-done rect { fill: #e7f5ed; stroke: #17794a; }
1006
+ .wf-failed rect { fill: #fde7e4; stroke: #b42318; stroke-width: 3; }
999
1007
  text { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
1000
1008
  .wf-name { fill: #1d2129; font-size: 12px; font-weight: 600; text-anchor: middle; }
1001
1009
  .wf-type { fill: #5d6470; font-size: 10px; text-anchor: middle; }