pgkiln 0.31.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/docs/apex-feature-parity.md +55 -35
  15. package/docs/guide/01-installation.md +1 -0
  16. package/docs/guide/03-builder.md +118 -2
  17. package/docs/guide/04-pages-and-regions.md +115 -0
  18. package/docs/guide/05-items.md +47 -0
  19. package/docs/guide/06-processing.md +210 -2
  20. package/docs/guide/07-dynamic-actions.md +60 -2
  21. package/docs/guide/08-security.md +13 -2
  22. package/docs/guide/09-reference.md +21 -0
  23. package/docs/guide/11-from-apex.md +5 -1
  24. package/docs/guide/12-development.md +33 -1
  25. package/docs/guide/14-globalization.md +15 -0
  26. package/docs/guide/15-extensions.md +1 -1
  27. package/docs/guide/18-cli.md +30 -1
  28. package/docs/guide/20-ai-agents.md +3 -2
  29. package/examples/hr/hr_50_search.sql +47 -0
  30. package/examples/hr/hr_51_mail.sql +70 -0
  31. package/examples/hr/hr_52_feedback.sql +15 -0
  32. package/examples/hr/hr_53_data_settings.sql +108 -0
  33. package/package.json +3 -1
  34. package/public/app.css +62 -0
  35. package/public/app.js +408 -11
  36. package/public/builder.css +11 -0
  37. package/public/builder.js +27 -0
  38. package/src/app.ts +8 -0
  39. package/src/appfiles.ts +12 -0
  40. package/src/automations.ts +12 -0
  41. package/src/builder/advisor.ts +54 -14
  42. package/src/builder/arrange.ts +2 -2
  43. package/src/builder/availability.ts +74 -0
  44. package/src/builder/backups.ts +134 -0
  45. package/src/builder/components.ts +109 -15
  46. package/src/builder/designer.ts +8 -3
  47. package/src/builder/feedback.ts +82 -0
  48. package/src/builder/forms.ts +4 -1
  49. package/src/builder/home.ts +3 -0
  50. package/src/builder/locks.ts +15 -5
  51. package/src/builder/mail.ts +425 -0
  52. package/src/builder/newapp.ts +1 -1
  53. package/src/builder/region-help.ts +71 -0
  54. package/src/builder/routes.ts +44 -1
  55. package/src/builder/search.ts +4 -4
  56. package/src/builder/shared.ts +3 -2
  57. package/src/builder/ui.ts +7 -1
  58. package/src/cli/apps.ts +46 -5
  59. package/src/cli/main.ts +21 -5
  60. package/src/cli/mcp.ts +16 -4
  61. package/src/cli/replace.ts +36 -4
  62. package/src/geocode.ts +44 -0
  63. package/src/i18n/ar.ts +39 -0
  64. package/src/i18n/cs.ts +39 -0
  65. package/src/i18n/da.ts +39 -0
  66. package/src/i18n/de.ts +39 -0
  67. package/src/i18n/el.ts +39 -0
  68. package/src/i18n/es.ts +39 -0
  69. package/src/i18n/fi.ts +39 -0
  70. package/src/i18n/fr.ts +39 -0
  71. package/src/i18n/he.ts +39 -0
  72. package/src/i18n/it.ts +39 -0
  73. package/src/i18n/ja.ts +39 -0
  74. package/src/i18n/ko.ts +39 -0
  75. package/src/i18n/nb.ts +39 -0
  76. package/src/i18n/pl.ts +39 -0
  77. package/src/i18n/pt.ts +39 -0
  78. package/src/i18n/ru.ts +39 -0
  79. package/src/i18n/sv.ts +39 -0
  80. package/src/i18n/tr.ts +39 -0
  81. package/src/i18n/uk.ts +39 -0
  82. package/src/i18n/zh.ts +39 -0
  83. package/src/i18n.ts +78 -0
  84. package/src/mail/mail.ts +148 -0
  85. package/src/mail/providers.ts +444 -0
  86. package/src/metadata.ts +26 -8
  87. package/src/runtime/ajax.ts +97 -0
  88. package/src/runtime/authz.ts +1 -1
  89. package/src/runtime/availability.ts +37 -0
  90. package/src/runtime/context.ts +46 -6
  91. package/src/runtime/data-export.ts +94 -0
  92. package/src/runtime/data-items.ts +194 -0
  93. package/src/runtime/devbar.ts +182 -0
  94. package/src/runtime/engine.ts +26 -1
  95. package/src/runtime/feedback.ts +128 -0
  96. package/src/runtime/items.ts +8 -0
  97. package/src/runtime/processes.ts +83 -2
  98. package/src/runtime/regions.ts +8 -0
  99. package/src/runtime/render.ts +33 -7
  100. package/src/runtime/rest.ts +4 -0
  101. package/src/runtime/routes.ts +59 -5
  102. package/src/runtime/search.ts +252 -0
  103. package/src/runtime/url-region.ts +93 -0
  104. package/src/security.ts +11 -1
  105. package/src/server.ts +2 -0
  106. package/src/session.ts +8 -2
  107. package/src/workingcopy.ts +2 -2
@@ -8,6 +8,8 @@ import { templateProblem } from '../runtime/document.ts';
8
8
  import { invokeStepReferences, stepProblems, stepWarnings } from '../workflow.ts';
9
9
  import { handlerProblems } from '../runtime/rest.ts';
10
10
  import { processProblems } from '../runtime/processes.ts';
11
+ import { urlConfigProblem, urlTarget } from '../runtime/url-region.ts';
12
+ import { configColumns, searchConfigProblems } from '../runtime/search.ts';
11
13
  import { COMPONENTS } from './components.ts';
12
14
  import { appEntries, type Entry } from './search.ts';
13
15
  import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
@@ -149,10 +151,16 @@ export async function checkSql(c: pg.PoolClient, sql: string, shape: SqlShape):
149
151
  const ITEM_REF = /(?<![A-Za-z0-9_$&:])(?::|&)(P\d+_[A-Z0-9_]+)\b/gi;
150
152
  const BUILT_IN = new Set(['MUST_NOT_BE_PUBLIC_USER']);
151
153
 
152
- /** Run every check of an application. */
153
- export async function advise(appId: number): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
154
- const app = await owner.one('select * from meta.app where id = $1', [appId]);
155
- const entries = await appEntries(appId);
154
+ /**
155
+ * Run every check of an application. With `tx` (a client in a transaction,
156
+ * e.g. an import that hasn't committed yet) everything is read and checked
157
+ * on that client, inside a savepoint that is rolled back.
158
+ */
159
+ export async function advise(appId: number, tx?: pg.ClientBase): Promise<{ findings: Finding[]; checked: number; plpgsqlCheck: boolean }> {
160
+ const query = (sql: string, params: unknown[] = []) => (tx ? tx.query(sql, params) : owner.query(sql, params));
161
+ const one = async (sql: string, params: unknown[] = []) => (await query(sql, params)).rows[0];
162
+ const app = await one('select * from meta.app where id = $1', [appId]);
163
+ const entries = await appEntries(appId, query);
156
164
  const byKey = new Map(entries.map((e) => [`${e.kind}-${e.id}`, e]));
157
165
  const findings: Finding[] = [];
158
166
  let checked = 0;
@@ -160,15 +168,15 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
160
168
  // rows with every column (the entries only carry the searchable fields)
161
169
  const rowsOf = async (kind: string, table: string, scope: 'page' | 'app') =>
162
170
  (scope === 'page'
163
- ? await owner.query(`select t.*, p.page_no from ${table} t join meta.page p on p.id = t.page_id where p.app_id = $1`, [appId])
164
- : await owner.query(`select * from ${table} where app_id = $1`, [appId])
171
+ ? await query(`select t.*, p.page_no from ${table} t join meta.page p on p.id = t.page_id where p.app_id = $1`, [appId])
172
+ : await query(`select * from ${table} where app_id = $1`, [appId])
165
173
  ).rows.map((row) => ({ kind, row }));
166
174
  const all = (await Promise.all(Object.entries(COMPONENTS).map(([kind, spec]) => rowsOf(kind, spec.table, spec.scope)))).flat();
167
175
 
168
- // ---- SQL, as the application's role, rolled back
169
- const c = await owner.pool.connect();
176
+ // ---- SQL, as the application's role, rolled back (a savepoint in the caller's transaction)
177
+ const c = (tx ?? (await owner.pool.connect())) as pg.PoolClient;
170
178
  try {
171
- await c.query('begin');
179
+ await c.query(tx ? 'savepoint advisor_all' : 'begin');
172
180
  await c.query(`set local statement_timeout = '5s'`);
173
181
  await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
174
182
  if (app.db_role) await c.query(`set local role ${pg.escapeIdentifier(app.db_role)}`);
@@ -193,6 +201,26 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
193
201
  if (problem) findings.push({ ...problem, entry, field: `Layer "${typeof l.name === 'string' ? l.name : ''}" query` });
194
202
  }
195
203
  }
204
+ // (080) search configurations: their source with the columns they name
205
+ for (const { kind, row } of all.filter((x) => x.kind === 'search_config' && x.row.source_type !== 'list')) {
206
+ const entry = byKey.get(`${kind}-${row.id}`) ?? null;
207
+ const problems = searchConfigProblems(row);
208
+ if (problems.length) {
209
+ findings.push({ severity: 'error', entry, field: 'Source', message: problems.join(' ') });
210
+ continue;
211
+ }
212
+ const cols = configColumns(row).map((x) => `t.${pg.escapeIdentifier(x)}`).join(', ') || '*';
213
+ checked++;
214
+ const problem = await checkSql(c, `select ${cols} from ${row.source_type === 'table' ? row.source.trim() : `(${row.source})`} t`, 'select');
215
+ if (problem) findings.push({ ...problem, entry, field: 'Source' });
216
+ }
217
+ // the search regions name existing configurations
218
+ for (const { kind, row } of all.filter((x) => x.kind === 'region' && x.row.type === 'search')) {
219
+ const names: unknown[] = Array.isArray(row.config?.configs) ? row.config.configs : [];
220
+ for (const n of names)
221
+ if (!all.some((x) => x.kind === 'search_config' && x.row.name === String(n).toUpperCase()))
222
+ findings.push({ severity: 'error', entry: byKey.get(`${kind}-${row.id}`) ?? null, field: 'Attributes', message: `Search configuration ${String(n)} doesn't exist.` });
223
+ }
196
224
  // REST handlers
197
225
  for (const { kind, row } of all.filter((x) => x.kind === 'rest_module')) {
198
226
  const entry = byKey.get(`${kind}-${row.id}`) ?? null;
@@ -221,8 +249,13 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
221
249
  }
222
250
  }
223
251
  } finally {
224
- await c.query('rollback').catch(() => {});
225
- c.release();
252
+ if (tx) {
253
+ await c.query('rollback to savepoint advisor_all');
254
+ await c.query('release savepoint advisor_all');
255
+ } else {
256
+ await c.query('rollback').catch(() => {});
257
+ c.release();
258
+ }
226
259
  }
227
260
 
228
261
  // ---- references
@@ -277,6 +310,13 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
277
310
  if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
278
311
  missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
279
312
  if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
313
+ // (078) a URL region: a literal origin, valid attributes
314
+ if (kind === 'region' && row.type === 'url') {
315
+ const target = urlTarget(row.source);
316
+ if ('error' in target) missing(e, 'Source', target.error);
317
+ const problem = urlConfigProblem(row.config);
318
+ if (problem) missing(e, 'Attributes', problem);
319
+ }
280
320
  if (kind === 'computation' && !items.has(String(row.item_name).toUpperCase())) missing(e, 'Item', `Item ${row.item_name} doesn't exist (the computation sets nothing).`);
281
321
  if (kind === 'computation' && row.type === 'item' && !items.has(String(row.expression).toUpperCase())) missing(e, 'Expression', `Item ${row.expression} doesn't exist (its value is always empty).`, 'warning');
282
322
  if (kind === 'process' && (row.type === 'form_dml' || row.type === 'grid_dml') && !row.region_id) missing(e, 'Region', `A ${row.type} process needs its region.`);
@@ -290,7 +330,7 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
290
330
  }
291
331
  if (kind === 'branch' && row.target_type === 'page' && row.target_page && !pageNos.has(Number(row.target_page))) missing(e, 'Page', `Page ${row.target_page} doesn't exist.`);
292
332
  if (kind === 'branch' && row.target_type === 'app'
293
- && !(await owner.one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
333
+ && !(await one('select 1 as ok from meta.app a join meta.page p on p.app_id = a.id where a.alias = $1 and p.page_no = $2', [row.target_app, row.target_page])))
294
334
  missing(e, 'Application (app)', `Application ${row.target_app} has no page ${row.target_page}.`);
295
335
  if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
296
336
  missing(e, 'Document template', row.document ? `Document template ${row.document} doesn't exist.` : 'A document button needs a document template.');
@@ -324,9 +364,9 @@ export async function advise(appId: number): Promise<{ findings: Finding[]; chec
324
364
  }
325
365
 
326
366
  // ---- PL/pgSQL functions of the app's schemas, with plpgsql_check when installed
327
- const plpgsqlCheck = !!(await owner.one(`select 1 as ok from pg_extension where extname = 'plpgsql_check'`));
367
+ const plpgsqlCheck = !!(await one(`select 1 as ok from pg_extension where extname = 'plpgsql_check'`));
328
368
  if (plpgsqlCheck && app.db_role) {
329
- const res = await owner.query(
369
+ const res = await query(
330
370
  `select p.oid::regprocedure::text as fn, r.level, r.message, r.lineno
331
371
  from pg_proc p
332
372
  join pg_namespace n on n.oid = p.pronamespace
@@ -29,7 +29,7 @@ export const REGION_LABELS: Record<string, [string, string]> = {
29
29
  cards: ['Cards', 'layers'], calendar: ['Calendar', 'calendar'], facets: ['Faceted search', 'filter'], smart_filters: ['Smart filters', 'search'], display_selector: ['Region display selector', 'menu'], tasks: ['Task list', 'inbox'],
30
30
  workflows: ['Workflow console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static content', 'region'], dynamic: ['Dynamic content', 'code'],
31
31
  template_component: ['Template component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'],
32
- ai_assistant: ['AI assistant', 'bolt'],
32
+ ai_assistant: ['AI assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'],
33
33
  };
34
34
  export const ITEM_LABELS: Record<string, [string, string]> = {
35
35
  text: ['Text field', 'item'], textarea: ['Text area', 'item'], number: ['Number field', 'item'], date: ['Date picker', 'calendar'],
@@ -37,7 +37,7 @@ export const ITEM_LABELS: Record<string, [string, string]> = {
37
37
  checkbox: ['Checkbox', 'check'], switch: ['Switch', 'check'], checkbox_group: ['Checkbox group', 'check'], multiselect: ['Multi select', 'list'],
38
38
  email: ['E-mail', 'inbox'], tel: ['Phone number', 'item'], url: ['URL', 'item'], color: ['Color picker', 'item'], file: ['File upload', 'upload'],
39
39
  location: ['Location', 'map'], richtext: ['Rich text editor', 'edit'], markdown: ['Markdown editor', 'code'], rating: ['Star rating', 'check'],
40
- combobox: ['Combobox (tags)', 'list'], daterange: ['Date range', 'calendar'], qrcode: ['QR code', 'scan'], hidden: ['Hidden', 'item'], display: ['Display only', 'file'], password: ['Password', 'key'],
40
+ combobox: ['Combobox (tags)', 'list'], daterange: ['Date range', 'calendar'], qrcode: ['QR code', 'scan'], image: ['Display image', 'file'], address: ['Geocoded address', 'map'], hidden: ['Hidden', 'item'], display: ['Display only', 'file'], password: ['Password', 'key'],
41
41
  };
42
42
  export const BUTTON_LABELS: Record<string, [string, string]> = {
43
43
  submit: ['Submit page', 'button'], redirect: ['Redirect to page', 'button'], da: ['Dynamic action', 'bolt'], document: ['Download document', 'download'],
@@ -0,0 +1,74 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { clientIp, logActivity } from '../session.ts';
5
+ import { appOr404 } from './forms.ts';
6
+ import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
7
+
8
+ // Application → Availability (APEX: Application Availability and Build Status,
9
+ // migration 083): who may use the running application, the message they see
10
+ // otherwise, and whether the builder may change it (Run only). Allowed on a
11
+ // Run only application (src/builder/locks.ts), logged as "availability".
12
+
13
+ const MODES: [string, string][] = [
14
+ ['available', 'Available'],
15
+ ['developers', 'Available to developers only'],
16
+ ['restricted', 'Restricted to the users below (and developers)'],
17
+ ['unavailable', 'Unavailable'],
18
+ ];
19
+
20
+ export async function availabilityRoutes(app: FastifyInstance) {
21
+ app.get(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
22
+ const s = await developer(req, reply);
23
+ if (!s) return;
24
+ const a = await appOr404(req.params.id);
25
+ if (!a) return reply.code(404).send('Not found');
26
+ const body = region('Availability', html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
27
+ <p class="muted u-mt0">Who may use the running application. Developers are those signed in to the builder in the same browser
28
+ (they see the developer toolbar). Pages, file downloads, REST modules and the REST API follow this setting; automations,
29
+ workflows, background jobs and queued mail keep running. It belongs to this installation: exports leave it out and an
30
+ import over the application keeps it.</p>
31
+ <div class="form-grid">
32
+ ${select('availability', 'Status', a.availability, MODES)}
33
+ <div class="field" data-wide><label class="label" for="f_message">Message</label>
34
+ <textarea id="f_message" name="message" rows="3" maxlength="2000">${a.availability_message ?? ''}</textarea>
35
+ <small class="help">Shown when the application is unavailable or for developers only, e.g. "We are upgrading the application; back at 14:00." Empty: a standard message in the user's language.</small></div>
36
+ <div class="field" data-wide><label class="label" for="f_users">Users (restricted)</label>
37
+ <textarea id="f_users" name="users" rows="2">${(a.availability_users ?? []).join(', ')}</textarea>
38
+ <small class="help">User names separated by commas or spaces. They sign in as usual; others get "not available to your account".</small></div>
39
+ ${select('build_status', 'Build status', a.build_status, [['run_and_build', 'Run and build'], ['run_only', 'Run only']],
40
+ 'Run only: the builder refuses changes to the application (pages, shared components, settings, restores) until it is set back here. pgkiln import --replace still deploys it.')}
41
+ </div>
42
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`);
43
+ const main = html`${appHeader(a, 'settings')}<div class="ide-body">${body}</div>`;
44
+ return send(reply, s, shell(s, `${a.name} availability`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Availability']], main));
45
+ });
46
+
47
+ app.post(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
48
+ const s = await developer(req, reply);
49
+ if (!s) return;
50
+ const b = req.body ?? {};
51
+ const mode = MODES.some(([v]) => v === b.availability) ? b.availability : 'available';
52
+ const users = [...new Set(String(b.users ?? '').split(/[\s,;]+/).map((u) => u.trim()).filter(Boolean))];
53
+ if (users.some((u) => u.length > 100 || /[:\s]/.test(u))) {
54
+ flash(s, 'User names are at most 100 characters, without spaces or colons.', 'error');
55
+ return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
56
+ }
57
+ const build = b.build_status === 'run_only' ? 'run_only' : 'run_and_build';
58
+ const r = await owner.query(
59
+ `update meta.app set availability = $2, availability_message = $3, availability_users = $4, build_status = $5, updated_at = now() where id = $1`,
60
+ [req.params.id, mode, String(b.message ?? '').trim().slice(0, 2000) || null, users, build]);
61
+ if (r.rowCount) await logActivity({ appId: Number(req.params.id), username: s.username, event: 'availability', ip: clientIp(req), detail: `${mode}, ${build}` });
62
+ flash(s, r.rowCount ? 'Availability saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
63
+ return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
64
+ });
65
+ }
66
+
67
+ /** A badge for an application that is not simply available, or Run only. */
68
+ export const availabilityBadge = (a: { id: number; availability?: string; build_status?: string }) =>
69
+ a.availability && a.availability !== 'available' || a.build_status === 'run_only'
70
+ ? html`<a class="tag tag-warning availability-badge" href="${BASE}/apps/${a.id}/availability">${[
71
+ a.availability && a.availability !== 'available' ? MODES.find(([v]) => v === a.availability)?.[1] : null,
72
+ a.build_status === 'run_only' ? 'Run only' : null,
73
+ ].filter(Boolean).join(' · ')}</a>`
74
+ : raw('');
@@ -0,0 +1,134 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { replaceApp } from '../cli/replace.ts';
3
+ import { owner } from '../db.ts';
4
+ import { html } from '../html.ts';
5
+ import { icon } from '../icons.ts';
6
+ import { clientIp, logActivity } from '../session.ts';
7
+ import { appOr404 } from './forms.ts';
8
+ import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
9
+
10
+ // Application → Backups (APEX: Application Backups, migration 082): the
11
+ // exports this installation keeps of an application (before every import
12
+ // that replaces it, a merge or a restore; daily when it changed; on demand),
13
+ // with download, restore and delete. A restore is an import --replace of the
14
+ // backup, so the version it replaces is backed up as well. Locks of the
15
+ // application are respected (developer() refuses a POST to a locked app).
16
+
17
+ const REASON: Record<string, string> = {
18
+ import: 'before an import', merge: 'before a working copy merge', restore: 'before a restore', daily: 'daily', manual: 'made by hand',
19
+ };
20
+ const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
21
+ const kb = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
22
+
23
+ /** Back up the applications whose newest backup is older than a day and that changed since (the scheduler). */
24
+ export async function backupTick() {
25
+ const due = (await owner.query<{ id: number }>(
26
+ `select a.id from meta.app a
27
+ where a.backups_keep > 0
28
+ and not exists (select 1 from meta.app_backup b where b.app_id = a.id and b.created_at > now() - interval '1 day')`)).rows;
29
+ for (const a of due) await owner.query(`select meta.backup_app($1, 'daily', 'scheduler')`, [a.id]).catch((e) => console.error(`backup of application ${a.id}:`, (e as Error).message));
30
+ return due.length;
31
+ }
32
+
33
+ export async function backupRoutes(app: FastifyInstance) {
34
+ app.get(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
35
+ const s = await developer(req, reply);
36
+ if (!s) return;
37
+ const a = await appOr404(req.params.id);
38
+ if (!a) return reply.code(404).send('Not found');
39
+ const rows = (await owner.query(
40
+ 'select id, created_at, created_by, reason, note, size from meta.app_backup where app_id = $1 order by created_at desc, id desc', [a.id])).rows;
41
+ const body = html`
42
+ ${region('Backups', html`
43
+ <p class="muted u-mt0">Exports of this application kept by this installation: one before every import that replaces it (also from the
44
+ command line and the MCP server), before a working copy merge and a restore, one a day when it changed, and the ones made here.
45
+ The newest ${a.backups_keep} are kept. Backups are not exported.</p>
46
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Made (UTC)</th><th>Why</th><th>By</th><th>Note</th><th class="num">Size</th><th>Actions</th></tr></thead><tbody>
47
+ ${rows.length ? rows.map((b) => html`<tr>
48
+ <td data-label="Made">${when(b.created_at)}</td>
49
+ <td data-label="Why">${REASON[b.reason] ?? b.reason}</td>
50
+ <td data-label="By">${b.created_by ?? ''}</td>
51
+ <td data-label="Note" class="u-break">${b.note ?? ''}</td>
52
+ <td class="num" data-label="Size">${kb(b.size)}</td>
53
+ <td data-label="Actions"><div class="buttons">
54
+ <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')} Download</a>
55
+ <form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/restore" class="u-inline">${csrf(s)}
56
+ <button class="btn btn-sm" data-confirm="Restore the application as it was on ${when(b.created_at)}? Its pages, shared components and settings become those of the backup; the current version is backed up first.">${icon('history')} Restore</button></form>
57
+ <form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/delete" class="u-inline">${csrf(s)}
58
+ <button class="btn btn-sm btn-danger" data-confirm="Delete this backup?">Delete</button></form>
59
+ </div></td></tr>`)
60
+ : html`<tr><td colspan="6" class="empty">No backups yet.</td></tr>`}</tbody></table></div>`)}
61
+ <div class="columns">
62
+ ${region('Back up now', html`<form method="post" action="${BASE}/apps/${a.id}/backups">${csrf(s)}
63
+ <div class="form-grid">${input('note', 'Note', '', { placeholder: 'e.g. before the new dashboard' })}</div>
64
+ <div class="buttons"><button class="btn btn-hot">${icon('save')} Back up now</button></div></form>`)}
65
+ ${region('Keep', html`<form method="post" action="${BASE}/apps/${a.id}/backups/settings">${csrf(s)}
66
+ <div class="form-grid">${input('keep', 'Backups to keep', a.backups_keep, { type: 'number', help: '0 to 500; 0 makes no backups at all (also not before an import).' })}</div>
67
+ <div class="buttons"><button class="btn">Save</button></div></form>`)}
68
+ </div>`;
69
+ const main = html`${appHeader(a, 'pages')}<div class="ide-body">${body}</div>`;
70
+ return send(reply, s, shell(s, `${a.name} backups`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Backups']], main));
71
+ });
72
+
73
+ app.post(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
74
+ const s = await developer(req, reply);
75
+ if (!s) return;
76
+ const a = await appOr404(req.params.id);
77
+ if (!a) return reply.code(404).send('Not found');
78
+ const r = await owner.one(`select meta.backup_app($1, 'manual', $2, $3) as id`, [a.id, s.username, String(req.body?.note ?? '').trim().slice(0, 500) || null]);
79
+ flash(s, r.id ? 'Backup made.' : 'No backup made: this application keeps none (set "Backups to keep").', r.id ? 'ok' : 'error');
80
+ return back(reply, s, `${BASE}/apps/${a.id}/backups`);
81
+ });
82
+
83
+ app.post(`${BASE}/apps/:id(^\\d+$)/backups/settings`, async (req: Req, reply) => {
84
+ const s = await developer(req, reply);
85
+ if (!s) return;
86
+ const keep = Number(req.body?.keep);
87
+ if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, 'Backups to keep: a whole number from 0 to 500.', 'error');
88
+ else {
89
+ await owner.query('update meta.app set backups_keep = $2, updated_at = now() where id = $1', [req.params.id, keep]);
90
+ // fewer to keep: the oldest go now
91
+ await owner.query(`delete from meta.app_backup where app_id = $1 and id not in (select id from meta.app_backup where app_id = $1 order by created_at desc, id desc limit $2)`, [req.params.id, keep]);
92
+ flash(s, 'Saved.');
93
+ }
94
+ return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
95
+ });
96
+
97
+ app.get(`${BASE}/apps/:id(^\\d+$)/backups/:bid(^\\d+$)/download`, async (req: Req, reply) => {
98
+ const s = await developer(req, reply);
99
+ if (!s) return;
100
+ const b = await owner.one(`select b.doc, b.created_at, a.alias from meta.app_backup b join meta.app a on a.id = b.app_id where b.id = $1 and b.app_id = $2`, [req.params.bid, req.params.id]);
101
+ if (!b) return reply.code(404).send('Not found');
102
+ const name = `${b.alias}-${new Date(b.created_at).toISOString().slice(0, 16).replace(/[-:]/g, '').replace('T', '-')}.json`;
103
+ return reply.header('content-disposition', `attachment; filename="${name.replace(/[^\w.-]/g, '_')}"`).header('cache-control', 'private, no-store')
104
+ .type('application/json').send(JSON.stringify(b.doc, null, 2));
105
+ });
106
+
107
+ app.post(`${BASE}/apps/:id(^\\d+$)/backups/:bid(^\\d+$)/restore`, async (req: Req, reply) => {
108
+ const s = await developer(req, reply);
109
+ if (!s) return;
110
+ const a = await appOr404(req.params.id);
111
+ if (!a) return reply.code(404).send('Not found');
112
+ try {
113
+ const at = await owner.tx(async (c) => {
114
+ const b = (await c.query('select doc, created_at from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, a.id])).rows[0];
115
+ if (!b) throw new Error('Backup not found.');
116
+ await replaceApp(c, b.doc, a.alias, { backup: { reason: 'restore', user: s.username, note: `before restoring the backup of ${when(b.created_at)}` } });
117
+ return when(b.created_at);
118
+ });
119
+ await logActivity({ appId: a.id, username: s.username, event: 'backup_restored', ip: clientIp(req), detail: `backup ${req.params.bid} of ${at}` });
120
+ flash(s, `Restored the application as it was on ${at}. The version it replaced is the newest backup.`);
121
+ } catch (e) {
122
+ flash(s, `Restore failed: ${(e as Error).message}`, 'error');
123
+ }
124
+ return back(reply, s, `${BASE}/apps/${a.id}/backups`);
125
+ });
126
+
127
+ app.post(`${BASE}/apps/:id(^\\d+$)/backups/:bid(^\\d+$)/delete`, async (req: Req, reply) => {
128
+ const s = await developer(req, reply);
129
+ if (!s) return;
130
+ const r = await owner.query('delete from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, req.params.id]);
131
+ flash(s, r.rowCount ? 'Backup deleted.' : 'Not found.', r.rowCount ? 'ok' : 'error');
132
+ return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
133
+ });
134
+ }