pgkiln 0.69.0 → 0.71.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 (63) hide show
  1. package/db/migrations/136_vector_search.sql +252 -0
  2. package/db/migrations/137_create_page_types.sql +904 -0
  3. package/db/migrations/138_app_utilities.sql +153 -0
  4. package/docs/apex-feature-parity.md +10 -8
  5. package/docs/guide/03-builder.md +176 -4
  6. package/docs/guide/04-pages-and-regions.md +45 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/06-processing.md +18 -0
  9. package/docs/guide/09-reference.md +34 -1
  10. package/docs/guide/12-development.md +53 -3
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/docs/guide/16-files.md +1 -1
  13. package/examples/hr/README.md +1 -0
  14. package/examples/hr/hr_72_vector_search.sql +158 -0
  15. package/examples/hr/hr_73_configuration_options.sql +17 -0
  16. package/examples/hr/hr_74_app_utilities.sql +22 -0
  17. package/package.json +1 -1
  18. package/public/app.css +9 -0
  19. package/public/builder.css +48 -0
  20. package/src/ai/embeddings.ts +197 -0
  21. package/src/ai/service.ts +13 -9
  22. package/src/app.ts +13 -0
  23. package/src/builder/advisor.ts +43 -3
  24. package/src/builder/ai-agents.ts +13 -5
  25. package/src/builder/app-updates.ts +3 -0
  26. package/src/builder/components.ts +24 -4
  27. package/src/builder/create-page.ts +259 -0
  28. package/src/builder/dependencies.ts +311 -0
  29. package/src/builder/features.ts +313 -0
  30. package/src/builder/home.ts +3 -0
  31. package/src/builder/i18n/de.ts +38 -0
  32. package/src/builder/i18n/en.ts +38 -0
  33. package/src/builder/i18n/es.ts +38 -0
  34. package/src/builder/i18n/fr.ts +38 -0
  35. package/src/builder/i18n/it.ts +38 -0
  36. package/src/builder/i18n/ja.ts +38 -0
  37. package/src/builder/i18n/ko.ts +38 -0
  38. package/src/builder/i18n/nl.ts +38 -0
  39. package/src/builder/i18n/pt.ts +38 -0
  40. package/src/builder/i18n/zh-cn.ts +38 -0
  41. package/src/builder/i18n/zh-tw.ts +38 -0
  42. package/src/builder/login-page.ts +17 -14
  43. package/src/builder/newapp.ts +41 -23
  44. package/src/builder/object-actions.ts +1 -0
  45. package/src/builder/routes.ts +13 -2
  46. package/src/builder/shared.ts +1 -1
  47. package/src/builder/sql.ts +3 -0
  48. package/src/builder/table-editor.ts +1 -0
  49. package/src/builder/ui-defaults.ts +161 -0
  50. package/src/builder/ui.ts +2 -1
  51. package/src/builder/upgrade.ts +154 -0
  52. package/src/builder/utilities.ts +181 -0
  53. package/src/builder/vector-column.ts +229 -0
  54. package/src/builder/vector-providers.ts +286 -0
  55. package/src/builder/wizards.ts +211 -26
  56. package/src/cli/replace.ts +11 -1
  57. package/src/history.ts +155 -0
  58. package/src/runtime/ai-agents.ts +53 -2
  59. package/src/runtime/builtin-components.ts +23 -2
  60. package/src/runtime/items.ts +4 -2
  61. package/src/runtime/search.ts +116 -12
  62. package/src/server.ts +3 -0
  63. package/src/vector.ts +288 -0
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { ICONS } from '../icons.ts';
5
5
  import { appOr404 } from './forms.ts';
6
+ import { addDashboard } from './appsheets.ts';
6
7
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
7
8
 
8
9
  // Create page wizards (APEX: Create Page). Step 1, on the app's Pages tab,
@@ -23,7 +24,32 @@ export const WIZARD_KINDS: [string, string, string][] = [
23
24
  ['facets', 'Faceted search', 'A report with a filter panel: values with counts, ranges and a search field.'],
24
25
  ['master_detail', 'Master detail', 'A grid of the table and, below it, an editable grid of the selected row\'s details.'],
25
26
  ];
26
- const KINDS = new Set(WIZARD_KINDS.map((k) => k[0]));
27
+ /**
28
+ * (sprint 137, AP-3) The page types that reuse other regions (APEX: Create a Page → Component), made by the same
29
+ * meta.generate_page (migration 137), and the dashboard (addDashboard). WIZARD_KINDS above stay the types the
30
+ * AI path and blueprints propose.
31
+ */
32
+ export const MORE_KINDS: [string, string, string][] = [
33
+ ['report', 'Interactive report', 'An interactive report of the table on its own page, optionally with a modal form page.'],
34
+ ['smart_filters', 'Smart filters', 'An interactive report with one search field above it that suggests filters (chips) from the columns.'],
35
+ ['search', 'Search page', 'A search region over the application\'s search configurations, or a new one on a table.'],
36
+ ['data_load', 'Data loading', 'A data load definition for the table and a page where users upload a CSV, Excel, JSON or XML file into it.'],
37
+ ['tree', 'Tree', 'The rows as a tree: an id, a parent (from a foreign key to the table itself) and a label.'],
38
+ ['timeline', 'Timeline', 'Rows as events on a vertical line: when, who, a title and a text (template component Timeline).'],
39
+ ['media_list', 'Media list', 'Rows with initials, a title, a description and a badge (template component Media list).'],
40
+ ['content_row', 'Content row', 'Rows with initials, a title and a description, and a secondary text and a badge on the right (template component Content row).'],
41
+ ['comments', 'Comments', 'Rows as a conversation: who wrote what and when (template component Comments).'],
42
+ ['dashboard', 'Dashboard', 'Charts of the application\'s tables, chosen from the data like a new application\'s dashboard.'],
43
+ ['wizard', 'Wizard', 'A form over several steps, one page per step with a progress list, Previous and Next; Finish adds the row.'],
44
+ ['tasks', 'Unified task list', 'The approval and action tasks of the signed-in user (a task list region).'],
45
+ ['workflows', 'Workflow console', 'The workflows the user started or administers, with their state and history.'],
46
+ ];
47
+ /** All the page types step 2 knows: [key, label, description]. */
48
+ export const PAGE_KINDS: [string, string, string][] = [...WIZARD_KINDS, ...MORE_KINDS];
49
+ const KINDS = new Set(PAGE_KINDS.map((k) => k[0]));
50
+ /** Page types that need no table (search takes one optionally, for a new search configuration). */
51
+ export const NO_TABLE = new Set(['search', 'tasks', 'workflows', 'dashboard']);
52
+ const TASK_CONTEXTS: [string, string][] = [['my', 'My tasks (to act on or claim)'], ['initiated', 'Tasks I requested'], ['admin', 'Tasks I administer']];
27
53
  const CHART_KINDS = ['bar', 'column', 'line', 'area', 'donut', 'pie', 'funnel'];
28
54
  const FUNCTIONS: [string, string][] = [['count', 'Count'], ['sum', 'Sum'], ['avg', 'Average'], ['min', 'Minimum'], ['max', 'Maximum']];
29
55
 
@@ -110,6 +136,55 @@ export function wizardOptions(kind: string, b: Record<string, unknown>) {
110
136
  o.search = b.search === 'true';
111
137
  o.form_page = formPage();
112
138
  break;
139
+ // (137) the page types of migration 137
140
+ case 'report':
141
+ o.form_page = formPage();
142
+ break;
143
+ case 'smart_filters':
144
+ o.facets = list(b.facets);
145
+ o.form_page = formPage();
146
+ break;
147
+ case 'tree':
148
+ for (const k of ['id_column', 'parent_column', 'label_column']) o[k] = str(k) || null;
149
+ o.expanded = /^\d{1,2}$/.test(str('expanded')) ? Number(str('expanded')) : 1;
150
+ o.form_page = formPage();
151
+ break;
152
+ case 'timeline':
153
+ for (const k of ['title', 'when', 'who', 'body']) o[k] = str(k) || null;
154
+ o.form_page = formPage();
155
+ break;
156
+ case 'media_list':
157
+ case 'content_row':
158
+ for (const k of ['title', 'description', 'badge', ...(kind === 'content_row' ? ['misc'] : [])]) o[k] = str(k) || null;
159
+ o.form_page = formPage();
160
+ break;
161
+ case 'comments':
162
+ for (const k of ['user', 'date', 'comment']) o[k] = str(k) || null;
163
+ break;
164
+ case 'data_load':
165
+ o.definition = str('definition').toUpperCase() || null;
166
+ o.mode = ['append', 'merge', 'replace'].includes(str('mode')) ? str('mode') : 'append';
167
+ o.skip_errors = b.skip_errors === 'true';
168
+ break;
169
+ case 'wizard':
170
+ o.columns = list(b.columns);
171
+ o.steps = /^\d{1,2}$/.test(str('steps')) ? Number(str('steps')) : 2;
172
+ o.return_page = pageNo(b.return_page);
173
+ break;
174
+ case 'search':
175
+ o.configs = list(b.configs).map((c) => c.toUpperCase());
176
+ o.form_page = formPage();
177
+ break;
178
+ case 'tasks':
179
+ o.context = TASK_CONTEXTS.some(([c]) => c === str('context')) ? str('context') : 'my';
180
+ o.completed = b.completed === 'true';
181
+ break;
182
+ case 'workflows':
183
+ o.context = str('context') === 'admin' ? 'admin' : 'initiator';
184
+ break;
185
+ case 'dashboard':
186
+ o.tables = list(b.tables);
187
+ break;
113
188
  case 'master_detail': {
114
189
  const d = str('detail');
115
190
  const at = d.lastIndexOf('|');
@@ -130,30 +205,31 @@ export async function wizardRoutes(app: FastifyInstance) {
130
205
  if (!a) return reply.code(404).send('Not found');
131
206
  const kind = String(req.query.kind ?? '');
132
207
  const table = String(req.query.table ?? '');
133
- const rel = KINDS.has(kind) && table
208
+ const rel = KINDS.has(kind) && table && kind !== 'dashboard'
134
209
  ? await owner.one<{ t: string; relkind: string }>(
135
210
  `select meta.wizard_qname(c.oid) as t, c.relkind::text from pg_class c join pg_namespace n on n.oid = c.relnamespace
136
211
  where c.oid = to_regclass($1) and c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')`,
137
212
  [table],
138
213
  )
139
214
  : null;
140
- if (!rel) {
215
+ if (!rel && !(NO_TABLE.has(kind) && !table)) {
141
216
  flash(s, KINDS.has(kind) ? 'Choose a table or view.' : 'Choose a page type.', 'error');
142
- return back(reply, s, `${BASE}/apps/${a.id}`);
217
+ // (137) back to the type's table step on the Create page grid
218
+ return back(reply, s, KINDS.has(kind) ? `${BASE}/apps/${a.id}/create-page?type=${encodeURIComponent(kind)}` : `${BASE}/apps/${a.id}/create-page`);
143
219
  }
144
220
  const [cols, defaults] = await Promise.all([
145
- owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows),
146
- owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel.t]).then((r) => r?.d ?? {}),
221
+ rel ? owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows) : Promise.resolve([] as Col[]),
222
+ owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel?.t ?? null]).then((r) => r?.d ?? {}),
147
223
  ]);
148
224
  const nextPage = await freePage(a.id, Math.max(0, ...(await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no)) + 1);
149
225
  const pk = cols.find((c) => c.is_pk)?.column_name ?? null;
150
226
  const role = a.db_role as string | null;
151
- const access = role
227
+ const access = role && rel
152
228
  ? await owner.one<{ s: boolean; i: boolean; u: boolean; d: boolean }>(
153
229
  `select has_table_privilege($1, $2::regclass, 'select') as s, has_table_privilege($1, $2::regclass, 'insert') as i,
154
230
  has_table_privilege($1, $2::regclass, 'update') as u, has_table_privilege($1, $2::regclass, 'delete') as d
155
231
  where exists (select 1 from pg_roles where rolname = $1)`,
156
- [role, rel.t],
232
+ [role, rel!.t],
157
233
  )
158
234
  : null;
159
235
 
@@ -171,6 +247,8 @@ export async function wizardRoutes(app: FastifyInstance) {
171
247
  )}</div></fieldset>`;
172
248
  const formPage = (help: string) => input('form_page', 'Form page', '', { type: 'number', placeholder: `e.g. ${nextPage + 1} (empty: no form)`, help });
173
249
  const needsPk = ['report_form', 'grid', 'form', 'master_detail'].includes(kind);
250
+ const allCols = (c: Col) => c.kind !== 'binary';
251
+ const dates = (c: Col) => c.kind === 'date' || c.kind === 'timestamp';
174
252
 
175
253
  let fields: Raw | string = '';
176
254
  switch (kind) {
@@ -228,43 +306,134 @@ export async function wizardRoutes(app: FastifyInstance) {
228
306
  ${check('search', 'A search field above the facets', defaults.search !== false)}
229
307
  ${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
230
308
  break;
309
+ // (137) the page types of migration 137
310
+ case 'report':
311
+ fields = pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : '';
312
+ break;
313
+ case 'smart_filters':
314
+ fields = html`
315
+ ${colChecks('facets', 'Filters (suggested in the search field)', defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? `: values of ${c.fk_table}.${c.fk_display ?? c.column_name}` : ['number', 'date', 'timestamp'].includes(c.kind) ? ': ranges' : ': values'))}
316
+ ${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
317
+ break;
318
+ case 'tree':
319
+ fields = html`<div class="form-grid">
320
+ ${select('id_column', 'Node id', defaults.id_column ?? '', colOpts(allCols, false), 'What identifies a node: usually the primary key.')}
321
+ ${select('parent_column', 'Parent', defaults.parent_column ?? '', colOpts(allCols), 'The column with the parent node\'s id; proposed from a foreign key to the table itself. Rows whose parent is not in the table are the top level.')}
322
+ ${select('label_column', 'Label', defaults.label_column ?? '', colOpts(notBinary, false))}
323
+ ${input('expanded', 'Levels open at first', defaults.expanded ?? 1, { type: 'number' })}
324
+ ${pk ? formPage('A modal form page, opened by clicking a node.') : ''}
325
+ </div>`;
326
+ break;
327
+ case 'timeline':
328
+ fields = html`<div class="form-grid">
329
+ ${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false))}
330
+ ${select('when', 'When', defaults.when ?? '', colOpts(dates), 'The events come newest first.')}
331
+ ${select('who', 'Who', defaults.who ?? '', colOpts(notBinary))}
332
+ ${select('body', 'Text', defaults.body ?? '', colOpts(notBinary))}
333
+ ${pk ? formPage('A modal form page, linked from each title.') : ''}
334
+ </div>`;
335
+ break;
336
+ case 'media_list':
337
+ case 'content_row':
338
+ fields = html`<div class="form-grid">
339
+ ${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false), 'Its first two letters are the initials.')}
340
+ ${select('description', 'Description', defaults.description ?? '', colOpts(notBinary))}
341
+ ${kind === 'content_row' ? select('misc', 'Secondary text (on the right)', defaults.misc ?? '', colOpts(notBinary)) : ''}
342
+ ${select('badge', 'Badge', defaults.badge ?? '', colOpts(notBinary))}
343
+ ${pk ? formPage('A modal form page, linked from each title.') : ''}
344
+ </div>`;
345
+ break;
346
+ case 'comments':
347
+ fields = html`<div class="form-grid">
348
+ ${select('user', 'Who wrote it', defaults.user ?? '', colOpts(notBinary))}
349
+ ${select('date', 'When', defaults.date ?? '', colOpts(dates), 'The newest first.')}
350
+ ${select('comment', 'Comment', defaults.comment ?? '', colOpts(notBinary, false))}
351
+ </div>`;
352
+ break;
353
+ case 'data_load': {
354
+ const defs = (await owner.query<{ name: string; table_name: string }>('select name, table_name from meta.data_load_def where app_id = $1 order by name', [a.id])).rows;
355
+ fields = html`<div class="form-grid">
356
+ ${input('definition', 'Data load definition', defaults.definition ?? '', { required: true, help: `Upper case, e.g. ORDERS_LOAD. A new definition for ${rel?.t ?? 'the table'}; an existing one of that name is used as it is${defs.length ? ` (this application has ${defs.map((d) => d.name).join(', ')})` : ''}.` })}
357
+ ${select('mode', 'Mode', defaults.mode ?? 'append', [['append', 'Append: add the rows'], ['merge', 'Merge: update rows with the same key, add the others'], ['replace', 'Replace: delete all rows first']])}
358
+ </div>
359
+ ${check('skip_errors', 'Skip rows with errors', !!defaults.skip_errors, 'Load the good rows and list the others; otherwise nothing is loaded when a row fails.')}`;
360
+ break;
361
+ }
362
+ case 'wizard':
363
+ fields = html`<div class="form-grid">
364
+ ${input('steps', 'Steps', defaults.steps ?? 2, { type: 'number', help: 'One page per step, numbered from the page number up; the columns are shared out in order.' })}
365
+ ${input('return_page', 'After Finish or Cancel, go to page', defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? `empty: the home page (${a.home_page})` : 'empty: this page' })}
366
+ </div>
367
+ ${colChecks('columns', 'Columns', defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary' && c.kind !== 'geometry', (c) => (c.not_null && !c.has_default ? ', required: always included' : c.fk_table ? `, a select list of ${c.fk_table}` : ''))}`;
368
+ break;
369
+ case 'search': {
370
+ const configs = (await owner.query<{ name: string; label: string; source: string }>('select name, label, source from meta.search_config where app_id = $1 order by seq, name', [a.id])).rows;
371
+ const tables = await wizardTables(role);
372
+ fields = html`
373
+ ${configs.length
374
+ ? html`<fieldset class="prop-group"><legend>Search configurations</legend><div class="qb-pick">${configs.map((c) => html`<label class="check"><input type="checkbox" name="configs" value="${c.name}" checked> ${c.label} <span class="muted small">${c.name}, ${c.source.slice(0, 60)}</span></label>`)}</div></fieldset>`
375
+ : html`<p class="muted">This application has no search configurations yet (<a href="${BASE}/apps/${a.id}/shared?type=search_config">Shared Components → Search configurations</a>): choose a table to search below.</p>`}
376
+ <div class="form-grid">
377
+ ${select('table', 'And a new configuration on', rel?.t ?? '', [['', '- none -'], ...tables.filter((t) => t.access).map((t): [string, string] => [t.t, t.t])], 'Searches the table\'s text columns and shows its name (or first text) column as the result\'s title.')}
378
+ ${formPage('With a table: a modal form page that each result links to.')}
379
+ </div>`;
380
+ break;
381
+ }
382
+ case 'tasks':
383
+ fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'my', TASK_CONTEXTS)}</div>
384
+ ${check('completed', 'Also completed tasks', !!defaults.completed)}
385
+ <p class="muted small">Tasks come from task definitions (Shared Components → Task definitions) and approvals started in processes or workflows.</p>`;
386
+ break;
387
+ case 'workflows':
388
+ fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'initiator', [['initiator', 'The workflows the user started'], ['admin', 'The workflows the user administers']])}</div>`;
389
+ break;
390
+ case 'dashboard': {
391
+ const tables = (await wizardTables(role)).filter((t) => t.access);
392
+ fields = tables.length
393
+ ? html`<fieldset class="prop-group"><legend>Tables</legend><div class="qb-pick">${tables.map((t, n) => html`<label class="check"><input type="checkbox" name="tables" value="${t.t}"${n < 12 ? raw(' checked') : ''}> ${t.t}</label>`)}</div></fieldset>
394
+ <p class="muted small">A chart per table (at most six) of the rows per parent row or per repeating value, read from the data; tables with row level security are left out.</p>`
395
+ : html`<p class="alert alert-error" role="status">The application's database role can't read any table.</p>`;
396
+ break;
397
+ }
231
398
  case 'master_detail': {
399
+ const t = rel!.t;
232
400
  const details = (
233
401
  await owner.query<{ t: string; col: string }>(
234
402
  `select meta.wizard_qname(con.conrelid) as t, a.attname::text as col
235
403
  from pg_constraint con join pg_attribute a on a.attrelid = con.conrelid and a.attnum = con.conkey[1]
236
404
  where con.confrelid = $1::regclass and con.contype = 'f' and con.conrelid <> con.confrelid and array_length(con.conkey, 1) = 1
237
405
  order by 1, 2`,
238
- [rel.t],
406
+ [t],
239
407
  )
240
408
  ).rows;
241
409
  fields = details.length
242
- ? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${rel.t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
243
- : html`<p class="alert alert-error" role="status">No table has a foreign key to ${rel.t}: a master-detail page needs one.</p>`;
410
+ ? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
411
+ : html`<p class="alert alert-error" role="status">No table has a foreign key to ${t}: a master-detail page needs one.</p>`;
244
412
  break;
245
413
  }
246
414
  }
247
415
 
248
- const kindName = WIZARD_KINDS.find((k) => k[0] === kind)![1];
416
+ const kindDef = PAGE_KINDS.find((k) => k[0] === kind)!;
417
+ const kindName = kindDef[1];
249
418
  const main = html`${appHeader(a, 'pages', s)}
250
- ${region(`${kindName} on ${rel.t}`, html`
251
- <p class="muted u-mt0">${WIZARD_KINDS.find((k) => k[0] === kind)![2]} The proposals come from the table's columns, key and foreign keys; change them below. <a href="${BASE}/apps/${a.id}">Back to choose another type or table</a>.</p>
252
- ${needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
253
- ${role && access && !(access.s && (!needsPk || (access.i && access.u && access.d)))
254
- ? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : 'select'}).</p>`
419
+ ${region(rel && kind !== 'search' ? `${kindName} on ${rel.t}` : kindName, html`
420
+ <p class="muted u-mt0">${kindDef[2]} ${rel ? 'The proposals come from the table\'s columns, key and foreign keys; change them below.' : ''} <a href="${BASE}/apps/${a.id}/create-page">Back to choose another type${rel ? ' or table' : ''}</a>.</p>
421
+ ${rel && needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
422
+ ${rel && role && access && !(access.s && (!(needsPk || kind === 'wizard' || kind === 'data_load') || (access.i && (kind === 'wizard' || (access.u && access.d)))))
423
+ ? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : kind === 'wizard' ? 'select, insert' : kind === 'data_load' ? 'select, insert, update, delete for merge or replace' : 'select'}).</p>`
255
424
  : ''}
256
425
  <form method="post" action="${BASE}/apps/${a.id}/wizard">${csrf(s)}
257
- <input type="hidden" name="kind" value="${kind}"><input type="hidden" name="table" value="${rel.t}">
426
+ <input type="hidden" name="kind" value="${kind}">${rel && kind !== 'search' ? html`<input type="hidden" name="table" value="${rel.t}">` : ''}
258
427
  <div class="form-grid">
259
428
  ${input('report_page', 'Page number', nextPage, { type: 'number', required: true })}
260
429
  ${input('label', 'Name', defaults.label ?? '', { help: 'The page title and the navigation entry.' })}
261
430
  ${select('icon', 'Menu icon', defaults.icon ?? 'table', [...ICONS])}
262
431
  </div>
263
- ${['report_form', 'grid'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
432
+ ${['report_form', 'grid', 'dashboard'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
264
433
  ${fields}
265
434
  <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}">Cancel</a><button class="btn btn-hot">Create page</button></div>
266
435
  </form>`)}`;
267
- return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], [`Create ${kindName.toLowerCase()} page`]], main));
436
+ return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page`], [kindName]], main));
268
437
  });
269
438
 
270
439
  app.post(`${BASE}/apps/:id/wizard`, async (req: Req, reply) => {
@@ -272,26 +441,42 @@ export async function wizardRoutes(app: FastifyInstance) {
272
441
  if (!s) return;
273
442
  const b = (req.body ?? {}) as Record<string, unknown>;
274
443
  const kind = KINDS.has(String(b.kind)) ? String(b.kind) : 'report_form';
275
- const table = String(b.table ?? '');
276
- const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}&table=${encodeURIComponent(table)}`;
444
+ const table = kind === 'dashboard' || kind === 'tasks' || kind === 'workflows' ? '' : String(b.table ?? '');
445
+ const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}${table && kind !== 'search' ? `&table=${encodeURIComponent(table)}` : ''}`;
277
446
  try {
278
447
  const page = pageNo(b.report_page);
279
448
  if (page === null) throw new Error('Enter a page number.');
280
449
  const options = wizardOptions(kind, b);
450
+ if (!table && !NO_TABLE.has(kind)) throw new Error('Choose a table or view.');
281
451
  const id = await owner.tx(async (c) => {
282
- const a = await c.query('select alias from meta.app where id = $1', [req.params.id]);
452
+ const a = await c.query('select alias, db_role from meta.app where id = $1', [req.params.id]);
283
453
  if (!a.rowCount) throw new Error('This application does not exist.');
284
454
  // report and form and grid keep their own icons and need no step 2 (older forms post here directly)
285
455
  if (kind === 'report_form' && options.form_page === null) throw new Error('Enter the form page number.');
286
456
  if (['report_form', 'grid'].includes(kind) && !options.icon) options.icon = kind === 'grid' ? 'grid' : 'table';
287
- return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table, page, JSON.stringify(options)])).rows[0].id as number;
457
+ if (kind === 'dashboard') {
458
+ // (137) only tables the application's role can read
459
+ const readable = new Set((await wizardTables(a.rows[0].db_role)).filter((t) => t.access).map((t) => t.t));
460
+ const tables = (options.tables as string[]).filter((t) => readable.has(t));
461
+ if (!tables.length) throw new Error('Choose at least one table the application can read.');
462
+ const made = await addDashboard(c, a.rows[0].alias, tables, page);
463
+ if (!made) throw new Error('None of these tables has a column worth a chart (a foreign key, or values that repeat).');
464
+ const pid = (await c.query('select id from meta.page where app_id = $1 and page_no = $2', [req.params.id, page])).rows[0].id as number;
465
+ if (options.label) {
466
+ await c.query('update meta.page set name = $2, title = $2 where id = $1', [pid, options.label]);
467
+ await c.query('update meta.nav_entry set label = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.label]);
468
+ }
469
+ if (options.icon) await c.query('update meta.nav_entry set icon = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.icon]);
470
+ return pid;
471
+ }
472
+ return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table || null, page, JSON.stringify(options)])).rows[0].id as number;
288
473
  });
289
474
  const pages = (await owner.query('select page_no from meta.page where app_id = $1 and id >= $2 order by page_no', [req.params.id, id])).rows.map((r) => r.page_no);
290
- flash(s, `${pages.length > 1 ? `Pages ${pages.join(' and ')}` : `Page ${page}`} created for ${table}. Make sure the app's database role has privileges on it.`);
475
+ flash(s, `${pages.length > 1 ? `Pages ${pages.join(', ')}` : `Page ${page}`} created${table ? ` for ${table}. Make sure the app's database role has privileges on it` : ''}.`);
291
476
  return back(reply, s, `${BASE}/pages/${id}`);
292
477
  } catch (e) {
293
478
  flash(s, (e as Error).message, 'error');
294
- return back(reply, s, KINDS.has(String(b.kind)) && table ? retry : `${BASE}/apps/${req.params.id}`);
479
+ return back(reply, s, KINDS.has(String(b.kind)) && (table || NO_TABLE.has(kind)) ? retry : `${BASE}/apps/${req.params.id}`);
295
480
  }
296
481
  });
297
482
  }
@@ -12,6 +12,7 @@
12
12
  import { randomBytes } from 'node:crypto';
13
13
  import type pg from 'pg';
14
14
  import { regionKeys } from '../appfiles.ts';
15
+ import { ensureSnapshot, recordHistory } from '../history.ts';
15
16
 
16
17
  /** Tables of an application that are its definition: replaced. */
17
18
  export const REPLACED = [
@@ -84,7 +85,12 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
84
85
  // (125) who changed the application last in the builder (App Builder list: Updated by)
85
86
  'app_change',
86
87
  // (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
87
- 'network_refusal'];
88
+ 'network_refusal',
89
+ // (136) rows queued for their embeddings (the vector providers travel in meta.app)
90
+ 'embedding_queue',
91
+ // (138) the change history and the snapshot it compares with (this installation's record)
92
+ 'app_snapshot', 'component_change',
93
+ ];
88
94
  /** Children of pages (replaced with their page). */
89
95
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
90
96
  /** References into replaced tables from kept data, repointed below: "table.column". */
@@ -138,6 +144,9 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
138
144
  const target = (await db.query<{ id: number }>('select id from meta.app where alias = $1 for update', [alias])).rows[0];
139
145
  if (!target) throw new Error(`application ${alias} not found`);
140
146
  const old = target.id;
147
+ // (sprint 138) the change history compares the application before and after (src/history.ts); not on a bare pool
148
+ const inTx = !('totalCount' in db);
149
+ if (inTx) await ensureSnapshot(old, db as pg.ClientBase);
141
150
  const backup = opts.backup === undefined ? { reason: 'import' as const } : opts.backup;
142
151
  if (backup) await db.query('select meta.backup_app($1, $2, $3, $4)', [old, backup.reason, backup.user ?? null, backup.note ?? null]);
143
152
  const tmpAlias = `pgkiln-replace-${randomBytes(6).toString('hex')}`;
@@ -249,5 +258,6 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
249
258
  [old, neu],
250
259
  );
251
260
  await db.query('delete from meta.app where id = $1', [neu]);
261
+ if (inTx) await recordHistory(old, opts.backup ? (opts.backup.user ?? null) : null, 'import', db as pg.ClientBase).catch(() => {});
252
262
  return old;
253
263
  }
package/src/history.ts ADDED
@@ -0,0 +1,155 @@
1
+ // (sprint 138, AP-5) Change history per component (APEX: Application Utilities ›
2
+ // Change History) and application checksums (APEX: Application Checksum).
3
+ //
4
+ // meta.app_snapshot keeps an application's export after its last recorded
5
+ // change. After a builder change (app-updates.ts, onSend) and inside an import
6
+ // --replace (cli/replace.ts: restores, merges, the command line and the MCP
7
+ // server), recordHistory() exports the application again, splits both exports
8
+ // into components the way working copies compare them (workingcopy.ts
9
+ // components(): a region with its SQL, a page's settings, a list of values, …)
10
+ // and stores each added, changed or deleted component with its files before and
11
+ // after in meta.component_change (migration 138). The first change of an
12
+ // application without a snapshot has nothing to compare with: ensureSnapshot()
13
+ // takes one before a builder POST runs (a preHandler hook).
14
+ //
15
+ // appChecksum(): a SHA-256 over the export's files (the text layout, without the
16
+ // format marker and with the alias blanked), so two installations, or an
17
+ // application and its working copy, compare at a glance; pageChecksums() per page.
18
+ import { createHash } from 'node:crypto';
19
+ import type pg from 'pg';
20
+ import { docToFiles, MARKER, type Doc, type FileMap } from './appfiles.ts';
21
+ import { owner } from './db.ts';
22
+ import { components, describe } from './workingcopy.ts';
23
+
24
+ type Db = { query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult<any>> };
25
+
26
+ /** Days a change stays in the history (CHANGE_HISTORY_DAYS, default 365; 0 keeps everything). */
27
+ export const historyDays = () => {
28
+ const n = Number(process.env.CHANGE_HISTORY_DAYS ?? 365);
29
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 365;
30
+ };
31
+
32
+ /** The export as files of the text layout (SQL inline in YAML: readable diffs), the alias blanked. */
33
+ export function appFiles(doc: Doc): FileMap {
34
+ const d = { ...doc, app: { ...(doc.app ?? {}), alias: '' } };
35
+ const files = docToFiles(d, 'text');
36
+ files.delete(MARKER);
37
+ return files;
38
+ }
39
+
40
+ const isText = (b: Buffer) => !b.subarray(0, 8000).includes(0);
41
+ const asText = (files: Map<string, Buffer> | undefined) =>
42
+ files ? Object.fromEntries([...files].map(([p, b]) => [p, isText(b) ? b.toString('utf8') : `(binary, ${b.length} bytes, sha256 ${createHash('sha256').update(b).digest('hex').slice(0, 16)})`])) : null;
43
+
44
+ const sameFiles = (a?: Map<string, Buffer>, b?: Map<string, Buffer>) => {
45
+ if (!a || !b) return !a && !b;
46
+ if (a.size !== b.size) return false;
47
+ for (const [p, buf] of a) if (!b.get(p)?.equals(buf)) return false;
48
+ return true;
49
+ };
50
+
51
+ export interface ComponentDiff {
52
+ component: string;
53
+ label: string;
54
+ page: number | null;
55
+ op: 'added' | 'changed' | 'deleted';
56
+ before: Record<string, string> | null;
57
+ after: Record<string, string> | null;
58
+ }
59
+
60
+ /** The components that differ between two exports. */
61
+ export function diffDocs(before: Doc, after: Doc): ComponentDiff[] {
62
+ const a = components(appFiles(before)), b = components(appFiles(after));
63
+ const out: ComponentDiff[] = [];
64
+ for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
65
+ const x = a.get(id), y = b.get(id);
66
+ if (sameFiles(x, y)) continue;
67
+ const { label, page } = describe(id);
68
+ out.push({ component: id, label, page, op: !x ? 'added' : !y ? 'deleted' : 'changed', before: asText(x), after: asText(y) });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ const exportDoc = async (db: Db, appId: number): Promise<Doc | null> =>
74
+ (await db.query('select meta.export_app(alias) as d from meta.app where id = $1', [appId])).rows[0]?.d ?? null;
75
+
76
+ /** Take a first snapshot of an application that has none (before its first recorded change); with `db`, inside its transaction. */
77
+ export async function ensureSnapshot(appId: number, db?: pg.ClientBase) {
78
+ const sql = `insert into meta.app_snapshot (app_id, doc) select id, meta.export_app(alias) from meta.app
79
+ where id = $1 and not exists (select 1 from meta.app_snapshot where app_id = $1)
80
+ on conflict (app_id) do nothing`;
81
+ if (!db) return void (await owner.query(sql, [appId]));
82
+ await db.query('savepoint history_snapshot');
83
+ try {
84
+ await db.query(sql, [appId]);
85
+ await db.query('release savepoint history_snapshot');
86
+ } catch {
87
+ await db.query('rollback to savepoint history_snapshot');
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Compare the application with its snapshot, record what changed and keep the new snapshot.
93
+ * With `db` a client in the caller's transaction (an import --replace), else a transaction of its own.
94
+ */
95
+ export async function recordHistory(appId: number, changedBy: string | null, source: 'builder' | 'import' | 'upgrade' = 'builder', db?: pg.ClientBase): Promise<number> {
96
+ const run = async (c: Db) => {
97
+ // one change at a time per application: the snapshot row is the lock
98
+ const snap = (await c.query('select doc from meta.app_snapshot where app_id = $1 for update', [appId])).rows[0];
99
+ const now = await exportDoc(c, appId);
100
+ if (!now) return 0;
101
+ if (!snap) {
102
+ await c.query('insert into meta.app_snapshot (app_id, doc) values ($1, $2) on conflict (app_id) do update set doc = excluded.doc, taken_at = now()', [appId, now]);
103
+ return 0;
104
+ }
105
+ const changes = diffDocs(snap.doc, now);
106
+ if (!changes.length) return 0;
107
+ await c.query(
108
+ `insert into meta.component_change (app_id, component, label, page_no, op, before, after, changed_by, source)
109
+ select $1, x.component, x.label, x.page, x.op, x.before, x.after, $2, $3
110
+ from jsonb_to_recordset($4::jsonb) as x(component text, label text, page int, op text, before jsonb, after jsonb)`,
111
+ [appId, changedBy?.slice(0, 200) ?? null, source, JSON.stringify(changes)],
112
+ );
113
+ await c.query('update meta.app_snapshot set doc = $2, taken_at = now() where app_id = $1', [appId, now]);
114
+ const days = historyDays();
115
+ if (days) await c.query(`delete from meta.component_change where app_id = $1 and changed_at < now() - make_interval(days => $2)`, [appId, days]);
116
+ return changes.length;
117
+ };
118
+ if (db) {
119
+ await db.query('savepoint history');
120
+ try {
121
+ const n = await run(db);
122
+ await db.query('release savepoint history');
123
+ return n;
124
+ } catch (e) {
125
+ await db.query('rollback to savepoint history');
126
+ throw e;
127
+ }
128
+ }
129
+ return owner.tx(run);
130
+ }
131
+
132
+ /** SHA-256 of a set of files (paths and contents, in path order). */
133
+ function hashFiles(files: [string, Buffer][]) {
134
+ const h = createHash('sha256');
135
+ for (const [p, b] of files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(p).update('\0').update(b).update('\0');
136
+ return h.digest('hex');
137
+ }
138
+
139
+ /** The application checksum of an export (the same definition gives the same checksum on any installation). */
140
+ export const appChecksum = (doc: Doc) => hashFiles([...appFiles(doc)]);
141
+
142
+ /** A checksum per page: page number → the hash of the page's files. */
143
+ export function pageChecksums(doc: Doc): Map<number, string> {
144
+ const byPage = new Map<number, [string, Buffer][]>();
145
+ for (const [id, files] of components(appFiles(doc))) {
146
+ const page = id.startsWith('pages/') ? describe(id).page : null;
147
+ if (page === null) continue;
148
+ if (!byPage.has(page)) byPage.set(page, []);
149
+ byPage.get(page)!.push(...files);
150
+ }
151
+ return new Map([...byPage].sort(([a], [b]) => a - b).map(([p, f]) => [p, hashFiles(f)]));
152
+ }
153
+
154
+ /** A short form for display (the first 16 hex digits). */
155
+ export const shortSum = (sum: string) => sum.slice(0, 16);