pgkiln 0.35.0 → 0.37.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 (103) hide show
  1. package/SECURITY.md +11 -9
  2. package/db/migrations/087_login_page.sql +175 -0
  3. package/db/migrations/088_region_templates.sql +18 -0
  4. package/db/migrations/089_page_groups.sql +330 -0
  5. package/db/migrations/090_workspace_isolation.sql +512 -0
  6. package/docs/apex-feature-parity.md +23 -21
  7. package/docs/guide/03-builder.md +85 -8
  8. package/docs/guide/04-pages-and-regions.md +42 -3
  9. package/docs/guide/08-security.md +46 -5
  10. package/docs/guide/11-from-apex.md +4 -1
  11. package/docs/guide/12-development.md +13 -3
  12. package/docs/guide/17-mobile.md +3 -1
  13. package/docs/guide/18-cli.md +2 -0
  14. package/docs/guide/20-ai-agents.md +2 -0
  15. package/examples/hr/hr_40_sample_data.sql +1 -1
  16. package/examples/hr/hr_54_region_templates.sql +67 -0
  17. package/examples/hr/hr_55_page_groups.sql +24 -0
  18. package/package.json +1 -1
  19. package/public/app.css +60 -3
  20. package/public/app.js +89 -3
  21. package/public/builder.css +11 -0
  22. package/public/builder.js +50 -0
  23. package/public/code-editor.css +3 -3
  24. package/public/sw.js +5 -3
  25. package/src/app.ts +20 -0
  26. package/src/appfiles.ts +1 -0
  27. package/src/automations.ts +3 -2
  28. package/src/builder/advisor.ts +25 -5
  29. package/src/builder/ai-builder.ts +27 -18
  30. package/src/builder/blueprints.ts +7 -7
  31. package/src/builder/code-editor.ts +3 -2
  32. package/src/builder/components.ts +15 -2
  33. package/src/builder/dataload.ts +24 -14
  34. package/src/builder/ddl-tools.ts +117 -0
  35. package/src/builder/designer.ts +29 -3
  36. package/src/builder/documents.ts +2 -1
  37. package/src/builder/forms.ts +6 -4
  38. package/src/builder/home.ts +6 -2
  39. package/src/builder/login-page.ts +82 -0
  40. package/src/builder/newapp.ts +21 -3
  41. package/src/builder/quicksql.ts +3 -2
  42. package/src/builder/region-help.ts +4 -2
  43. package/src/builder/region-settings.ts +19 -3
  44. package/src/builder/report-settings.ts +4 -2
  45. package/src/builder/routes.ts +18 -6
  46. package/src/builder/sampledata.ts +22 -18
  47. package/src/builder/scripts.ts +27 -16
  48. package/src/builder/shared.ts +1 -1
  49. package/src/builder/sql.ts +18 -10
  50. package/src/builder/ui.ts +2 -2
  51. package/src/builder/unload.ts +3 -2
  52. package/src/builder/workspaces.ts +175 -15
  53. package/src/cli/main.ts +45 -0
  54. package/src/cli/mcp.ts +42 -0
  55. package/src/cli/replace.ts +4 -0
  56. package/src/db.ts +36 -6
  57. package/src/ddl.ts +510 -0
  58. package/src/debug.ts +3 -2
  59. package/src/i18n/ar.ts +17 -0
  60. package/src/i18n/cs.ts +17 -0
  61. package/src/i18n/da.ts +17 -0
  62. package/src/i18n/de.ts +17 -0
  63. package/src/i18n/el.ts +17 -0
  64. package/src/i18n/es.ts +17 -0
  65. package/src/i18n/fi.ts +17 -0
  66. package/src/i18n/fr.ts +17 -0
  67. package/src/i18n/he.ts +17 -0
  68. package/src/i18n/it.ts +17 -0
  69. package/src/i18n/ja.ts +17 -0
  70. package/src/i18n/ko.ts +17 -0
  71. package/src/i18n/nb.ts +17 -0
  72. package/src/i18n/pl.ts +17 -0
  73. package/src/i18n/pt.ts +17 -0
  74. package/src/i18n/ru.ts +17 -0
  75. package/src/i18n/sv.ts +17 -0
  76. package/src/i18n/tr.ts +17 -0
  77. package/src/i18n/uk.ts +17 -0
  78. package/src/i18n/zh.ts +17 -0
  79. package/src/i18n.ts +34 -0
  80. package/src/icons.ts +2 -1
  81. package/src/layout.ts +3 -2
  82. package/src/metadata.ts +10 -4
  83. package/src/migrate.ts +3 -0
  84. package/src/process-jobs.ts +7 -2
  85. package/src/restsync.ts +3 -2
  86. package/src/runtime/devbar.ts +3 -1
  87. package/src/runtime/display-selector.ts +12 -4
  88. package/src/runtime/grid.ts +170 -26
  89. package/src/runtime/lists.ts +11 -1
  90. package/src/runtime/password-reset.ts +171 -0
  91. package/src/runtime/pwa.ts +7 -10
  92. package/src/runtime/regions.ts +33 -0
  93. package/src/runtime/render.ts +15 -0
  94. package/src/runtime/report-views.ts +4 -2
  95. package/src/runtime/report.ts +36 -22
  96. package/src/runtime/routes.ts +39 -33
  97. package/src/runtime/signin.ts +80 -0
  98. package/src/sampledata.ts +3 -3
  99. package/src/session.ts +5 -2
  100. package/src/static-version.ts +34 -0
  101. package/src/websources.ts +3 -1
  102. package/src/workflow.ts +65 -9
  103. package/src/workspace-db.ts +166 -0
@@ -5,7 +5,7 @@ import { icon } from '../icons.ts';
5
5
  import { APP_COLORS, BASE, csrf, developer, input, region, select, send, shell, type Req } from './ui.ts';
6
6
  import type { Session } from '../session.ts';
7
7
  import { isAdmin } from './locks.ts';
8
- import { currentWorkspace, inWorkspaceSql } from './workspaces.ts';
8
+ import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
9
9
 
10
10
  // The workspace pages of the builder, laid out like APEX's App Builder: the
11
11
  // home page (tiles, a searchable list of applications as a report or as
@@ -285,7 +285,11 @@ export async function homeRoutes(app: FastifyInstance) {
285
285
  ${card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
286
286
  ${card(`${BASE}/dashboard`, icon('activity'), 'Dashboard', 'Usage and problems across the workspace.')}
287
287
  ${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), 'Instance settings', 'Session length, sign-in throttling and the server\'s configuration (administrators).') : ''}
288
- ${(await isAdmin(s.username)) ? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).') : ''}
288
+ ${(await isAdmin(s.username))
289
+ ? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).')
290
+ : s.workspace && (await workspaceAdmin(s.username, s.workspace.id))
291
+ ? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), `Workspace ${s.workspace.name}`, 'Its name and developers (workspace administrators).')
292
+ : ''}
289
293
  ${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
290
294
  ${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
291
295
  ${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
@@ -0,0 +1,82 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { back, BASE, developer, flash, type Req } from './ui.ts';
5
+
6
+ // Application settings → Sign-in methods (migration 087): the application's
7
+ // own login page (APEX: the login page, usually 9999) and "Forgot password?".
8
+ // "Create login page" adds a minimal public page with a login region and
9
+ // makes it the login page.
10
+
11
+ interface AppRow {
12
+ id: number;
13
+ name: string;
14
+ authentication: string;
15
+ local_login: boolean;
16
+ login_page: number | null;
17
+ password_reset: boolean;
18
+ }
19
+
20
+ /** The fields in the settings form. */
21
+ export async function loginSettingsFields(a: AppRow) {
22
+ const pages = (await owner.query<{ page_no: number; name: string; requires_auth: boolean }>(
23
+ 'select page_no, name, requires_auth from meta.page where app_id = $1 order by page_no', [a.id],
24
+ )).rows;
25
+ const mail = await owner.one<{ service: string | null; enabled: boolean | null; from_address: string | null }>(
26
+ `select s.name as service, s.enabled, ams.from_address
27
+ from meta.app_mail_service ams join meta.mail_service s on s.id = ams.service_id where ams.app_id = $1`, [a.id],
28
+ );
29
+ const mailOk = !!(mail?.enabled && mail.from_address?.trim());
30
+ const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
31
+ const current = a.login_page ? String(a.login_page) : '';
32
+ return html`<div class="form-grid">
33
+ <div class="field"><label class="label" for="f_login_page">Login page</label>
34
+ <select id="f_login_page" name="login_page">${opt('', '- the built-in sign-in page -', current)}${pages.map((p) =>
35
+ opt(String(p.page_no), `${p.page_no} ${p.name}${p.requires_auth ? ' (requires sign-in: not usable)' : ''}`, current))}</select>
36
+ <small class="help">A page of the application with a <em>Login</em> region replaces the built-in sign-in page (APEX: the login page). It must not require authentication; with page template <em>Minimal</em> it has no header and navigation.
37
+ <button class="link-button" formaction="${BASE}/apps/${a.id}/login-page">Create a login page</button></small></div>
38
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
39
+ <small class="help">Users with a password and an e-mail address in the user directory get a link to choose a new password (single-use, valid for 30 minutes).
40
+ ${mailOk
41
+ ? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
42
+ : html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
43
+ Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
44
+ </div>`;
45
+ }
46
+
47
+ /** Save them (with the rest of the settings form). */
48
+ export async function saveLoginSettings(appId: string, b: Record<string, string | undefined>) {
49
+ const page = b.login_page?.trim() ? Number(b.login_page) : null;
50
+ if (page !== null && !(await owner.one('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, page]))) throw new Error(`Login page: there is no page ${b.login_page}.`);
51
+ await owner.query('update meta.app set login_page = $2, password_reset = $3 where id = $1', [appId, page, b.password_reset === 'true']);
52
+ }
53
+
54
+ export async function loginPageRoutes(app: FastifyInstance) {
55
+ // Create a login page: page 9999 (or the next free number), public, minimal, with a login region
56
+ app.post(`${BASE}/apps/:id/login-page`, async (req: Req, reply) => {
57
+ const s = await developer(req, reply);
58
+ if (!s) return;
59
+ const a = await owner.one<{ id: number; name: string }>('select id, name from meta.app where id = $1', [req.params.id]);
60
+ if (!a) return reply.code(404).send('Not found');
61
+ try {
62
+ const { page, no } = await owner.tx(async (c) => {
63
+ const n = (await c.query<{ n: number }>(
64
+ `select case when not exists (select 1 from meta.page where app_id = $1 and page_no = 9999) then 9999
65
+ else (select max(page_no) + 1 from meta.page where app_id = $1) end as n`, [a.id],
66
+ )).rows[0].n;
67
+ const id = (await c.query<{ id: number }>(
68
+ `insert into meta.page (app_id, page_no, name, title, requires_auth, page_template) values ($1, $2, 'Login', $3, false, 'minimal') returning id`,
69
+ [a.id, n, a.name],
70
+ )).rows[0].id;
71
+ await c.query(`insert into meta.region (page_id, seq, title, type, template) values ($1, 10, $2, 'login', 'standard')`, [id, a.name]);
72
+ await c.query('update meta.app set login_page = $2, updated_at = now() where id = $1', [a.id, n]);
73
+ return { page: id, no: n };
74
+ });
75
+ flash(s, `Page ${no} "Login" created and made the login page. Add regions around the sign-in form in the Page Designer.`);
76
+ return back(reply, s, `${BASE}/pages/${page}`);
77
+ } catch (e) {
78
+ flash(s, (e as Error).message, 'error');
79
+ return back(reply, s, `${BASE}/apps/${req.params.id}/settings`);
80
+ }
81
+ });
82
+ }
@@ -64,9 +64,27 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
64
64
  const S = pg.escapeIdentifier(a.schema);
65
65
  const R = pg.escapeIdentifier(a.role);
66
66
  // The parsing schema: a role that can only use this schema.
67
- await c.query(`create schema if not exists ${S}`);
68
- if (!(await c.query('select 1 from pg_roles where rolname = $1', [a.role])).rowCount) await c.query(`create role ${R} nologin`);
69
- await c.query(`grant ${R} to pgkiln_runtime`);
67
+ // (090) an isolated workspace's application gets a new role, and a schema that is new (owned by that
68
+ // role) or one of the workspace's own: never another workspace's
69
+ const isolated = (await c.query('select 1 from meta.workspace where id = $1 and isolated', [a.workspaceId])).rowCount;
70
+ const roleExists = (await c.query('select 1 from pg_roles where rolname = $1', [a.role])).rowCount;
71
+ if (isolated) {
72
+ // (a taken alias fails below, with its own message)
73
+ const aliasTaken = (await c.query('select 1 from meta.app where alias = $1', [a.alias])).rowCount;
74
+ if (roleExists && !aliasTaken) throw new Error(`The database role ${a.role} exists already: choose another alias.`);
75
+ const foreign = await c.query(
76
+ `select 1 from pg_namespace n where n.nspname = $1
77
+ and n.nspowner::regrole::text not in (select role_name from meta.workspace_role where workspace_id = $2)`,
78
+ [a.schema, a.workspaceId],
79
+ );
80
+ if (foreign.rowCount) throw new Error(`The schema ${a.schema} exists already and doesn't belong to this workspace: choose another schema.`);
81
+ }
82
+ if (!roleExists) await c.query(`create role ${R} nologin`);
83
+ await c.query(`create schema if not exists ${S}${isolated ? ` authorization ${R}` : ''}`);
84
+ if (isolated) {
85
+ // a name the workspace lists already (an application deleted, its role dropped) is its own; another workspace's is refused
86
+ await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict (workspace_id, role_name) do nothing', [a.workspaceId, a.role]);
87
+ } else await c.query(`grant ${R} to pgkiln_runtime`);
70
88
  await c.query(`grant usage on schema ${S} to ${R}`);
71
89
  await c.query(`grant select, insert, update, delete on all tables in schema ${S} to ${R}`);
72
90
  await c.query(`grant usage, select on all sequences in schema ${S} to ${R}`);
@@ -6,6 +6,7 @@ import { quickSql } from '../quicksql.ts';
6
6
  import { clientIp, type Session } from '../session.ts';
7
7
  import { runAndRecord, runOptions, SCRIPT_MAX_MB } from './scripts.ts';
8
8
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
+ import { currentWorkspace } from './workspaces.ts';
9
10
 
10
11
  // SQL Workshop → Quick SQL: shorthand → DDL preview, then save it as a SQL
11
12
  // script or run it (as a script, so the results per statement are kept).
@@ -85,8 +86,8 @@ export async function quickSqlRoutes(app: FastifyInstance) {
85
86
  return back(reply, s, `${BASE}/sql/scripts/runs/${id}`);
86
87
  }
87
88
  let unique = name;
88
- for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1', [unique]); n++) unique = `${name} (${n})`;
89
- const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by) values ($1, $2, $3, $3) returning id`, [unique, content, s.username]);
89
+ for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [unique, currentWorkspace(s)]); n++) unique = `${name} (${n})`;
90
+ const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [unique, content, s.username, currentWorkspace(s)]);
90
91
  flash(s, `Saved as script “${unique}”.`);
91
92
  return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
92
93
  });
@@ -12,7 +12,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
12
12
  [['chart'], '{"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"}'],
13
13
  [['cards'], '{"style":"metric","link":{...},"max_rows":500}'],
14
14
  [['dynamic'], '{"max_rows":1000}'],
15
- [['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME"}'],
15
+ [['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME","views":["row","group","chart"] (views besides the grid; left out: all three)}'],
16
16
  [['calendar'], '{"link":{...}}'],
17
17
  [['facets'], '{"report":<region id>,"search":true,"facets":[{"column":"job","label":"Job","exclude":true},{"column":"sal","type":"range","ranges":[{"to":1000},{"from":1000}],"custom":true},{"column":"rating","type":"star","max":5}]}'],
18
18
  [['smart_filters'], '{"report":<region id>,"suggestions":3,"placeholder":"…","facets":[…as facets]}'],
@@ -25,6 +25,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
25
25
  [['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
26
26
  [['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
27
27
  [['form'], '{"lost_update":false} saves without checking that the row is unchanged since it was shown'],
28
+ [['login'], '(no attributes) the sign-in form of the application\'s login page (Application settings → Login page): providers, user name and password, "Keep me signed in", "Forgot password?"'],
28
29
  [['url'], '{"height":480,"sandbox":["allow-scripts","allow-forms","allow-popups","allow-same-origin"] (the default; false: no sandbox),"fullscreen":true}'],
29
30
  [['search'], '{"configs":["CUSTOMERS","ORDERS"] (Shared Components → Search configurations; empty: all),"item":"P1_SEARCH" (search with this item\'s value instead of a field of its own),"placeholder":"…","min_length":2,"empty":"No results"}'],
30
31
  ];
@@ -53,6 +54,7 @@ const ANCHORS: Record<string, string> = {
53
54
  plugin: 'plug-ins-with-their-own-code',
54
55
  tasks: 'approvals-and-the-task-list',
55
56
  workflows: 'workflows',
57
+ login: 'the-login-page',
56
58
  };
57
59
 
58
60
  /** The help of every type (a region whose type isn't known yet). */
@@ -63,7 +65,7 @@ export function regionConfigHelp(type: string | null | undefined): { text: strin
63
65
  if (!type) return { text: ALL_REGION_CONFIG_HELP, url: null };
64
66
  const own = REGION_CONFIG_HELP.filter(([types]) => types.includes(type)).map(([, text]) => text);
65
67
  const any = REGION_CONFIG_HELP.filter(([types]) => types.includes('*')).map(([, text]) => `any region: ${text}`);
66
- const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : 'pages-and-regions';
68
+ const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : type === 'login' ? 'security' : 'pages-and-regions';
67
69
  return {
68
70
  text: [...own, ...any].join(' · ') || 'No attributes of its own.',
69
71
  url: ANCHORS[type] ? `${GUIDE}/${chapter}#${ANCHORS[type]}` : null,
@@ -242,6 +242,10 @@ function mergeGridFeatures(out: Config, b: Body, a: Allowed, columns: string[])
242
242
  set('saved_reports', b.saved_reports === 'true' ? undefined : false);
243
243
  const pub = (b.public_reports ?? '').trim().toUpperCase();
244
244
  set('public_reports', pub && a.authz?.has(pub) ? pub : undefined);
245
+ // views besides the grid: all three is the default (left out); none ticked means the grid only
246
+ // (only when the form has the Views fieldset)
247
+ const views = GRID_USER_VIEWS.filter((v) => b[`view_${v}`] === 'true');
248
+ if (b.grid_views === '1') set('views', views.length < GRID_USER_VIEWS.length ? views : undefined);
245
249
  // row actions: an edit link, duplicate and delete; custom links stay as written in the JSON
246
250
  const edit = mergeLink(b, a.pages, 'edit');
247
251
  const ra: Config = { ...(out.row_actions && typeof out.row_actions === 'object' ? out.row_actions : {}) };
@@ -342,8 +346,10 @@ export function mergeSmartFiltersSettings(config: Config, b: Body, a: Allowed):
342
346
  export function mergeDisplaySelectorSettings(config: Config, b: Body): Config {
343
347
  const out = { ...config };
344
348
  const set = setter(out);
345
- set('style', b.style === 'select' ? 'select' : undefined);
349
+ set('style', b.style === 'select' || b.style === 'carousel' ? b.style : undefined);
346
350
  set('show_all', b.show_all === 'true' ? undefined : false);
351
+ const interval = Number(b.interval);
352
+ set('interval', b.style === 'carousel' && Number.isInteger(interval) && interval >= 1 && interval <= 120 ? interval : undefined);
347
353
  set('remember', b.remember === 'true' ? undefined : false);
348
354
  return out;
349
355
  }
@@ -497,6 +503,11 @@ async function gridFields(appId: number, r: RegionRow, id: (n: string) => string
497
503
  <div class="field"><label class="label" for="${id('public_reports')}">Public reports by</label>
498
504
  <select id="${id('public_reports')}" name="public_reports">${opt('', '- nobody -', cfg.public_reports)}${authz.map((n) => opt(n, n, cfg.public_reports))}</select></div>
499
505
  </div></fieldset>
506
+ <input type="hidden" name="grid_views" value="1">
507
+ <fieldset class="prop-group"><legend>Views</legend><div class="form-grid">
508
+ ${GRID_USER_VIEWS.map((v) => check(`view_${v}`, GRID_VIEW_LABELS[v], !Array.isArray(cfg.views) || cfg.views.includes(v)))}
509
+ </div>
510
+ <small class="help">Views users may switch to besides the grid. The single row view edits one row at a time (also from the row actions menu); group by and chart are set up in Actions.</small></fieldset>
500
511
  <fieldset class="prop-group"><legend>Row actions</legend><div class="form-grid">
501
512
  ${check('row_actions', 'Row actions menu', !!cfg.row_actions)}
502
513
  ${check('row_duplicate', 'Duplicate', cfg.row_actions?.duplicate !== false)}
@@ -634,6 +645,8 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
634
645
  }
635
646
 
636
647
  const VIEW_LABELS: Record<string, string> = { month: 'Month', week: 'Week', day: 'Day', list: 'List' };
648
+ const GRID_USER_VIEWS = ['row', 'group', 'chart'] as const;
649
+ const GRID_VIEW_LABELS: Record<string, string> = { row: 'Single row view', group: 'Group by', chart: 'Chart' };
637
650
 
638
651
  async function calendarFields(appId: number, r: RegionRow, id: (n: string) => string, pages: { page_no: number; name: string }[]) {
639
652
  const cfg = r.config ?? {};
@@ -676,7 +689,10 @@ async function displaySelectorFields(r: RegionRow, pageId: number, id: (n: strin
676
689
  where page_id = $1 and id <> $2 and type <> 'display_selector' order by seq, id`, [pageId, r.id])).rows;
677
690
  return html`<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
678
691
  <div class="field"><label class="label" for="${id('style')}">Show as</label>
679
- <select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}</select></div>
692
+ <select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}${opt('carousel', 'Carousel (one at a time, Previous and Next)', cfg.style)}</select></div>
693
+ <div class="field"><label class="label" for="${id('interval')}">Carousel: next region every (seconds)</label>
694
+ <input id="${id('interval')}" name="interval" type="number" min="1" max="120" value="${cfg.interval ?? ''}" placeholder="empty: only by hand">
695
+ <small class="help">Not while the pointer or the focus is in the carousel, nor for users who prefer reduced motion. A carousel has no "Show all".</small></div>
680
696
  </div>
681
697
  ${check('show_all', '"Show all" choice (shows every region)', cfg.show_all !== false)}
682
698
  ${check('remember', 'Remember the choice during the session', cfg.remember !== false)}</fieldset>
@@ -765,7 +781,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
765
781
  <div class="field"><label class="label" for="${id('list')}">List</label>
766
782
  <select id="${id('list')}" name="list">${opt('', '- choose -', cfg.list)}${lists.map((l) => opt(l.name, `${l.name} (${l.type})`, cfg.list))}${cfg.list && !lists.some((l) => l.name === cfg.list) ? opt(cfg.list, `${cfg.list} (missing!)`, cfg.list) : ''}</select></div>
767
783
  <div class="field"><label class="label" for="${id('template')}">Template</label>
768
- <select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}</select></div>
784
+ <select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}${opt('wizard_progress', 'Wizard progress (numbered steps)', cfg.template)}</select></div>
769
785
  </div></fieldset>`;
770
786
  break;
771
787
  }
@@ -3,6 +3,7 @@ import { designSql } from './websources.ts';
3
3
  import pg from 'pg';
4
4
  import { applyBinds } from '../binds.ts';
5
5
  import { owner } from '../db.ts';
6
+ import { devDbForApp } from '../workspace-db.ts';
6
7
  import { html, raw } from '../html.ts';
7
8
  import { formatMaskError } from '../runtime/format.ts';
8
9
  import { heading } from '../runtime/items.ts';
@@ -19,12 +20,13 @@ const PAGE_SIZES = ['5', '10', '15', '25', '50', '100'];
19
20
  /** The column names of a report's query: run with limit 0, as the app's role, then rolled back. */
20
21
  export async function reportColumns(appId: number, source: string | null): Promise<{ columns: string[] } | { error: string }> {
21
22
  if (!source?.trim()) return { error: 'The region has no source query yet.' };
22
- const c = await owner.pool.connect();
23
+ // the role from pgkiln's tables (the owner); the query on the developer's connection (090)
24
+ const role = (await owner.one<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId]))?.db_role;
25
+ const c = await (await devDbForApp(appId)).pool.connect();
23
26
  try {
24
27
  await c.query('begin');
25
28
  await c.query(`set local statement_timeout = '5s'`);
26
29
  await c.query(`select set_config('pgkiln.app_id', $1, true)`, [String(appId)]);
27
- const role = (await c.query('select db_role from meta.app where id = $1', [appId])).rows[0]?.db_role;
28
30
  if (role) await c.query(`set local role ${pg.escapeIdentifier(role)}`);
29
31
  const sql = applyBinds(source.trim().replace(/;+\s*$/, ''), {});
30
32
  const res = await c.query(`select * from (\n${sql}\n) "__q" limit 0`);
@@ -12,11 +12,12 @@ import { pwaSection } from './pwa.ts';
12
12
  import { documentShell } from '../layout.ts';
13
13
  import { passwordProblem } from '../accounts.ts';
14
14
  import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
15
+ import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
15
16
  import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
16
17
  import { appOr404 } from './forms.ts';
17
18
  import { docToFiles, filesToZip } from '../appfiles.ts';
18
19
  import { homeRoutes, rememberApp } from './home.ts';
19
- import { appAllowed, currentWorkspace, placeApp, workspaceRoutes } from './workspaces.ts';
20
+ import { appAllowed, currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
20
21
  import { instanceRoutes } from './instance.ts';
21
22
  import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
22
23
  import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
@@ -169,7 +170,7 @@ export async function builderRoutes(app: FastifyInstance) {
169
170
  await placeApp(c, made.id, ws);
170
171
  // (sprint 51) the Advisor on the imported application, before the commit: errors refuse it
171
172
  if (req.body?.check === 'true') {
172
- const errors = (await advise(made.id, c)).findings.filter((f) => f.severity === 'error');
173
+ const errors = (await advise(made.id, c, { db: await workshopDb(s) })).findings.filter((f) => f.severity === 'error');
173
174
  if (errors.length)
174
175
  throw new Error(`the check found ${errors.length} error(s), nothing was imported: ${errors.slice(0, 5).map((f) =>
175
176
  `${f.entry ? `${f.entry.pageNo !== null ? `page ${f.entry.pageNo}, ` : ''}${f.entry.label}` : 'database'} (${f.field}): ${f.message}`).join('; ')}${errors.length > 5 ? '; …' : ''}. Fix them, or import without the check.`);
@@ -207,6 +208,11 @@ export async function builderRoutes(app: FastifyInstance) {
207
208
  wizardTables(a.db_role),
208
209
  ]);
209
210
  const nextPage = Math.max(0, ...pages.rows.map((p) => p.page_no)) + 1;
211
+ // (089) page groups: a column and a filter (?group=name, or "-" for pages without one)
212
+ const groups = (await owner.query<{ name: string }>('select name from meta.page_group where app_id = $1 order by name', [a.id])).rows.map((g) => g.name);
213
+ const grouped = groups.length > 0 || pages.rows.some((p) => p.page_group);
214
+ const groupFilter = typeof req.query?.group === 'string' ? req.query.group : '';
215
+ const shownPages = pages.rows.filter((p) => !groupFilter || (groupFilter === '-' ? !p.page_group : p.page_group === groupFilter));
210
216
  const locks = new Map((await appLocks(a.id)).map((l) => [l.page_no, l]));
211
217
  const commentsPerPage = new Map((await owner.query('select page_no, count(*)::int as n from meta.dev_comment where app_id = $1 group by 1', [a.id])).rows.map((r) => [r.page_no, r.n]));
212
218
  const appLock = locks.get(0);
@@ -214,10 +220,14 @@ export async function builderRoutes(app: FastifyInstance) {
214
220
  const main = html`
215
221
  ${appHeader(a, 'pages')}
216
222
  ${appLock && appLock.locked_by !== s.username ? html`<div class="alert alert-error" role="status">${icon('key')} ${lockText(appLock)} Changes are refused until it is unlocked.</div>` : ''}
217
- ${region('Pages', html`<div class="table-wrap"><table class="report">
218
- <thead><tr><th class="num">Page</th><th>Name</th><th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
219
- <tbody>${pages.rows.map((p) => html`<tr>
220
- <td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td><td>${p.mode}</td>
223
+ ${region('Pages', html`${grouped
224
+ ? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">Page group</label>
225
+ <select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
226
+ <button class="btn u-selfend">Show</button></form>`
227
+ : ''}<div class="table-wrap"><table class="report">
228
+ <thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
229
+ <tbody>${shownPages.map((p) => html`<tr>
230
+ <td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
221
231
  <td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
222
232
  <td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
223
233
  <td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
@@ -354,6 +364,7 @@ export async function builderRoutes(app: FastifyInstance) {
354
364
  : html`<p class="muted">No LDAP directories configured. <a href="${BASE}/users/directories">Add one</a> to check passwords against LDAP or Active Directory.</p>`}
355
365
  <small class="help">LDAP passwords are checked by the username and password form, after local accounts, so keep that method on.</small>
356
366
  <div class="form-grid">${input('session_group', 'Session sharing group', a.session_group ?? '', { placeholder: 'empty: not shared', help: 'Applications with the same group (and the user directory as authentication) share a sign-in: after signing in to one, the others open without a new sign-in, each with its own access check and roles; signing out of one signs out of all (APEX: session sharing). Lower case letters, digits and _.' })}</div>
367
+ ${await loginSettingsFields(a)}
357
368
  <div class="form-grid">${input('remember_me_days', '"Keep me signed in" for (days)', a.remember_me_days ?? '', { type: 'number', placeholder: 'empty: not offered', help: 'Offers a checkbox on the sign-in form (APEX: persistent authentication). The browser stays signed in for this many days after the sign-in, also when the session ends; signing out, a new password, deactivation or removed access ends it. 1 to 365.' })}</div>
358
369
  ${providers.length
359
370
  ? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> Sign in with ${pr.display_name}${pr.enabled ? '' : ' (disabled)'}</label></div>`)
@@ -484,6 +495,7 @@ export async function builderRoutes(app: FastifyInstance) {
484
495
  /^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null],
485
496
  );
486
497
  await saveTimeZoneSettings(req.params.id, b);
498
+ await saveLoginSettings(req.params.id, b);
487
499
  flash(s, 'Settings saved.');
488
500
  } catch (e) {
489
501
  flash(s, (e as Error).message, 'error');
@@ -31,6 +31,7 @@ import {
31
31
  } from '../sampledata.ts';
32
32
  import { clientIp, logActivity, type Session } from '../session.ts';
33
33
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
34
+ import { currentWorkspace, workshopDb } from './workspaces.ts';
34
35
 
35
36
  // SQL Workshop → Sample Data (APEX 26.1: Data Generator): generate realistic
36
37
  // rows for one or more tables of a schema (src/sampledata.ts). Step 1 picks a
@@ -58,9 +59,9 @@ interface Saved {
58
59
  tables: TableSpec[];
59
60
  }
60
61
 
61
- async function saved(id: unknown) {
62
+ async function saved(s: Session, id: unknown) {
62
63
  if (!/^\d{1,9}$/.test(String(id ?? ''))) return undefined;
63
- return owner.one<Saved>('select * from meta.data_generator where id = $1', [id]);
64
+ return owner.one<Saved>('select * from meta.data_generator where id = $1 and workspace_id = $2', [id, currentWorkspace(s)]);
64
65
  }
65
66
 
66
67
  /** The definition posted by the generator form (never trusted: plan() checks it against the catalog). */
@@ -108,12 +109,13 @@ export async function sampleDataRoutes(app: FastifyInstance) {
108
109
 
109
110
  const intro = html`<p class="muted">Generate realistic rows for the tables of a schema: names, e-mail addresses, dates and numbers in a range, values from a list,
110
111
  foreign keys that pick existing parent rows. Preview them, insert them in one transaction (parents first) or download them as SQL or CSV.
111
- Runs as the builder's owner connection, like <a href="${BASE}/sql">SQL Commands</a>.</p>`;
112
+ Runs as the builder's owner connection (an isolated workspace's developer login for its developers), like <a href="${BASE}/sql">SQL Commands</a>.</p>`;
112
113
 
113
114
  // ------------------------------------------------------------ step 1 and 2: schema and tables
114
115
  const start = async (s: Session, reply: FastifyReply, schema: string | undefined) => {
115
- const all = await schemas(owner.pool);
116
- const gens = (await owner.query<Saved & { updated_at: string; updated_by: string }>('select id, name, description, schema_name, tables, updated_at, updated_by from meta.data_generator order by lower(name)')).rows;
116
+ const db = await workshopDb(s);
117
+ const all = (await schemas(db.pool)).filter((x) => db === owner || x.usable);
118
+ const gens = (await owner.query<Saved & { updated_at: string; updated_by: string }>('select id, name, description, schema_name, tables, updated_at, updated_by from meta.data_generator where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
117
119
  const savedList = gens.length
118
120
  ? html`<ul class="sd-saved">${gens.map(
119
121
  (g) => html`<li><a href="${sdUrl}/${g.id}">${g.name}</a> <span class="muted small">${g.schema_name}: ${g.tables.map((t) => `${t.table} (${t.rows})`).join(', ')}</span>
@@ -129,7 +131,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
129
131
  <button class="btn">Choose</button></form>`;
130
132
  let tablesForm: Raw | '' = '';
131
133
  if (chosen) {
132
- const tables = await schemaTables(owner.pool, chosen);
134
+ const tables = await schemaTables(db.pool, chosen);
133
135
  tablesForm = html`<form method="get" action="${sdUrl}" class="u-mt1"><input type="hidden" name="schema" value="${chosen}">
134
136
  <fieldset class="prop-group"><legend>Tables of ${chosen}</legend><div class="qb-pick">${tables.map(
135
137
  (t) => html`<label class="check"><input type="checkbox" name="t" value="${t.name}"> ${t.name} <span class="muted small">${t.estimate >= 0 ? `~${t.estimate.toLocaleString('en')} rows` : ''}</span></label>`,
@@ -155,23 +157,24 @@ export async function sampleDataRoutes(app: FastifyInstance) {
155
157
  }
156
158
 
157
159
  /** The tables of a definition with their catalog info; specs proposed for columns the definition doesn't mention. */
158
- const describeAll = async (def: GeneratorDef) => {
160
+ const describeAll = async (s: Session, def: GeneratorDef) => {
161
+ const db = (await workshopDb(s)).pool;
159
162
  const out: { info: TableInfo; spec: TableSpec }[] = [];
160
163
  const missing: string[] = [];
161
164
  for (const t of def.tables) {
162
- const info = await describe(owner.pool, def.schema, t.table);
165
+ const info = await describe(db, def.schema, t.table);
163
166
  if (!info) {
164
167
  missing.push(t.table);
165
168
  continue;
166
169
  }
167
- const spec = tableSpec(info, DEFAULT_ROWS, t, undefined, await nextValues(owner.pool, info));
170
+ const spec = tableSpec(info, DEFAULT_ROWS, t, undefined, await nextValues(db, info));
168
171
  out.push({ info, spec: { ...spec, rows: Number.isInteger(t.rows) ? t.rows : DEFAULT_ROWS } });
169
172
  }
170
173
  return { tables: out, missing };
171
174
  };
172
175
 
173
176
  const editor = async (s: Session, reply: FastifyReply, st: FormState, code = 200) => {
174
- const { tables, missing } = await describeAll(st.def);
177
+ const { tables, missing } = await describeAll(s, st.def);
175
178
  const title = st.id ? (st.name ?? 'Generator') : 'New generator';
176
179
  const columnRow = (i: number, j: number, c: ColumnInfo, spec: TableSpec) => {
177
180
  const cs = spec.columns.find((x) => x.column === c.name)!;
@@ -246,7 +249,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
246
249
  app.get(`${sdUrl}/:id`, async (req: Req, reply) => {
247
250
  const s = await developer(req, reply);
248
251
  if (!s) return;
249
- const g = await saved(req.params.id);
252
+ const g = await saved(s, req.params.id);
250
253
  if (!g) return reply.code(404).send('Not found');
251
254
  return editor(s, reply, { id: g.id, name: g.name, description: g.description ?? '', def: cleanDef({ schema: g.schema_name, seed: g.seed, tables: g.tables }) });
252
255
  });
@@ -256,7 +259,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
256
259
  if (!s) return;
257
260
  const b = req.body ?? {};
258
261
  const def = defFromBody(b);
259
- const g = b.id ? await saved(b.id) : undefined;
262
+ const g = b.id ? await saved(s, b.id) : undefined;
260
263
  if (b.id && !g) return reply.code(404).send('Not found');
261
264
  const st: FormState = { def, id: g?.id, name: (b.name ?? '').trim(), description: (b.description ?? '').trim() };
262
265
  const refuse = (error: string) => editor(s, reply, { ...st, error }, 422);
@@ -266,13 +269,13 @@ export async function sampleDataRoutes(app: FastifyInstance) {
266
269
  if (!st.name) return refuse('Give the generator a name to save it.');
267
270
  if (st.name.length > 100 || (st.description ?? '').length > 1000) return refuse('The name is at most 100 characters, the description 1000.');
268
271
  if (!allowedSchema(def.schema)) return refuse('Choose a schema of your own: not meta, information_schema or pg_*.');
269
- const { tables } = await describeAll(def);
272
+ const { tables } = await describeAll(s, def);
270
273
  if (!tables.length) return refuse('Choose at least one table.');
271
274
  const json = JSON.stringify(tables.map(({ spec }) => spec));
272
275
  try {
273
276
  const id = g
274
277
  ? (await owner.one('update meta.data_generator set name = $2, description = $3, schema_name = $4, seed = $5, tables = $6, updated_by = $7, updated_at = now() where id = $1 returning id', [g.id, st.name, st.description || null, def.schema, def.seed, json, s.username])).id
275
- : (await owner.one('insert into meta.data_generator (name, description, schema_name, seed, tables, created_by, updated_by) values ($1, $2, $3, $4, $5, $6, $6) returning id', [st.name, st.description || null, def.schema, def.seed, json, s.username])).id;
278
+ : (await owner.one('insert into meta.data_generator (name, description, schema_name, seed, tables, created_by, updated_by, workspace_id) values ($1, $2, $3, $4, $5, $6, $6, $7) returning id', [st.name, st.description || null, def.schema, def.seed, json, s.username, currentWorkspace(s)])).id;
276
279
  flash(s, `Generator ${st.name} saved.`);
277
280
  return back(reply, s, `${sdUrl}/${id}`);
278
281
  } catch (e) {
@@ -282,7 +285,8 @@ export async function sampleDataRoutes(app: FastifyInstance) {
282
285
  }
283
286
  if (!['preview', 'insert', 'sql', 'csv'].includes(action)) return refuse('Unknown action.');
284
287
 
285
- const p = await plan(owner.pool, def);
288
+ const db = await workshopDb(s);
289
+ const p = await plan(db.pool, def);
286
290
  if (p.problems.length) return refuse(p.problems.join(' '));
287
291
  const seed = def.seed ?? randomSeed();
288
292
  st.def = { ...def, seed };
@@ -290,7 +294,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
290
294
  if (action === 'sql' || action === 'csv') {
291
295
  let generated;
292
296
  try {
293
- generated = await generateAll(owner.pool, p.tables, seed);
297
+ generated = await generateAll(db.pool, p.tables, seed);
294
298
  } catch (e) {
295
299
  if (e instanceof SampleDataError) return refuse(e.message);
296
300
  throw e;
@@ -309,7 +313,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
309
313
 
310
314
  // preview and insert: the same run, rolled back or committed
311
315
  const started = performance.now();
312
- const c = await owner.pool.connect();
316
+ const c = await db.pool.connect();
313
317
  let result: RunResult;
314
318
  try {
315
319
  await c.query('begin');
@@ -340,7 +344,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
340
344
  app.post(`${sdUrl}/:id/delete`, async (req: Req, reply) => {
341
345
  const s = await developer(req, reply);
342
346
  if (!s) return;
343
- const g = await saved(req.params.id);
347
+ const g = await saved(s, req.params.id);
344
348
  if (!g) return reply.code(404).send('Not found');
345
349
  await owner.query('delete from meta.data_generator where id = $1', [g.id]);
346
350
  flash(s, `Generator ${g.name} deleted.`);