pgkiln 0.41.0 → 0.43.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 (68) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/db/migrations/099_layout_positions.sql +29 -0
  4. package/docs/apex-feature-parity.md +38 -11
  5. package/docs/guide/03-builder.md +133 -31
  6. package/docs/guide/04-pages-and-regions.md +46 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +10 -3
  9. package/docs/guide/11-from-apex.md +1 -1
  10. package/docs/guide/12-development.md +3 -2
  11. package/docs/guide/14-globalization.md +7 -3
  12. package/docs/guide/17-mobile.md +1 -1
  13. package/package.json +1 -1
  14. package/public/app.css +233 -33
  15. package/public/app.js +263 -0
  16. package/public/builder.css +142 -2
  17. package/public/builder.js +311 -3
  18. package/src/builder/appfromfile.ts +5 -4
  19. package/src/builder/appsheets.ts +23 -9
  20. package/src/builder/appwizard.ts +215 -70
  21. package/src/builder/arrange.ts +133 -17
  22. package/src/builder/blueprints.ts +2 -1
  23. package/src/builder/components.ts +29 -23
  24. package/src/builder/designer.ts +67 -18
  25. package/src/builder/forms.ts +4 -1
  26. package/src/builder/home.ts +2 -32
  27. package/src/builder/newapp.ts +182 -5
  28. package/src/builder/object-browser.ts +342 -0
  29. package/src/builder/pwa.ts +3 -3
  30. package/src/builder/routes.ts +86 -51
  31. package/src/builder/shared.ts +103 -1
  32. package/src/builder/sql.ts +13 -65
  33. package/src/builder/ui.ts +21 -6
  34. package/src/i18n/ar.ts +17 -0
  35. package/src/i18n/cs.ts +17 -0
  36. package/src/i18n/da.ts +17 -0
  37. package/src/i18n/de.ts +17 -0
  38. package/src/i18n/el.ts +17 -0
  39. package/src/i18n/es.ts +17 -0
  40. package/src/i18n/fi.ts +17 -0
  41. package/src/i18n/fr.ts +17 -0
  42. package/src/i18n/he.ts +17 -0
  43. package/src/i18n/it.ts +17 -0
  44. package/src/i18n/ja.ts +17 -0
  45. package/src/i18n/ko.ts +17 -0
  46. package/src/i18n/nb.ts +17 -0
  47. package/src/i18n/pl.ts +17 -0
  48. package/src/i18n/pt.ts +17 -0
  49. package/src/i18n/ru.ts +17 -0
  50. package/src/i18n/sv.ts +17 -0
  51. package/src/i18n/tr.ts +17 -0
  52. package/src/i18n/uk.ts +17 -0
  53. package/src/i18n/zh.ts +17 -0
  54. package/src/i18n.ts +34 -0
  55. package/src/icons.ts +2 -1
  56. package/src/layout.ts +2 -2
  57. package/src/metadata.ts +5 -1
  58. package/src/runtime/charts.ts +8 -3
  59. package/src/runtime/devbar.ts +35 -14
  60. package/src/runtime/engine.ts +13 -4
  61. package/src/runtime/format.ts +52 -0
  62. package/src/runtime/items.ts +27 -0
  63. package/src/runtime/locale.ts +5 -2
  64. package/src/runtime/regions.ts +61 -4
  65. package/src/runtime/render.ts +38 -8
  66. package/src/runtime/report.ts +149 -88
  67. package/src/runtime/routes.ts +34 -3
  68. package/src/runtime/styles.ts +5 -2
@@ -3,7 +3,146 @@ import { owner, type Client } from '../db.ts';
3
3
  import { passwordProblem } from '../accounts.ts';
4
4
  import { replaceApp } from '../cli/replace.ts';
5
5
  import { placeApp } from './workspaces.ts';
6
- import { NEW_APP_BASE } from '../runtime/styles.ts';
6
+ import { BASE_STYLES, NEW_APP_BASE } from '../runtime/styles.ts';
7
+ import { LANGUAGE_NAMES } from '../i18n.ts';
8
+ import { isIcon } from '../icons.ts';
9
+ import { secretKeyConfigured } from '../secrets.ts';
10
+
11
+ /** (097) The icon of a new application's home page when the developer chose none. */
12
+ export const DEFAULT_APP_ICON = 'layers';
13
+ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
14
+
15
+ /** (sprint 72) The page number of a generated application's About page. */
16
+ export const ABOUT_PAGE = 9000;
17
+
18
+ /**
19
+ * (sprint 72) The About page of a generated application (APEX: the About Page
20
+ * feature): the application's name, its description or the pages of its menu,
21
+ * and a Help menu with it in the header's navigation bar (a list NAVBAR, unless
22
+ * the application has a navigation bar list already). Nothing when the page
23
+ * number is taken.
24
+ */
25
+ export async function addAboutPage(c: Client, appId: number, description?: string | null) {
26
+ const app = (await c.query<{ name: string; navbar_list: string | null; home_page: number }>('select name, navbar_list, home_page from meta.app where id = $1', [appId])).rows[0];
27
+ if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ABOUT_PAGE])).rowCount) return;
28
+ const pages = (
29
+ await c.query<{ label: string }>(
30
+ `select e.label from meta.nav_entry e where e.app_id = $1 and e.parent_id is null and e.target_page is not null and e.target_page <> $2 order by e.seq, e.id`,
31
+ [appId, app.home_page],
32
+ )
33
+ ).rows.map((r) => r.label);
34
+ const text = description
35
+ ? `<p>${escapeHtml(description)}</p>`
36
+ : pages.length
37
+ ? `<p>${escapeHtml(app.name)} has these pages:</p><ul>${pages.map((l) => `<li>${escapeHtml(l)}</li>`).join('')}</ul>`
38
+ : `<p>${escapeHtml(app.name)}</p>`;
39
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page) values ($1, $2, 'About', $3, $4) returning id`, [appId, ABOUT_PAGE, `About ${app.name}`.slice(0, 200), app.home_page])).rows[0].id;
40
+ await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 10, $2, 'static', $3)`, [page, app.name, text]);
41
+ if (app.navbar_list) return;
42
+ await c.query(`insert into meta.list (app_id, name, description) values ($1, 'NAVBAR', 'The navigation bar: the Help menu') on conflict (app_id, name) do nothing`, [appId]);
43
+ const help = (await c.query(`insert into meta.list_entry (app_id, list_name, seq, label, icon) values ($1, 'NAVBAR', 10, 'Help', 'help') returning id`, [appId])).rows[0].id;
44
+ await c.query(`insert into meta.list_entry (app_id, list_name, parent_id, seq, label, icon, target_page) values ($1, 'NAVBAR', $2, 10, 'About', 'info', $3)`, [appId, help, ABOUT_PAGE]);
45
+ await c.query(`update meta.app set navbar_list = 'NAVBAR' where id = $1`, [appId]);
46
+ }
47
+
48
+ /**
49
+ * (sprint 73) The Create Application wizard's features (APEX: Features). Each maps to a setting or to pages:
50
+ * - about: an About page in a Help menu (addAboutPage)
51
+ * - access: authorization schemes CONTRIBUTOR and READER next to ADMIN (roles admin, contributor, reader)
52
+ * - activity: an Activity page for administrators (page 9010: page views per day and the latest activity)
53
+ * - feedback: users may send feedback (the navigation bar's Feedback entry)
54
+ * - theme: users may choose light or dark (theme.user_choice)
55
+ * - pwa, push: installable, with push notifications (push needs PGKILN_SECRET_KEY; its key is made after the commit)
56
+ */
57
+ export interface Features { about: boolean; access: boolean; activity: boolean; feedback: boolean; theme: boolean; pwa: boolean; push: boolean }
58
+ export const FEATURES: [keyof Features, string, string, boolean][] = [
59
+ ['about', 'About page', 'An About page in a Help menu of the header.', true],
60
+ ['access', 'Access control', 'Authorization schemes for the roles admin, contributor and reader, to put on pages, regions and buttons.', false],
61
+ ['activity', 'Activity reporting', 'A page for administrators with the page views per day and the latest activity.', false],
62
+ ['feedback', 'Feedback', 'Users send feedback from the header; you read it under Activity → Feedback.', false],
63
+ ['theme', 'Theme style selection', 'Users may choose the light or the dark theme.', true],
64
+ ['pwa', 'Install as an app (PWA)', 'Installable on phones and desktops, with offline pages.', false],
65
+ ['push', 'Push notifications', 'Notifications to the users\' devices (with Install as an app).', false],
66
+ ];
67
+ /** The features ticked on a form (a form without them: the defaults). */
68
+ export const featuresFrom = (b: Record<string, unknown>, posted: boolean): Features =>
69
+ Object.fromEntries(FEATURES.map(([k, , , on]) => [k, posted ? b[`f_${k}`] === 'true' : on])) as unknown as Features;
70
+
71
+ /** (sprint 73) The Activity page of a generated application. */
72
+ export const ACTIVITY_PAGE = 9010;
73
+
74
+ async function applyFeatures(c: Client, appId: number, f: Partial<Features>, notes: string[]) {
75
+ if (f.access) {
76
+ await c.query(
77
+ `insert into meta.authz_scheme (app_id, name, type, value, error_message) values
78
+ ($1, 'CONTRIBUTOR', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'')', 'Only contributors can do this.'),
79
+ ($1, 'READER', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'') or meta.has_role(''reader'')', 'Only readers can open this page.')
80
+ on conflict do nothing`,
81
+ [appId],
82
+ );
83
+ }
84
+ if (f.activity && !(await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ACTIVITY_PAGE])).rowCount) {
85
+ const home = (await c.query('select home_page from meta.app where id = $1', [appId])).rows[0].home_page;
86
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page, authz) values ($1, $2, 'Activity', 'Activity', $3, 'ADMIN') returning id`, [appId, ACTIVITY_PAGE, home])).rows[0].id;
87
+ await c.query(
88
+ `insert into meta.region (page_id, seq, title, type, source, config) values
89
+ ($1, 10, 'Page views per day', 'chart', $2, '{"kind": "column"}'),
90
+ ($1, 20, 'Latest activity', 'report', $3, '{"headings": {"at": "Time", "username": "User", "event": "Event", "page_no": "Page", "elapsed_ms": "Time taken (ms)"}}')`,
91
+ [page,
92
+ `select to_char(date_trunc('day', at), 'YYYY-MM-DD') as "Day", count(*) as "Page views"
93
+ from meta.app_activity(30)
94
+ where event = 'page_view'
95
+ group by 1
96
+ order by 1`,
97
+ `select at, username, event, page_no, elapsed_ms
98
+ from meta.app_activity(30)`],
99
+ );
100
+ await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page, authz) values ($1, 9010, 'Activity', 'activity', $2, 'ADMIN')`, [appId, ACTIVITY_PAGE]);
101
+ }
102
+ if (f.feedback) await c.query('update meta.app set feedback = true where id = $1', [appId]);
103
+ if (f.theme === false) await c.query(`update meta.app set theme = theme || '{"user_choice": false}' where id = $1`, [appId]);
104
+ if (f.pwa || f.push) await c.query('update meta.app set pwa = true where id = $1', [appId]);
105
+ if (f.push) {
106
+ if (secretKeyConfigured()) await c.query('update meta.app set pwa_push = true where id = $1', [appId]);
107
+ else notes.push('Push notifications are off: they need PGKILN_SECRET_KEY in the server\'s environment.');
108
+ }
109
+ }
110
+
111
+ /** (sprint 72) After a wizard generated the pages: the home page's cards and, unless turned off, the About page; (sprint 73) the chosen features. */
112
+ export async function finishGeneratedApp(c: Client, appId: number, o: { about?: boolean; description?: string | null; features?: Partial<Features>; notes?: string[] } = {}) {
113
+ const f = { ...(o.features ?? {}) };
114
+ if (f.about === undefined) f.about = o.about !== false;
115
+ await applyFeatures(c, appId, f, o.notes ?? []);
116
+ await addHomeCards(c, appId);
117
+ if (f.about) await addAboutPage(c, appId, o.description);
118
+ }
119
+
120
+ /**
121
+ * (097) A card per page of the navigation menu on the home page (a static list
122
+ * HOME_PAGES shown by a list region with the cards template), after the pages
123
+ * of a new application were generated. Does nothing when the application has
124
+ * no other pages, or the list exists already.
125
+ */
126
+ export async function addHomeCards(c: Client, appId: number) {
127
+ const entries = (
128
+ await c.query<{ label: string; icon: string | null; target_page: number; authz: string | null }>(
129
+ `select e.label, e.icon, e.target_page, e.authz from meta.nav_entry e join meta.app a on a.id = e.app_id
130
+ where e.app_id = $1 and e.parent_id is null and e.target_page is not null and e.target_page <> a.home_page
131
+ order by e.seq, e.id`,
132
+ [appId],
133
+ )
134
+ ).rows;
135
+ if (!entries.length) return;
136
+ const made = await c.query(`insert into meta.list (app_id, name, description) values ($1, 'HOME_PAGES', 'The cards of the home page') on conflict (app_id, name) do nothing`, [appId]);
137
+ if (!made.rowCount) return;
138
+ for (const [n, e] of entries.entries())
139
+ await c.query('insert into meta.list_entry (app_id, list_name, seq, label, icon, target_page, authz) values ($1, $2, $3, $4, $5, $6, $7)', [appId, 'HOME_PAGES', (n + 1) * 10, e.label, e.icon, e.target_page, e.authz]);
140
+ await c.query(
141
+ `insert into meta.region (page_id, seq, title, type, template, config)
142
+ select p.id, 20, 'Pages', 'list', 'plain', '{"list": "HOME_PAGES", "template": "cards"}' from meta.page p join meta.app a on a.id = p.app_id and p.page_no = a.home_page where a.id = $1`,
143
+ [appId],
144
+ );
145
+ }
7
146
 
8
147
  // Creating an application (Create → blank application, and Create → from a
9
148
  // file): the parsing schema, a database role app_<alias> that can use only
@@ -18,6 +157,15 @@ export interface NewAppInput {
18
157
  authentication?: string;
19
158
  admin_user?: string;
20
159
  admin_password?: string;
160
+ /** (097) a line about the application, shown on the home page */
161
+ description?: string;
162
+ /** (097) the application's icon (a name from icons.svg) */
163
+ icon?: string;
164
+ /** (sprint 73) appearance: base style, menu position, accent colour; the primary language */
165
+ base?: string;
166
+ nav?: string;
167
+ accent?: string;
168
+ language?: string;
21
169
  }
22
170
 
23
171
  export interface CheckedApp {
@@ -31,6 +179,16 @@ export interface CheckedApp {
31
179
  adminPassword: string | null;
32
180
  /** the workspace that gets the application (the developer's current one) */
33
181
  workspaceId: number;
182
+ /** (097) shown under the name on the home page */
183
+ description?: string | null;
184
+ /** (097) the home page's icon */
185
+ icon?: string | null;
186
+ /** (sprint 73) the theme: base style (Iris when not given), menu at the top, own accent colour */
187
+ base?: 'iris' | 'lavender' | 'standard';
188
+ topNav?: boolean;
189
+ accent?: string | null;
190
+ /** (sprint 73) the primary language (default en) */
191
+ language?: string;
34
192
  }
35
193
 
36
194
  /** pgkiln's and the system's schemas never become an application's parsing schema. */
@@ -56,7 +214,17 @@ export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<
56
214
  if (problem) throw new Error(problem);
57
215
  adminPassword = known ? null : (b.admin_password ?? '');
58
216
  }
59
- return { name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId };
217
+ const description = (b.description ?? '').trim().slice(0, 300) || null;
218
+ const appIcon = (b.icon ?? '').trim();
219
+ if (appIcon && !isIcon(appIcon)) throw new Error('Choose one of the icons in the list.');
220
+ const base = b.base === 'lavender' || b.base === 'standard' ? b.base : 'iris';
221
+ const accent = /^#[0-9a-f]{6}$/i.test(b.accent ?? '') && b.accent!.toLowerCase() !== BASE_STYLES[base].accent ? b.accent!.toLowerCase() : null;
222
+ const language = (b.language ?? '').trim() || 'en';
223
+ if (!(language in LANGUAGE_NAMES)) throw new Error('Choose one of the languages in the list.');
224
+ return {
225
+ name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId, description, icon: appIcon || null,
226
+ base, topNav: b.nav === 'top', accent, language,
227
+ };
60
228
  }
61
229
 
62
230
  /** Create the schema, the role and the application (inside the caller's owner transaction). */
@@ -93,12 +261,21 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
93
261
  await c.query(`alter default privileges in schema ${S} grant usage, select on sequences to ${R}`);
94
262
  await c.query(`alter default privileges in schema ${S} grant execute on functions to ${R}`);
95
263
 
96
- // new applications start with the newest base style (Iris); existing ones keep theirs
97
- const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme) values ($1, $2, $3, $4, $5) returning id', [a.alias, a.name, a.authentication, a.role, { base: NEW_APP_BASE }]);
264
+ // new applications start with the newest base style (Iris), light (users may switch to dark); existing ones keep theirs
265
+ // (sprint 73) the wizard's appearance: base style, menu at the top, an own accent colour
266
+ const theme: Record<string, string> = { mode: 'light' };
267
+ const base = a.base ?? NEW_APP_BASE;
268
+ if (base !== 'standard') theme.base = base;
269
+ if (a.topNav) theme.nav = 'top';
270
+ if (a.accent) theme.accent = a.accent;
271
+ const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme, language) values ($1, $2, $3, $4, $5, $6) returning id', [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
98
272
  const appId = r.rows[0].id as number;
99
273
  await placeApp(c, appId, a.workspaceId);
100
274
  const p = await c.query(`insert into meta.page (app_id, page_no, name, title) values ($1, 1, 'Home', 'Home') returning id`, [appId]);
101
- await c.query(`insert into meta.region (page_id, title, type, source) values ($1, 'Welcome', 'static', '<p>Hello, &APP_USER.! Edit this page in the builder.</p>')`, [p.rows[0].id]);
275
+ // (097) a hero with the application's icon, name and description (no developer wording for end users)
276
+ await c.query(`insert into meta.region (page_id, seq, title, type, source, template, config) values ($1, 10, $2, 'static', $3, 'hero', $4)`, [
277
+ p.rows[0].id, a.name, a.description ? `<p>${escapeHtml(a.description)}</p>` : '', { icon: a.icon ?? DEFAULT_APP_ICON },
278
+ ]);
102
279
  await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page) values ($1, 1, 'Home', 'home', 1)`, [appId]);
103
280
  await c.query(`insert into meta.authz_scheme (app_id, name, type, value, error_message) values ($1, 'ADMIN', 'role', 'admin', 'Only administrators can access this page.')`, [appId]);
104
281
  let existingAccount = false;
@@ -0,0 +1,342 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import pg from 'pg';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { readSchema } from '../ddl.ts';
6
+ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
7
+ import { sessionSchema, workshopDb } from './workspaces.ts';
8
+
9
+ // (sprint 75) The SQL Workshop's Object Browser like APEX's: a schema in the header (the session's
10
+ // schema by default), a tree with a folder per object type, a filter, a + menu that starts a CREATE
11
+ // statement in SQL Commands, and create tiles when nothing is selected. pgkiln's own schemas (meta,
12
+ // pgkiln_plugins) and objects that belong to an extension are left out unless "Show system objects" is
13
+ // on. A table opens in tabs: Columns, Data, Indexes, Constraints, Triggers, Grants, Row level security,
14
+ // Dependencies, Statistics and DDL; the column actions write the ALTER TABLE statement into SQL
15
+ // Commands to review and run (nothing runs here). Everything is read with the workshop's connection
16
+ // (an isolated workspace's developer login for its developers), and every name in a URL is looked up
17
+ // in the catalog before it is used, quoted.
18
+
19
+ /** pgkiln's own schemas: shown with "Show system objects" only. */
20
+ const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
21
+
22
+ export const OB_TYPES = [
23
+ ['table', 'Tables', 'table'],
24
+ ['view', 'Views', 'layers'],
25
+ ['matview', 'Materialized Views', 'layers'],
26
+ ['index', 'Indexes', 'list'],
27
+ ['sequence', 'Sequences', 'hash'],
28
+ ['type', 'Types', 'tag'],
29
+ ['function', 'Functions', 'code'],
30
+ ['procedure', 'Procedures', 'code'],
31
+ ['trigger', 'Triggers', 'bolt'],
32
+ ] as const;
33
+ type ObType = (typeof OB_TYPES)[number][0];
34
+
35
+ interface Obj {
36
+ type: ObType;
37
+ name: string;
38
+ /** the object as SQL can name it: regclass, regprocedure or regtype text; a trigger: "table" */
39
+ ref: string;
40
+ rls: boolean;
41
+ /** an extension's object */
42
+ ext: boolean;
43
+ }
44
+
45
+ const TABLE_TABS = [
46
+ ['columns', 'Columns'], ['data', 'Data'], ['indexes', 'Indexes'], ['constraints', 'Constraints'], ['triggers', 'Triggers'],
47
+ ['grants', 'Grants'], ['rls', 'Row level security'], ['dependencies', 'Dependencies'], ['statistics', 'Statistics'], ['ddl', 'DDL'],
48
+ ] as const;
49
+
50
+ /** CREATE statements the + menu and the tiles start with (SQL Commands shows them; nothing runs until Run). */
51
+ export function createTemplate(kind: string, schema: string): string | null {
52
+ const S = pg.escapeIdentifier(schema);
53
+ switch (kind) {
54
+ case 'table': return `create table ${S}.new_table (\n id bigint generated by default as identity primary key,\n name text not null,\n created_at timestamptz not null default now()\n);`;
55
+ case 'view': return `create view ${S}.new_view as\nselect *\n from ${S}.some_table;`;
56
+ case 'function': return `create function ${S}.new_function(p_id bigint) returns text\nlanguage sql stable as $$\n select 'x'\n$$;`;
57
+ case 'sequence': return `create sequence ${S}.new_sequence start 1;`;
58
+ case 'index': return `create index on ${S}.some_table (some_column);`;
59
+ default: return null;
60
+ }
61
+ }
62
+
63
+ const PAGE_SIZE = 25;
64
+ /** A column type in an action: a type name with an optional size and array brackets. */
65
+ const TYPE_RE = /^[a-z][a-z0-9_ ]{0,40}(\(\d{1,4}(,\s?\d{1,4})?\))?(\[\])?$/i;
66
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
67
+
68
+ export async function objectBrowserRoutes(app: FastifyInstance) {
69
+ app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
70
+ const s = await developer(req, reply);
71
+ if (!s) return;
72
+ const db = await workshopDb(s);
73
+ const q = req.query ?? {};
74
+ const system = q.system === '1';
75
+ const schemas = (
76
+ await db.query<{ n: string }>(
77
+ `select nspname as n from pg_namespace
78
+ where nspname !~ '^pg_' and nspname <> 'information_schema' and has_schema_privilege(oid, 'USAGE')
79
+ and ($1 or not (nspname = any ($2)))
80
+ order by nspname = 'public', 1`,
81
+ [system, SYSTEM_SCHEMAS],
82
+ )
83
+ ).rows.map((r) => r.n);
84
+ const own = await sessionSchema(s, db);
85
+ const schema = typeof q.schema === 'string' && schemas.includes(q.schema) ? q.schema : own && schemas.includes(own) ? own : (schemas[0] ?? '');
86
+ const filter = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
87
+ const objects = schema ? await listObjects(db, schema, system) : [];
88
+ const shown = objects.filter((o) => !filter || o.name.toLowerCase().includes(filter.toLowerCase()));
89
+ const kind = String(q.k ?? '') as ObType;
90
+ const selected = objects.find((o) => o.type === kind && o.ref === q.o);
91
+ const link = (extra: Record<string, string>) => `?${new URLSearchParams({ schema, ...(system ? { system: '1' } : {}), ...(filter ? { q: filter } : {}), ...extra })}`;
92
+
93
+ const tree = html`<form method="get" class="ob-filter" role="search"><input type="hidden" name="schema" value="${schema}">${system ? html`<input type="hidden" name="system" value="1">` : ''}
94
+ <label class="sr-only" for="ob-q">Filter objects</label><input id="ob-q" name="q" type="search" value="${filter}" placeholder="Filter" data-filter-tree="ob-tree"></form>
95
+ <div id="ob-tree">${OB_TYPES.map(([t, label, ic]) => {
96
+ const list = shown.filter((o) => o.type === t);
97
+ if (!list.length && filter) return '';
98
+ return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
99
+ <summary>${icon(ic)}<span>${label}</span><span class="kind">${list.length}</span></summary>
100
+ <ul class="tree">${list.map((o) => html`<li><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">extension</span>` : ''}</a></li>`)}</ul>
101
+ </details>`;
102
+ })}</div>`;
103
+
104
+ let detail: Raw;
105
+ if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
106
+ else if (selected) detail = await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link);
107
+ else
108
+ detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">${[
109
+ ['table', 'table', 'Table'], ['view', 'layers', 'View'], ['function', 'code', 'Function'], ['sequence', 'hash', 'Sequence'], ['index', 'list', 'Index'],
110
+ ].map(([k, ic, label]) => html`<a class="create-source" href="${BASE}/sql/commands?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${label}</b><small class="muted">Start a CREATE ${label.toUpperCase()} statement</small></span></a>`)}
111
+ <a class="create-source" href="${BASE}/sql/quick">${icon('bolt')}<span><b>Quick SQL</b><small class="muted">A data model in shorthand</small></span></a>
112
+ <a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>Load data</b><small class="muted">A table from a file</small></span></a></div>
113
+ <p class="muted">Select an object in the tree, or filter it by name.</p>`);
114
+
115
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('objects')}
116
+ <div class="ob-head">
117
+ <form method="get" class="ob-schema">${filter ? html`<input type="hidden" name="q" value="${filter}">` : ''}
118
+ <label class="label" for="ob-schema">Schema</label>
119
+ <select id="ob-schema" name="schema" data-autosubmit>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
120
+ <label class="check"><input type="checkbox" name="system" value="1"${system ? raw(' checked') : ''} data-autosubmit> Show system objects</label>
121
+ <button class="btn btn-sm">Show</button></form>
122
+ <details class="menu ob-create"><summary class="btn btn-hot">${icon('plus')} Create</summary>
123
+ <div class="menu-panel align-right"><div class="menu-section menu-links">${['table', 'view', 'function', 'sequence', 'index'].map((k) =>
124
+ html`<a href="${BASE}/sql/commands?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
125
+ </div>
126
+ <div class="designer"><aside class="region region-standard ob-tree" aria-label="Database objects">${tree}</aside><div>${detail}</div></div>`;
127
+ return send(reply, s, shell(s, 'Object Browser', [['SQL Workshop', `${BASE}/sql`], ['Object Browser']], main, 'sql'));
128
+ });
129
+
130
+ // column actions: the ALTER TABLE statement, into SQL Commands to review and run
131
+ app.post(`${BASE}/sql/objects/alter`, async (req: Req, reply) => {
132
+ const s = await developer(req, reply);
133
+ if (!s) return;
134
+ const db = await workshopDb(s);
135
+ const b = req.body ?? {};
136
+ const table = await db.one<{ q: string; ok: boolean }>(
137
+ `select c.oid::regclass::text as q, has_table_privilege(c.oid, 'select') or pg_has_role(c.relowner, 'usage') as ok
138
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
139
+ where c.oid = to_regclass($1) and c.relkind in ('r', 'p')
140
+ and n.nspname !~ '^pg_' and n.nspname <> 'information_schema' and not (n.nspname = any ($2))`,
141
+ [String(b.table ?? ''), SYSTEM_SCHEMAS],
142
+ ).catch(() => undefined);
143
+ const backTo = `${BASE}/sql/objects?${new URLSearchParams({ schema: String(b.schema ?? ''), k: 'table', o: String(b.table ?? ''), tab: 'columns' })}`;
144
+ if (!table?.ok) {
145
+ flash(s, 'Choose a table you may use.', 'error');
146
+ return back(reply, s, `${BASE}/sql/objects`);
147
+ }
148
+ const col = String(b.column ?? '').trim();
149
+ const cols = (await db.query<{ n: string }>(`select attname as n from pg_attribute where attrelid = $1::regclass and attnum > 0 and not attisdropped`, [table.q])).rows.map((r) => r.n);
150
+ const ident = pg.escapeIdentifier;
151
+ let sql: string | null = null;
152
+ let problem = '';
153
+ if (b.action === 'add') {
154
+ const type = String(b.type ?? '').trim();
155
+ if (!NAME_RE.test(col)) problem = 'A column name is a letter or _ followed by letters, digits, _ or $.';
156
+ else if (cols.includes(col)) problem = `${col} is a column already.`;
157
+ else if (!TYPE_RE.test(type)) problem = 'A type is a name with an optional size, e.g. text, numeric(9,2), varchar(40), int[].';
158
+ else sql = `alter table ${table.q} add column ${ident(col)} ${type}${b.not_null === 'true' ? ' not null' : ''};`;
159
+ } else if (b.action === 'rename') {
160
+ const to = String(b.new_name ?? '').trim();
161
+ if (!cols.includes(col)) problem = 'Choose a column of the table.';
162
+ else if (!NAME_RE.test(to) || cols.includes(to)) problem = 'The new name must be a valid name that the table doesn\'t have yet.';
163
+ else sql = `alter table ${table.q} rename column ${ident(col)} to ${ident(to)};`;
164
+ } else if (b.action === 'drop') {
165
+ if (!cols.includes(col)) problem = 'Choose a column of the table.';
166
+ else sql = `alter table ${table.q} drop column ${ident(col)};`;
167
+ } else problem = 'Unknown action.';
168
+ if (!sql) {
169
+ flash(s, problem, 'error');
170
+ return back(reply, s, backTo);
171
+ }
172
+ s.state.__SQLDRAFT = sql;
173
+ flash(s, 'Review the statement and press Run: nothing has been changed yet.');
174
+ return back(reply, s, `${BASE}/sql/commands`);
175
+ });
176
+ }
177
+
178
+ async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
179
+ const ext = (cls: string, oid: string) => `exists (select 1 from pg_depend d where d.classid = '${cls}'::regclass and d.objid = ${oid} and d.deptype = 'e')`;
180
+ const rows = (
181
+ await db.query<Obj>(
182
+ `select * from (
183
+ select case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'matview'
184
+ when 'S' then 'sequence' when 'i' then 'index' when 'I' then 'index' end as type,
185
+ c.relname as name, c.oid::regclass::text as ref, c.relrowsecurity as rls, ${ext('pg_class', 'c.oid')} as ext
186
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
187
+ where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm', 'S', 'i', 'I') and not c.relispartition
188
+ union all
189
+ select case p.prokind when 'p' then 'procedure' else 'function' end, p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')',
190
+ p.oid::regprocedure::text, false, ${ext('pg_proc', 'p.oid')}
191
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
192
+ where n.nspname = $1 and p.prokind in ('f', 'p', 'a', 'w')
193
+ union all
194
+ select 'type', t.typname, t.oid::regtype::text, false, ${ext('pg_type', 't.oid')}
195
+ from pg_type t join pg_namespace n on n.oid = t.typnamespace
196
+ where n.nspname = $1 and t.typtype in ('c', 'e', 'd', 'r', 'm') and t.typname !~ '^_'
197
+ and (t.typrelid = 0 or (select relkind from pg_class where oid = t.typrelid) = 'c')
198
+ union all
199
+ select 'trigger', tg.tgname || ' on ' || c.relname, c.oid::regclass::text || '|' || tg.tgname, false, false
200
+ from pg_trigger tg join pg_class c on c.oid = tg.tgrelid join pg_namespace n on n.oid = c.relnamespace
201
+ where n.nspname = $1 and not tg.tgisinternal
202
+ ) x order by 2`,
203
+ [schema],
204
+ )
205
+ ).rows;
206
+ return system ? rows : rows.filter((o) => !o.ext);
207
+ }
208
+
209
+ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csrf_token: string }, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
210
+ const table = (rows: Record<string, unknown>[], empty = 'None.') =>
211
+ rows.length
212
+ ? html`<div class="table-wrap"><table class="report"><thead><tr>${Object.keys(rows[0]).map((k) => html`<th scope="col">${k}</th>`)}</tr></thead>
213
+ <tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
214
+ : html`<p class="muted">${empty}</p>`;
215
+ const ddlOf = async (match: (x: { kind: string; name: string; table?: string }) => boolean) => {
216
+ const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
217
+ const text = objects.filter(match).map((x) => x.ddl).join('\n\n');
218
+ return text ? html`<pre class="source">${text}</pre>` : html`<p class="muted">No DDL (an extension's object, or not readable with this connection).</p>`;
219
+ };
220
+ const relname = o.ref.includes('.') ? o.ref.slice(o.ref.lastIndexOf('.') + 1).replace(/^"|"$/g, '') : o.ref.replace(/^"|"$/g, '');
221
+
222
+ if (o.type === 'function' || o.type === 'procedure') {
223
+ const src = await db.one<{ def: string }>('select pg_get_functiondef($1::regprocedure) as def', [o.ref]).catch(() => undefined);
224
+ return region(o.name, src ? html`<pre class="source">${src.def}</pre>` : html`<p class="muted">An aggregate or window function: no source.</p>`);
225
+ }
226
+ if (o.type === 'trigger') {
227
+ const [rel, name] = o.ref.split('|');
228
+ const t = await db.one<{ def: string }>('select pg_get_triggerdef(oid, true) as def from pg_trigger where tgrelid = $1::regclass and tgname = $2', [rel, name]);
229
+ return region(o.name, html`<pre class="source">${t?.def ?? ''}</pre>`);
230
+ }
231
+ if (o.type === 'index') {
232
+ const i = await db.one<{ def: string; size: string }>('select pg_get_indexdef($1::regclass) as def, pg_size_pretty(pg_relation_size($1::regclass)) as size', [o.ref]);
233
+ return region(o.name, html`<pre class="source">${i?.def ?? ''};</pre><p class="muted">Size: ${i?.size ?? ''}</p>`);
234
+ }
235
+ if (o.type === 'sequence') {
236
+ const q = await db.one<Record<string, unknown>>(
237
+ `select data_type, start_value, min_value, max_value, increment_by, cycle, cache_size, last_value from pg_sequences where schemaname = $1 and sequencename = $2`,
238
+ [schema, relname],
239
+ );
240
+ return html`${region(o.name, table(q ? [q] : []))}${region('DDL', await ddlOf((x) => x.kind === 'sequence' && x.name === relname))}`;
241
+ }
242
+ if (o.type === 'type') {
243
+ return region(o.name, await ddlOf((x) => (x.kind === 'type' || x.kind === 'domain') && x.name === relname));
244
+ }
245
+
246
+ // tables, views and materialized views: tabs
247
+ const isTable = o.type === 'table';
248
+ const tabs = TABLE_TABS.filter(([k]) => isTable || !['indexes', 'constraints', 'triggers', 'rls'].includes(k) || (o.type === 'matview' && k === 'indexes'));
249
+ const tab = tabs.some(([k]) => k === tabAsked) ? tabAsked : 'columns';
250
+ const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${tabs.map(([k, label]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`)}</nav>`;
251
+ let body: Raw;
252
+ switch (tab) {
253
+ case 'columns': {
254
+ const cols = (
255
+ await db.query(
256
+ `select a.attname as "column", format_type(a.atttypid, a.atttypmod) as type, not a.attnotnull as nullable,
257
+ pg_get_expr(d.adbin, d.adrelid) as "default", col_description(a.attrelid, a.attnum) as comment
258
+ from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
259
+ where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped order by a.attnum`,
260
+ [o.ref],
261
+ )
262
+ ).rows;
263
+ const names = cols.map((c) => String(c.column));
264
+ const hidden = html`${raw(`<input type="hidden" name="__csrf" value="${s.csrf_token}">`)}<input type="hidden" name="table" value="${o.ref}"><input type="hidden" name="schema" value="${schema}">`;
265
+ const pick = (id: string) => html`<label class="sr-only" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
266
+ body = html`${table(cols)}${isTable
267
+ ? html`<div class="ob-actions">
268
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
269
+ <b>Add column</b><label class="sr-only" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
270
+ <label class="sr-only" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
271
+ <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm">Show the SQL</button></form>
272
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
273
+ <b>Rename column</b>${pick('ob-ren-col')}<label class="sr-only" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm">Show the SQL</button></form>
274
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
275
+ <b>Drop column</b>${pick('ob-drop-col')}<button class="btn btn-sm">Show the SQL</button></form>
276
+ <p class="muted u-m0">Each action writes the ALTER TABLE statement into SQL Commands: review it there and press Run.</p>
277
+ </div>`
278
+ : ''}`;
279
+ break;
280
+ }
281
+ case 'data': {
282
+ const offset = (Math.max(1, Math.min(page, 100000)) - 1) * PAGE_SIZE;
283
+ const data = await db.pool.query({ text: `select * from ${o.ref} limit ${PAGE_SIZE + 1} offset ${offset}`, rowMode: 'array' }).catch((e: Error) => e);
284
+ if (data instanceof Error) body = html`<div class="alert alert-error" role="alert">${data.message}</div>`;
285
+ else {
286
+ const more = data.rows.length > PAGE_SIZE;
287
+ const rows = data.rows.slice(0, PAGE_SIZE).map((r: unknown[]) => Object.fromEntries(data.fields.map((f, i) => [f.name, r[i]])));
288
+ const p = offset / PAGE_SIZE + 1;
289
+ body = html`${table(rows, 'No rows.')}<nav class="pager" aria-label="Pages">${p > 1 ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p - 1) })}">‹ Previous</a>` : ''}
290
+ <span>Rows ${rows.length ? offset + 1 : 0}–${offset + rows.length}</span>${more ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p + 1) })}">Next ›</a>` : ''}</nav>
291
+ <p class="muted">To change rows, use SQL Commands, or an interactive grid on the table in an application.</p>`;
292
+ }
293
+ break;
294
+ }
295
+ case 'indexes':
296
+ body = table((await db.query(`select i.relname as "index", ix.indisunique as "unique", ix.indisprimary as "primary", pg_get_indexdef(ix.indexrelid) as definition, pg_size_pretty(pg_relation_size(ix.indexrelid)) as size
297
+ from pg_index ix join pg_class i on i.oid = ix.indexrelid where ix.indrelid = $1::regclass order by i.relname`, [o.ref])).rows);
298
+ break;
299
+ case 'constraints':
300
+ body = table((await db.query(`select conname as "constraint", case contype when 'p' then 'primary key' when 'f' then 'foreign key' when 'u' then 'unique' when 'c' then 'check' when 'x' then 'exclusion' when 'n' then 'not null' end as type,
301
+ pg_get_constraintdef(oid, true) as definition, case when contype = 'f' then confrelid::regclass::text end as "references"
302
+ from pg_constraint where conrelid = $1::regclass order by contype = 'p' desc, contype, conname`, [o.ref])).rows);
303
+ break;
304
+ case 'triggers':
305
+ body = table((await db.query(`select tgname as "trigger", tgenabled <> 'D' as enabled, pg_get_triggerdef(oid, true) as definition from pg_trigger where tgrelid = $1::regclass and not tgisinternal order by tgname`, [o.ref])).rows);
306
+ break;
307
+ case 'grants':
308
+ body = table((await db.query(`select grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges from information_schema.role_table_grants where table_schema = $1 and table_name = $2 group by grantee order by grantee`, [schema, relname])).rows);
309
+ break;
310
+ case 'rls': {
311
+ const on = await db.one<{ rls: boolean; force: boolean }>('select relrowsecurity as rls, relforcerowsecurity as force from pg_class where oid = $1::regclass', [o.ref]);
312
+ body = html`<p>${on?.rls ? html`<b>Row level security is on</b>${on.force ? ' (forced for the owner too)' : ''}.` : 'Row level security is off.'}</p>
313
+ ${table((await db.query(`select policyname as policy, permissive, cmd, roles::text, qual as "using", with_check from pg_policies where schemaname = $1 and tablename = $2 order by policyname`, [schema, relname])).rows, 'No policies.')}`;
314
+ break;
315
+ }
316
+ case 'dependencies':
317
+ body = html`<h3>Used by</h3>${table((await db.query(
318
+ `select distinct dc.oid::regclass::text as object, case dc.relkind when 'v' then 'view' when 'm' then 'materialized view' else 'table' end as type
319
+ from pg_depend d join pg_rewrite r on r.oid = d.objid join pg_class dc on dc.oid = r.ev_class
320
+ where d.classid = 'pg_rewrite'::regclass and d.refobjid = $1::regclass and dc.oid <> $1::regclass
321
+ union
322
+ select conrelid::regclass::text, 'foreign key ' || conname from pg_constraint where confrelid = $1::regclass and contype = 'f' and conrelid <> $1::regclass
323
+ order by 1`, [o.ref])).rows, 'Nothing refers to it.')}
324
+ <h3>Uses</h3>${table((await db.query(
325
+ `select distinct d.refobjid::regclass::text as object, 'table or view' as type
326
+ from pg_rewrite r join pg_depend d on d.objid = r.oid and d.classid = 'pg_rewrite'::regclass and d.refclassid = 'pg_class'::regclass
327
+ where r.ev_class = $1::regclass and d.refobjid <> $1::regclass
328
+ union
329
+ select confrelid::regclass::text, 'foreign key ' || conname from pg_constraint where conrelid = $1::regclass and contype = 'f' and confrelid <> $1::regclass
330
+ order by 1`, [o.ref])).rows, 'It refers to nothing else.')}`;
331
+ break;
332
+ case 'statistics':
333
+ body = table([(await db.one(
334
+ `select c.reltuples::bigint as estimated_rows, pg_size_pretty(pg_total_relation_size(c.oid)) as total_size, pg_size_pretty(pg_relation_size(c.oid)) as table_size,
335
+ st.last_analyze, st.last_autoanalyze, st.last_vacuum, st.last_autovacuum, st.n_live_tup as live_rows, st.n_dead_tup as dead_rows
336
+ from pg_class c left join pg_stat_user_tables st on st.relid = c.oid where c.oid = $1::regclass`, [o.ref]))!]);
337
+ break;
338
+ default:
339
+ body = await ddlOf((x) => (x.kind === 'table' || x.kind === 'view' || x.kind === 'materialized view') ? x.name === relname : x.table === relname);
340
+ }
341
+ return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
342
+ }
@@ -74,7 +74,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
74
74
  const s = await developer(req, reply);
75
75
  if (!s) return;
76
76
  const id = req.params.id;
77
- const target = `${BASE}/apps/${id}/settings`;
77
+ const target = `${BASE}/apps/${id}/settings?tab=pwa`;
78
78
  const a = /^\d+$/.test(id) ? await owner.one('select id from meta.app where id = $1', [id]) : undefined;
79
79
  if (!a) return reply.code(404).send('Not found');
80
80
  const b = req.body ?? {};
@@ -111,7 +111,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
111
111
  const s = await developer(req, reply);
112
112
  if (!s) return;
113
113
  const id = req.params.id;
114
- const target = `${BASE}/apps/${id}/settings`;
114
+ const target = `${BASE}/apps/${id}/settings?tab=pwa`;
115
115
  const a = /^\d+$/.test(id) ? await owner.one<{ id: number; name: string }>('select id, name from meta.app where id = $1', [id]) : undefined;
116
116
  if (!a) return reply.code(404).send('Not found');
117
117
  const user = String(req.body?.push_user ?? '').trim();
@@ -137,6 +137,6 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
137
137
  if (!/^\d+$/.test(id) || !(await owner.one('select 1 from meta.app where id = $1', [id]))) return reply.code(404).send('Not found');
138
138
  await newAppKeys(Number(id));
139
139
  flash(s, 'New keys made. Users have to turn notifications on again on each device.');
140
- return back(reply, s, `${BASE}/apps/${id}/settings`);
140
+ return back(reply, s, `${BASE}/apps/${id}/settings?tab=pwa`);
141
141
  });
142
142
  }