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
@@ -9,6 +9,7 @@ import type { Session } from '../session.ts';
9
9
  import { appOr404 } from './forms.ts';
10
10
  import { isAdmin } from './locks.ts';
11
11
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
12
+ import { workshopDb } from './workspaces.ts';
12
13
  import { WIZARD_KINDS, wizardTables } from './wizards.ts';
13
14
 
14
15
  // App Builder AI (migration 062), with the AI service an administrator
@@ -43,10 +44,20 @@ async function ask(s: Session, req: Parameters<typeof generate>[1]) {
43
44
  return generate(svc.name, req, { appId: null, user: s.username, source: 'builder' });
44
45
  }
45
46
 
46
- /** Schemas a developer may describe or ask about (not pgkiln's, not the system's). */
47
- async function schemas() {
48
- return (await owner.query<{ n: string }>(
49
- `select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`)).rows.map((r) => r.n);
47
+ /** Schemas a developer may describe or ask about (not pgkiln's, not the system's; in an isolated workspace, its own). */
48
+ async function schemas(s: Session) {
49
+ const db = await workshopDb(s);
50
+ return (await db.query<{ n: string }>(
51
+ `select nspname as n from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta')
52
+ ${db === owner ? '' : `and has_schema_privilege(oid, 'USAGE')`} order by 1`)).rows.map((r) => r.n);
53
+ }
54
+
55
+ /** A table's descriptions as database comments. */
56
+ async function comment(c: { query: (sql: string) => Promise<unknown> }, t: TableInfo, notes: [string, string][]) {
57
+ const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
58
+ const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
59
+ for (const [col, note] of notes)
60
+ await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
50
61
  }
51
62
 
52
63
  interface TableInfo {
@@ -130,7 +141,7 @@ const SQL_SCHEMA = {
130
141
  };
131
142
 
132
143
  async function workshopPage(s: Session, reply: FastifyReply, out: { question?: string; schema?: string; sql?: string; error?: string; result?: Raw }) {
133
- const list = await schemas();
144
+ const list = await schemas(s);
134
145
  const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai')}
135
146
  ${await serviceBox(s)}
136
147
  <p><a href="${BASE}/sql/ai/describe">Describe tables for AI ▸</a> <span class="muted">(better answers: say what tables and columns mean)</span></p>
@@ -162,7 +173,7 @@ async function workshopPage(s: Session, reply: FastifyReply, out: { question?: s
162
173
  const isName = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 63;
163
174
 
164
175
  async function describePage(s: Session, reply: FastifyReply, schema: string, table: string, draft: { table?: string; columns?: Record<string, string> } | null, message?: Raw) {
165
- const list = await schemas();
176
+ const list = await schemas(s);
166
177
  const tables = schema && list.includes(schema) ? await tableInfo({ schema }) : [];
167
178
  const t = table ? tables.find((x) => x.table === table) : undefined;
168
179
  const noted = new Set((await owner.query<{ t: string }>(`select distinct table_name as t from meta.ai_table_note where schema_name = $1`, [schema])).rows.map((r) => r.t));
@@ -299,7 +310,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
299
310
  let result: Raw;
300
311
  try {
301
312
  if (!question) throw new AiError('config', 'Type a question.');
302
- if (!(await schemas()).includes(schema)) throw new AiError('config', 'Choose a schema.');
313
+ if (!(await schemas(s)).includes(schema)) throw new AiError('config', 'Choose a schema.');
303
314
  const res = await ask(s, {
304
315
  system: `You write PostgreSQL ${(await owner.one<{ v: number }>(`select current_setting('server_version_num')::int / 10000 as v`))!.v} SQL for a developer, for these tables and views:\n\n${describeTables(await tableInfo({ schema }))}\n\n` +
305
316
  'Use only those tables and columns, qualified with their schema. Write a SELECT unless the question clearly asks to change data. The developer reviews the SQL before anything runs.',
@@ -331,7 +342,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
331
342
  let result: Raw;
332
343
  try {
333
344
  if (!sql.trim() && !error.trim()) throw new AiError('config', 'Paste a query, an error message or both.');
334
- const tables = schema && (await schemas()).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
345
+ const tables = schema && (await schemas(s)).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
335
346
  const res = await ask(s, {
336
347
  system: 'You explain PostgreSQL to a developer: what a query does, why an error happens and how to fix it. Be concise; show corrected SQL when it helps.' +
337
348
  (tables ? `\n\nThe tables and views of the schema:\n\n${tables}` : ''),
@@ -352,10 +363,10 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
352
363
  });
353
364
 
354
365
  /** The posted table, checked against the catalog (null: unknown or reserved). */
355
- const postedTable = async (b: Record<string, any>) => {
366
+ const postedTable = async (s: Session, b: Record<string, any>) => {
356
367
  const schema = String(b.schema ?? '');
357
368
  const table = String(b.table ?? '');
358
- if (!isName(schema) || !isName(table) || RESERVED.test(schema)) return null;
369
+ if (!isName(schema) || !isName(table) || RESERVED.test(schema) || !(await schemas(s)).includes(schema)) return null;
359
370
  return (await tableInfo({ schema, table }))[0] ?? null;
360
371
  };
361
372
 
@@ -363,11 +374,14 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
363
374
  const s = await developer(req, reply);
364
375
  if (!s) return;
365
376
  const b = (req.body ?? {}) as Record<string, any>;
366
- const t = await postedTable(b);
377
+ const t = await postedTable(s, b);
367
378
  if (!t) return reply.code(404).send('Not found');
368
379
  const notes: [string, string][] = [['', String(b['note:'] ?? '')], ...t.columns.map((c): [string, string] => [c.name, String(b[`note:${c.name}`] ?? '')])]
369
380
  .map(([k, v]) => [k, v.replace(/\s+/g, ' ').trim().slice(0, 2000)]);
370
381
  try {
382
+ // database comments as the developer's SQL connection (an isolated workspace: its own login), then the notes
383
+ const db = await workshopDb(s);
384
+ if (b.comments === 'true' && db !== owner) await db.tx((c) => comment(c, t, notes));
371
385
  await owner.tx(async (c) => {
372
386
  for (const [col, note] of notes) {
373
387
  if (note) await c.query(
@@ -376,12 +390,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
376
390
  [t.schema, t.table, col, note, s.username]);
377
391
  else await c.query('delete from meta.ai_table_note where schema_name = $1 and table_name = $2 and column_name = $3', [t.schema, t.table, col]);
378
392
  }
379
- if (b.comments === 'true') {
380
- const rel = `${pg.escapeIdentifier(t.schema)}.${pg.escapeIdentifier(t.table)}`;
381
- const what = t.kind === 'view' ? 'view' : t.kind === 'materialized view' ? 'materialized view' : 'table';
382
- for (const [col, note] of notes)
383
- await c.query(col ? `comment on column ${rel}.${pg.escapeIdentifier(col)} is ${note ? pg.escapeLiteral(note) : 'null'}` : `comment on ${what} ${rel} is ${note ? pg.escapeLiteral(note) : 'null'}`);
384
- }
393
+ if (b.comments === 'true' && db === owner) await comment(c, t, notes);
385
394
  });
386
395
  flash(s, `Descriptions of ${t.schema}.${t.table} saved${b.comments === 'true' ? ', also as database comments' : ''}.`);
387
396
  } catch (e) {
@@ -394,7 +403,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
394
403
  const s = await developer(req, reply);
395
404
  if (!s) return;
396
405
  const b = (req.body ?? {}) as Record<string, any>;
397
- const t = await postedTable(b);
406
+ const t = await postedTable(s, b);
398
407
  if (!t) return reply.code(404).send('Not found');
399
408
  const names = t.columns.map((c) => c.name);
400
409
  try {
@@ -133,7 +133,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
133
133
  app.get(`${BASE}/blueprints`, async (req: Req, reply) => {
134
134
  const s = await developer(req, reply);
135
135
  if (!s) return;
136
- const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id order by lower(b.name), b.id`)).rows;
136
+ const list = (await owner.query(`select b.id, b.name, b.created_by, b.updated_at::text as updated, a.id as app_id, a.alias from meta.blueprint b left join meta.app a on a.id = b.app_id where b.workspace_id = $1 order by lower(b.name), b.id`, [currentWorkspace(s)])).rows;
137
137
  return page(s, reply, '', html`<h1>Blueprints</h1>
138
138
  <p class="muted">Describe an application as a blueprint (tables, pages, menu, sample rows), review it and create it. <a class="btn btn-hot" href="${BASE}/blueprints/new">${icon('plus')} New blueprint</a></p>
139
139
  ${list.length
@@ -154,7 +154,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
154
154
  app.get(`${BASE}/blueprints/:id(^\\d+$)`, async (req: Req, reply) => {
155
155
  const s = await developer(req, reply);
156
156
  if (!s) return;
157
- const b = await owner.one('select id, name, spec from meta.blueprint where id = $1', [req.params.id]);
157
+ const b = await owner.one('select id, name, spec from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
158
158
  if (!b) return reply.code(404).send('Not found');
159
159
  return editor(s, reply, { id: b.id, name: b.name, spec: JSON.stringify(b.spec, null, 2) });
160
160
  });
@@ -178,8 +178,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
178
178
  if (error || !json || typeof json !== 'object' || Array.isArray(json))
179
179
  return editor(s, reply, { ...o, message: html`<div class="alert alert-error" role="alert">${error ?? 'A blueprint is a JSON object.'}</div>` });
180
180
  const id = o.id
181
- ? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 returning id', [o.id, o.name, JSON.stringify(json)]))?.id
182
- : (await owner.one('insert into meta.blueprint (name, spec, created_by) values ($1, $2, $3) returning id', [o.name, JSON.stringify(json), s.username])).id;
181
+ ? (await owner.one('update meta.blueprint set name = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4 returning id', [o.id, o.name, JSON.stringify(json), currentWorkspace(s)]))?.id
182
+ : (await owner.one('insert into meta.blueprint (name, spec, created_by, workspace_id) values ($1, $2, $3, $4) returning id', [o.name, JSON.stringify(json), s.username, currentWorkspace(s)])).id;
183
183
  if (!id) return reply.code(404).send('Not found');
184
184
  flash(s, `Blueprint ${o.name} saved.`);
185
185
  return back(reply, s, `${BASE}/blueprints/${id}`);
@@ -240,8 +240,8 @@ export async function blueprintRoutes(app: FastifyInstance) {
240
240
  throw new Error(createAppError(e, checked.alias));
241
241
  });
242
242
  const r = await buildBlueprint(c, blueprint, checked.role, { dashboard: (tables, no) => addDashboard(c, checked.alias, tables, no) });
243
- if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1', [o.id, a.id, JSON.stringify(json)]);
244
- else await c.query('insert into meta.blueprint (name, spec, created_by, app_id) values ($1, $2, $3, $4)', [o.name, JSON.stringify(json), s.username, a.id]);
243
+ if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4', [o.id, a.id, JSON.stringify(json), currentWorkspace(s)]);
244
+ else await c.query('insert into meta.blueprint (name, spec, created_by, app_id, workspace_id) values ($1, $2, $3, $4, $5)', [o.name, JSON.stringify(json), s.username, a.id, currentWorkspace(s)]);
245
245
  return { ...r, existing: a.existingAccount };
246
246
  });
247
247
  await logActivity({ username: s.username, event: 'blueprint', ip: clientIp(req), detail: `created ${blueprint.alias} from blueprint ${o.name}` });
@@ -255,7 +255,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
255
255
  app.post(`${BASE}/blueprints/:id(^\\d+$)/delete`, async (req: Req, reply) => {
256
256
  const s = await developer(req, reply);
257
257
  if (!s) return;
258
- const r = await owner.query('delete from meta.blueprint where id = $1', [req.params.id]);
258
+ const r = await owner.query('delete from meta.blueprint where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]);
259
259
  flash(s, r.rowCount ? 'Blueprint deleted (applications created from it stay).' : 'There is no such blueprint.');
260
260
  return back(reply, s, `${BASE}/blueprints`);
261
261
  });
@@ -1,10 +1,11 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import pg from 'pg';
3
3
  import { owner, runtime } from '../db.ts';
4
+ import { devDbForApp } from '../workspace-db.ts';
4
5
  import { html, raw, type Raw } from '../html.ts';
5
6
  import { checkSql, type SqlShape } from './advisor.ts';
6
7
  import { BASE, developer, type Req } from './ui.ts';
7
- import { appAllowed } from './workspaces.ts';
8
+ import { appAllowed, workshopDb } from './workspaces.ts';
8
9
 
9
10
  // The builder's code editor (public/code-editor.js) and its SQL completions.
10
11
  //
@@ -215,7 +216,7 @@ export async function codeEditorRoutes(app: FastifyInstance) {
215
216
  if (!sql.trim()) return reply.send({ ok: true, message: 'Nothing to check.' });
216
217
  if (/^\s*(STATIC|LOV):/i.test(sql)) return reply.send({ ok: true, message: 'Not SQL: a static or shared list of values.' });
217
218
  const role = target ? (await owner.one('select db_role from meta.app where id = $1', [target.id]))?.db_role : null;
218
- const c = await owner.pool.connect();
219
+ const c = await (target ? await devDbForApp(target.id) : await workshopDb(s)).pool.connect();
219
220
  try {
220
221
  await c.query('begin');
221
222
  await c.query(`set local statement_timeout = '5s'`);
@@ -104,7 +104,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
104
104
  { name: 'title', label: 'Title', kind: 'text', group: 'Identification' },
105
105
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
106
106
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
107
- { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
107
+ { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
108
108
  { name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source',
109
109
  help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
110
110
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
@@ -114,7 +114,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
114
114
  { name: 'pk_item', label: 'Primary key item (form)', kind: 'upper', group: 'Source' },
115
115
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
116
116
  { name: 'columns', label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
117
- { name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible'], group: 'Layout' },
117
+ { name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
118
118
  { name: 'template_options', label: 'Template options', kind: 'options', choices: REGION_OPTIONS, group: 'Appearance',
119
119
  help: 'CSS classes from a fixed list, added to the region (APEX: Template Options). "Hide the header" applies to the standard template.' },
120
120
  { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
@@ -448,6 +448,19 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
448
448
  buildOption(),
449
449
  ],
450
450
  },
451
+ page_group: {
452
+ table: 'meta.page_group',
453
+ scope: 'app',
454
+ label: 'Page group',
455
+ plural: 'Page groups',
456
+ icon: 'layers',
457
+ summary: (o) => o.name,
458
+ defaults: {},
459
+ fields: [
460
+ { name: 'name', label: 'Name', kind: 'text', help: 'e.g. Administration. Pages join it in their Page group property; renaming the group renames it on its pages.' },
461
+ { name: 'description', label: 'Description', kind: 'text', wide: true },
462
+ ],
463
+ },
451
464
  build_option: {
452
465
  table: 'meta.build_option',
453
466
  scope: 'app',
@@ -26,6 +26,7 @@ import { readMultipart } from '../runtime/files.ts';
26
26
  import type { Session } from '../session.ts';
27
27
  import type { ComponentSpec } from './components.ts';
28
28
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
29
+ import { appWorkspace, currentWorkspace, workshopDb } from './workspaces.ts';
29
30
 
30
31
  // SQL Workshop → Load Data: upload a CSV/TSV/XLSX/JSON/XML file, preview it,
31
32
  // and load it into a new table (column types inferred) or an existing one
@@ -73,17 +74,23 @@ export const DATA_LOAD_DEF_SPEC: ComponentSpec = {
73
74
  export const dataLoadDefExtras = (row: { id: number }) =>
74
75
  html`<p class="u-mt1"><a class="btn" href="${BASE}/sql/load?definition=${row.id}">${icon('upload')} Load a file with this definition</a></p>`;
75
76
 
76
- async function definitions() {
77
+ /** The data load definitions of the current workspace's applications. */
78
+ async function definitions(s: Session) {
77
79
  return (
78
80
  await owner.query<{ id: number; app_id: number; app_name: string; name: string; table_name: string }>(
79
- 'select d.id, d.app_id, a.name as app_name, d.name, d.table_name from meta.data_load_def d join meta.app a on a.id = d.app_id order by a.name, d.name',
81
+ `select d.id, d.app_id, a.name as app_name, d.name, d.table_name from meta.data_load_def d join meta.app a on a.id = d.app_id
82
+ where meta.app_workspace(a.id) = $1 order by a.name, d.name`,
83
+ [currentWorkspace(s)],
80
84
  )
81
85
  ).rows;
82
86
  }
83
87
 
84
- async function definition(id: unknown) {
88
+ async function definition(s: Session, id: unknown) {
85
89
  if (!/^\d+$/.test(String(id ?? ''))) return undefined;
86
- return owner.one<DataLoadDefinition & { id: number; app_id: number }>('select * from meta.data_load_def where id = $1', [id]);
90
+ return owner.one<DataLoadDefinition & { id: number; app_id: number }>(
91
+ 'select * from meta.data_load_def where id = $1 and meta.app_workspace(app_id) = $2',
92
+ [id, currentWorkspace(s)],
93
+ );
87
94
  }
88
95
 
89
96
  export const MAX_MB = Number(process.env.DATA_LOAD_MAX_MB ?? 50);
@@ -142,7 +149,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
142
149
  });
143
150
 
144
151
  const uploadForm = async (s: Session, error?: string, chosen?: string) => {
145
- const defs = await definitions();
152
+ const defs = await definitions(s);
146
153
  return region(
147
154
  'Load data from a file',
148
155
  html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
@@ -180,7 +187,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
180
187
  if (!s) return;
181
188
  if (!file) return page(s, reply, await uploadForm(s, 'Choose a file.'));
182
189
  if (file.truncated) return page(s, reply, await uploadForm(s, `The file is larger than ${MAX_MB} MB.`));
183
- const def = await definition(req.body?.definition);
190
+ const def = await definition(s, req.body?.definition);
184
191
  const rowTag = (req.body?.row_tag ?? '').trim();
185
192
  try {
186
193
  if (def) await parseFile(file.filename, file.data, { headers: def.headers, format: def.format, rowTag: def.row_tag });
@@ -203,7 +210,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
203
210
  if (!f) return reply.redirect(`${BASE}/sql/load`);
204
211
  const headers = (req.query.h ?? req.body?.h) !== '0';
205
212
  const rowTag = (req.query.rt ?? req.body?.rt ?? '').trim();
206
- const def = await definition(req.query.d ?? req.body?.d);
213
+ const def = await definition(s, req.query.d ?? req.body?.d);
207
214
  let sheet: Sheet;
208
215
  try {
209
216
  sheet = def
@@ -256,17 +263,19 @@ export async function dataLoadRoutes(app: FastifyInstance) {
256
263
  ${skip}
257
264
  <div class="buttons"><button class="btn btn-hot">Create table and load</button></div></form>`;
258
265
 
266
+ const db = await workshopDb(s);
259
267
  const tables = (
260
- await owner.query(
268
+ await db.query(
261
269
  `select c.oid::regclass::text as name from pg_class c join pg_namespace n on n.oid = c.relnamespace
262
- where c.relkind in ('r', 'p') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta') order by 1`,
270
+ where c.relkind in ('r', 'p') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')
271
+ and has_table_privilege(c.oid, 'INSERT') order by 1`,
263
272
  )
264
273
  ).rows.map((r) => r.name as string);
265
274
  const chosen = tables.find((t) => t === (req.query.table ?? req.body?.table));
266
- const apps = (await owner.query<{ id: number; name: string }>('select id, name from meta.app order by name')).rows;
275
+ const apps = (await owner.query<{ id: number; name: string }>('select id, name from meta.app where meta.app_workspace(id) = $1 order by name', [currentWorkspace(s)])).rows;
267
276
  let mapping: Raw | '' = '';
268
277
  if (chosen) {
269
- const cols = await owner.tx((c) => tableColumns(c, chosen));
278
+ const cols = await db.tx((c) => tableColumns(c, chosen));
270
279
  const auto = autoMap(sheet.headers, cols);
271
280
  const hasPk = cols.some((c) => c.pk);
272
281
  mapping = html`<form method="post" action="${action}">${hidden}<input type="hidden" name="target" value="existing"><input type="hidden" name="table" value="${chosen}">
@@ -335,6 +344,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
335
344
  const mode = ['append', 'replace', 'merge'].includes(b.mode ?? '') ? b.mode : 'append';
336
345
  const format = sheet instanceof Error ? 'auto' : sheet.format;
337
346
  try {
347
+ if (!/^\d{1,9}$/.test(String(b.def_app ?? '')) || (await appWorkspace(String(b.def_app))) !== currentWorkspace(s)) throw new LoadError('Choose an application of this workspace.');
338
348
  const r = await owner.one(
339
349
  `insert into meta.data_load_def (app_id, name, table_name, format, headers, row_tag, mode, skip_errors, columns)
340
350
  values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id, app_id`,
@@ -344,7 +354,7 @@ export async function dataLoadRoutes(app: FastifyInstance) {
344
354
  return back(reply, s, `${BASE}/apps/${r!.app_id}/shared?c=data_load_def-${r!.id}`);
345
355
  } catch (e) {
346
356
  const code = (e as { code?: string }).code;
347
- if (!code?.startsWith('23') && code !== '22P02') throw e;
357
+ if (!(e instanceof LoadError) && !code?.startsWith('23') && code !== '22P02') throw e;
348
358
  reply.code(422);
349
359
  return targetPage(req, reply, s, html`<div class="alert alert-error" role="alert">The definition could not be saved: ${
350
360
  code === '23505' ? `the application already has a definition named ${name}.` : (e as Error).message}</div>`);
@@ -354,10 +364,10 @@ export async function dataLoadRoutes(app: FastifyInstance) {
354
364
  let error: string | undefined;
355
365
  let failed = false;
356
366
  try {
357
- const def = b.target === 'definition' ? await definition(b.d) : undefined;
367
+ const def = b.target === 'definition' ? await definition(s, b.d) : undefined;
358
368
  const sheet = def ? null : await parseFile(f.filename, f.content, { headers, rowTag });
359
369
  const opts = { skipErrors: b.skip_errors === 'true', firstRow: headers && sheet && (sheet.format === 'csv' || sheet.format === 'xlsx') ? 2 : 1 };
360
- result = await owner.tx(async (c) => {
370
+ result = await (await workshopDb(s)).tx(async (c) => {
361
371
  if (def) return loadWithDefinition(c, def, f);
362
372
  if (!sheet) throw new LoadError('The data load definition no longer exists.');
363
373
  if (b.target === 'new') {
@@ -0,0 +1,117 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { unifiedDiff } from '../cli/diff.ts';
3
+ import { owner } from '../db.ts';
4
+ import { changeScript, compare, ddlScript, OBJECT_TYPES, pick, readSchema, userSchemas, type Difference } from '../ddl.ts';
5
+ import { html, raw, type Raw } from '../html.ts';
6
+ import { icon } from '../icons.ts';
7
+ import { clientIp, logActivity } from '../session.ts';
8
+ import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
+ import { workshopDb } from './workspaces.ts';
10
+
11
+ // SQL Workshop → Generate DDL and Schema Comparison (APEX: SQL Workshop →
12
+ // Utilities). Both only read the catalog (src/ddl.ts); the scripts they
13
+ // write are shown and downloaded, never run.
14
+
15
+ const TYPE_LABELS: Record<(typeof OBJECT_TYPES)[number], string> = {
16
+ types: 'Types and domains', sequences: 'Sequences', tables: 'Tables (constraints, indexes)', views: 'Views and materialized views',
17
+ functions: 'Functions and procedures', triggers: 'Triggers', policies: 'Row level security and policies',
18
+ };
19
+ const STATE_LABELS: Record<Difference['state'], string> = { source: 'Only in the source', target: 'Only in the target', different: 'Different' };
20
+
21
+ const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
22
+ const schemaSelect = (name: string, label: string, schemas: string[], current: string) =>
23
+ html`<div class="field"><label class="label" for="f_${name}">${label}</label>
24
+ <select id="f_${name}" name="${name}" required><option value="">- choose a schema -</option>${schemas.map((x) => html`<option value="${x}"${x === current ? raw(' selected') : ''}>${x}</option>`)}</select></div>`;
25
+
26
+ /** A unified diff with added and removed lines marked. */
27
+ const diffBlock = (d: Difference, source: string, target: string) => {
28
+ const text = unifiedDiff(d.name, Buffer.from(`${d.target ?? ''}\n`), Buffer.from(`${d.source ?? ''}\n`), [target, source]);
29
+ return html`<pre class="diff">${text.trimEnd().split('\n').map((l, i) => html`${i ? '\n' : ''}<span class="${l.startsWith('+') && !l.startsWith('+++') ? 'diff-add' : l.startsWith('-') && !l.startsWith('---') ? 'diff-del' : null}">${l}</span>`)}</pre>`;
30
+ };
31
+
32
+ export async function ddlToolRoutes(app: FastifyInstance) {
33
+ app.get(`${BASE}/sql/ddl`, async (req: Req, reply) => {
34
+ const s = await developer(req, reply);
35
+ if (!s) return;
36
+ const db = await workshopDb(s);
37
+ const schemas = await userSchemas(db, { usable: db !== owner });
38
+ const q = req.query ?? {};
39
+ const schema = schemas.includes(q.schema ?? '') ? q.schema! : '';
40
+ // the first visit: every type, comments and grants
41
+ const fresh = !q.schema;
42
+ const types = new Set(fresh ? OBJECT_TYPES : many(q.t).filter((x) => (OBJECT_TYPES as readonly string[]).includes(x)));
43
+ const opts = { comments: fresh || q.comments === '1', grants: fresh || q.grants === '1' };
44
+ let result: Raw | '' = '';
45
+ if (schema) {
46
+ try {
47
+ const objects = pick(await readSchema(db.pool, schema), types, opts);
48
+ const script = ddlScript(schema, objects);
49
+ await logActivity({ username: s.username, event: 'generate_ddl', ip: clientIp(req), detail: `${schema}: ${objects.length} objects` });
50
+ if (q.download === '1')
51
+ return reply.header('content-disposition', `attachment; filename="${schema}.sql"`).type('application/sql; charset=utf-8').send(script);
52
+ const qs = new URLSearchParams([['schema', schema], ...[...types].map((x) => ['t', x]), ...(opts.comments ? [['comments', '1']] : []), ...(opts.grants ? [['grants', '1']] : []), ['download', '1']]);
53
+ result = region(`DDL of ${schema}`, html`<p class="muted">${objects.length} object(s). Review the script before you run it (SQL Workshop → SQL Scripts).</p>
54
+ <textarea class="code" rows="24" spellcheck="false" readonly aria-label="DDL of ${schema}" data-code="plpgsql">${script}</textarea>`,
55
+ html`<a class="btn" href="${BASE}/sql/ddl?${qs.toString()}">${icon('download')} Download ${schema}.sql</a>`);
56
+ } catch (e) {
57
+ result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
58
+ }
59
+ }
60
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ddl')}
61
+ <p class="muted">The DDL of a schema's objects, from the database catalog, in an order that runs: types, sequences, tables, functions, views, constraints, indexes, triggers, row level security, comments and grants (APEX: Generate DDL). Objects of extensions are left out.</p>
62
+ <form method="get" class="u-mb1">
63
+ <div class="form-grid">${schemaSelect('schema', 'Schema', schemas, schema)}</div>
64
+ <fieldset class="prop-group"><legend>Objects</legend><div class="form-grid">
65
+ ${OBJECT_TYPES.map((x) => html`<label class="check"><input type="checkbox" name="t" value="${x}"${types.has(x) ? raw(' checked') : ''}> ${TYPE_LABELS[x]}</label>`)}
66
+ <label class="check"><input type="checkbox" name="comments" value="1"${opts.comments ? raw(' checked') : ''}> Comments</label>
67
+ <label class="check"><input type="checkbox" name="grants" value="1"${opts.grants ? raw(' checked') : ''}> Grants</label>
68
+ </div></fieldset>
69
+ <div class="buttons"><button class="btn btn-hot">${icon('code')} Generate</button></div>
70
+ </form>
71
+ ${result}`;
72
+ return send(reply, s, shell(s, 'Generate DDL', [['SQL Workshop', `${BASE}/sql`], ['Generate DDL']], main, 'sql'));
73
+ });
74
+
75
+ app.get(`${BASE}/sql/compare`, async (req: Req, reply) => {
76
+ const s = await developer(req, reply);
77
+ if (!s) return;
78
+ const db = await workshopDb(s);
79
+ const schemas = await userSchemas(db, { usable: db !== owner });
80
+ const q = req.query ?? {};
81
+ const source = schemas.includes(q.source ?? '') ? q.source! : '';
82
+ const target = schemas.includes(q.target ?? '') ? q.target! : '';
83
+ let result: Raw | '' = '';
84
+ if (source && target && source === target) result = html`<div class="alert alert-error" role="alert">Choose two different schemas.</div>`;
85
+ else if (source && target) {
86
+ try {
87
+ const [a, b] = await Promise.all([readSchema(db.pool, source), readSchema(db.pool, target)]);
88
+ const { differences, same } = compare(a, b);
89
+ const script = changeScript(source, target, a, b);
90
+ await logActivity({ username: s.username, event: 'schema_compare', ip: clientIp(req), detail: `${source} → ${target}: ${differences.length} differences` });
91
+ if (q.download === '1')
92
+ return reply.header('content-disposition', `attachment; filename="${target}-like-${source}.sql"`).type('application/sql; charset=utf-8').send(script);
93
+ const groups = (['source', 'target', 'different'] as const).map((st) => [st, differences.filter((d) => d.state === st)] as const).filter(([, list]) => list.length);
94
+ result = html`${region('Summary', html`<p>${differences.length
95
+ ? `${differences.length} difference(s): ${groups.map(([st, list]) => `${list.length} ${STATE_LABELS[st].toLowerCase()}`).join(', ')}; ${same} object(s) the same.`
96
+ : `The schemas are the same (${same} objects).`}</p>`)}
97
+ ${groups.map(([st, list]) => html`<div class="u-spacer"></div>${region(`${STATE_LABELS[st]} (${st === 'source' ? source : st === 'target' ? target : `${source} and ${target}`})`,
98
+ html`<ul class="compare-list">${list.map((d) => html`<li><details><summary><span class="tag">${d.kind}</span> ${d.name}</summary>
99
+ ${st === 'different' ? diffBlock(d, source, target) : html`<pre class="diff">${d.source ?? d.target}</pre>`}</details></li>`)}</ul>`)}`)}
100
+ <div class="u-spacer"></div>
101
+ ${region(`Change script: make ${target} like ${source}`, html`<p class="muted">Creates what only ${source} has, changes what differs, and lists what only ${target} has as commented-out drops; columns that change identity or generation, and types, domains and sequences that differ, are left for review. Nothing is run here.</p>
102
+ <textarea class="code" rows="20" spellcheck="false" readonly aria-label="Change script" data-code="plpgsql">${script}</textarea>`,
103
+ html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} Download</a>`)}`;
104
+ } catch (e) {
105
+ result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
106
+ }
107
+ }
108
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('compare')}
109
+ <p class="muted">Compares the objects of two schemas of this database by their DDL (APEX: Schema Comparison), e.g. a development copy with the schema it came from, and writes a script that makes the target like the source. The order of a table's columns is not compared.</p>
110
+ <form method="get" class="u-mb1">
111
+ <div class="form-grid">${schemaSelect('source', 'Source', schemas, source)}${schemaSelect('target', 'Target (to make like the source)', schemas, target)}</div>
112
+ <div class="buttons"><button class="btn btn-hot">${icon('layers')} Compare</button></div>
113
+ </form>
114
+ ${result}`;
115
+ return send(reply, s, shell(s, 'Schema Comparison', [['SQL Workshop', `${BASE}/sql`], ['Schema Comparison']], main, 'sql'));
116
+ });
117
+ }
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
5
5
  import { COMPONENTS } from './components.ts';
6
6
  import { back, BASE, bicon, csrf, developer, flash, input, select, send, shell, type Req } from './ui.ts';
7
7
  import { buildOptionChoices, componentForm, lookups, saveComponent } from './forms.ts';
8
+ import { regionConfigHelp } from './region-help.ts';
8
9
  import { parseIncludes } from './static-files.ts';
9
10
  import { regionSettingsForm } from './region-settings.ts';
10
11
  import { usedInPanel } from './search.ts';
@@ -301,6 +302,8 @@ export async function designerRoutes(app: FastifyInstance) {
301
302
  </div></fieldset>
302
303
  <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
303
304
  ${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
305
+ ${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
306
+ ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['minimal', 'Minimal (no navigation)']], 'Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
304
307
  ${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
305
308
  ${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
306
309
  ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
@@ -322,12 +325,24 @@ export async function designerRoutes(app: FastifyInstance) {
322
325
  <form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-zone">${csrf(s)}
323
326
  <button class="btn btn-sm btn-danger" data-confirm="Delete page ${p.page_no} and all its components?">Delete page</button></form>`;
324
327
  }
328
+ // (sprint 59) the help pane: the guide section of the selected component's type
329
+ const guide = (() => {
330
+ const GUIDE = 'https://pgkiln.vargar.eu/docs';
331
+ const k = newKind || kind;
332
+ if (k === 'region') return regionConfigHelp((newKind ? req.query.type : rows.region.find((r: any) => String(r.id) === cid)?.type) ?? null).url ?? `${GUIDE}/pages-and-regions#region-types`;
333
+ return ({ item: `${GUIDE}/items`, button: `${GUIDE}/processing#buttons`, dynamic_action: `${GUIDE}/dynamic-actions`, validation: `${GUIDE}/processing#validations`,
334
+ process: `${GUIDE}/processing#processes`, computation: `${GUIDE}/processing#computations`, branch: `${GUIDE}/processing#branches` } as Record<string, string>)[k] ?? `${GUIDE}/pages-and-regions`;
335
+ })();
325
336
  const right = html`
326
337
  <div class="pe-head"><div class="pe-title">${heading}</div>
327
338
  <div class="pe-filter-row" hidden><label class="sr-only" for="pe-filter">Filter properties</label>${icon('filter')}<input id="pe-filter" class="pe-filter" type="search" placeholder="Filter" autocomplete="off"></div>
328
339
  </div>
329
340
  ${arrange}
330
- <div class="pe">${props}</div>`;
341
+ <div class="pe">${props}</div>
342
+ <details class="pe-help" open${guide ? raw(` data-guide="${guide}"`) : ''}>
343
+ <summary>${icon('help')} Help</summary>
344
+ <div class="pe-help-body" aria-live="polite" data-pe-help><p class="muted">Select a property to see its help here.</p></div>
345
+ </details>`;
331
346
 
332
347
  // ------------------------------------------------------------ toolbar
333
348
  const idx = pages.findIndex((x) => x.id === p.id);
@@ -380,6 +395,17 @@ export async function designerRoutes(app: FastifyInstance) {
380
395
  return send(reply, s, shell(s, `Page ${p.page_no}`, [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [`Page ${p.page_no}: ${p.name}`]], main, 'apps', { toolbar, full: true }));
381
396
  });
382
397
 
398
+ // (sprint 59) Quick edit from the developer toolbar: an item or button of the page by its name
399
+ app.get(`${BASE}/pages/:pid/quick`, async (req: Req, reply) => {
400
+ const s = await developer(req, reply);
401
+ if (!s) return;
402
+ if (!/^\d{1,9}$/.test(req.params.pid)) return reply.code(404).send('Not found');
403
+ const q = req.query ?? {};
404
+ const [kind, table, name] = q.item ? ['item', 'meta.item', String(q.item).toUpperCase()] : q.button ? ['button', 'meta.button', String(q.button).toUpperCase()] : [null, null, null];
405
+ const row = kind ? await owner.one(`select id from ${table} where page_id = $1 and name = $2 order by id limit 1`, [req.params.pid, name]) : null;
406
+ return reply.redirect(`${BASE}/pages/${req.params.pid}${row ? `?c=${kind}-${row.id}` : ''}`);
407
+ });
408
+
383
409
  app.post(`${BASE}/pages/:pid`, async (req: Req, reply) => {
384
410
  const s = await developer(req, reply);
385
411
  if (!s) return;
@@ -388,9 +414,9 @@ export async function designerRoutes(app: FastifyInstance) {
388
414
  const before = await owner.one('select app_id, page_no from meta.page where id = $1', [req.params.pid]);
389
415
  await owner.query(
390
416
  `update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
391
- dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14 where id = $1`,
417
+ dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
392
418
  [req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
393
- ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true'],
419
+ ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', b.page_template === 'minimal' ? 'minimal' : 'standard', b.page_group?.trim() || null],
394
420
  );
395
421
  // the page's lock and comments follow a new page number
396
422
  if (before && before.page_no !== Number(b.page_no))
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
2
2
  import pg from 'pg';
3
3
  import { applyBinds } from '../binds.ts';
4
4
  import { owner } from '../db.ts';
5
+ import { devDbForApp } from '../workspace-db.ts';
5
6
  import { html } from '../html.ts';
6
7
  import { english } from '../i18n.ts';
7
8
  import { documentData, documentPdf, fillTemplate, TemplateError } from '../runtime/document.ts';
@@ -42,7 +43,7 @@ export async function documentRoutes(app: FastifyInstance) {
42
43
  const tpl = a && /^\d+$/.test(tid) ? await owner.one('select * from meta.document_template where id = $1 and app_id = $2', [tid, a.id]) : undefined;
43
44
  if (!a || !tpl) return reply.code(404).send('Not found');
44
45
  const binds = previewBinds(String(req.query.binds ?? ''));
45
- const c = await owner.pool.connect();
46
+ const c = await (await devDbForApp(a.id)).pool.connect();
46
47
  try {
47
48
  await c.query('begin');
48
49
  await c.query(`set local statement_timeout = '10s'`);
@@ -19,10 +19,11 @@ export interface Lookups {
19
19
  lists: string[];
20
20
  listEntries: { id: number; list_name: string; label: string }[];
21
21
  automations: string[];
22
+ pageGroups: string[];
22
23
  }
23
24
 
24
25
  export async function lookups(appId: number, pageId?: number): Promise<Lookups> {
25
- const [regions, pages, authz, nav, buildOptions, rest, lists, listEntries, automations] = await Promise.all([
26
+ const [regions, pages, authz, nav, buildOptions, rest, lists, listEntries, automations, pageGroups] = await Promise.all([
26
27
  pageId ? owner.query('select id, title, type from meta.region where page_id = $1 order by seq, id', [pageId]) : Promise.resolve({ rows: [] }),
27
28
  owner.query('select page_no, name from meta.page where app_id = $1 order by page_no', [appId]),
28
29
  owner.query('select name from meta.authz_scheme where app_id = $1 order by name', [appId]),
@@ -32,8 +33,9 @@ export async function lookups(appId: number, pageId?: number): Promise<Lookups>
32
33
  owner.query('select name from meta.list where app_id = $1 order by name', [appId]),
33
34
  owner.query('select id, list_name, label from meta.list_entry where app_id = $1 order by list_name, seq, id', [appId]),
34
35
  owner.query('select name from meta.automation where app_id = $1 order by name', [appId]),
36
+ owner.query('select name from meta.page_group where app_id = $1 order by name', [appId]),
35
37
  ]);
36
- return { regions: regions.rows, pages: pages.rows, authz: authz.rows.map((r) => r.name), nav: nav.rows, buildOptions: buildOptions.rows, restSources: rest.rows.map((r) => r.name), lists: lists.rows.map((r) => r.name), listEntries: listEntries.rows, automations: automations.rows.map((r) => r.name) };
38
+ return { regions: regions.rows, pages: pages.rows, authz: authz.rows.map((r) => r.name), nav: nav.rows, buildOptions: buildOptions.rows, restSources: rest.rows.map((r) => r.name), lists: lists.rows.map((r) => r.name), listEntries: listEntries.rows, automations: automations.rows.map((r) => r.name), pageGroups: pageGroups.rows.map((r) => r.name) };
37
39
  }
38
40
 
39
41
  /** Choices of a build option field: each option and its negation, plus a missing current value. */
@@ -84,7 +86,7 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
84
86
  html`<select id="${id}" name="${f.name}">${list.map(([val, label]) => html`<option value="${val}"${String(v ?? '') === val ? raw(' selected') : ''}>${label}</option>`)}</select>`;
85
87
  switch (f.kind) {
86
88
  case 'bool':
87
- return html`<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="${f.name}" value="true"${v ? raw(' checked') : ''}> ${f.label}</label>${help}</div>`;
89
+ return html`<div class="field" data-prop="${f.name}"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="${f.name}" value="true"${v ? raw(' checked') : ''}> ${f.label}</label>${help}</div>`;
88
90
  case 'options': {
89
91
  const chosen: unknown[] = Array.isArray(v) ? v : [];
90
92
  return html`<fieldset class="field options-field"${f.wide ? raw(' data-wide') : ''}><legend class="label">${f.label}</legend>
@@ -159,7 +161,7 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
159
161
  default:
160
162
  control = html`<input id="${id}" name="${f.name}" type="${f.kind === 'int' ? 'number' : 'text'}" value="${v ?? ''}">`;
161
163
  }
162
- return html`<div class="field"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
164
+ return html`<div class="field" data-prop="${f.name}"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
163
165
  };
164
166
  const groups = [...new Set(spec.fields.map((f) => f.group ?? ''))];
165
167
  return html`<form method="post" action="${action}" class="component-form"${opts.id ? html` id="${opts.id}"` : ''}>