pgkiln 0.43.0 → 0.45.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 (69) hide show
  1. package/db/migrations/100_generated_labels.sql +381 -0
  2. package/db/migrations/101_theme_choice.sql +15 -0
  3. package/db/migrations/102_app_computations.sql +345 -0
  4. package/db/migrations/103_shortcuts.sql +341 -0
  5. package/db/migrations/104_report_queries.sql +413 -0
  6. package/db/migrations/105_component_settings_groups.sql +367 -0
  7. package/docs/apex-feature-parity.md +12 -12
  8. package/docs/guide/02-concepts.md +24 -0
  9. package/docs/guide/03-builder.md +48 -9
  10. package/docs/guide/04-pages-and-regions.md +20 -4
  11. package/docs/guide/06-processing.md +22 -0
  12. package/docs/guide/09-reference.md +14 -0
  13. package/docs/guide/12-development.md +2 -1
  14. package/docs/guide/14-globalization.md +5 -1
  15. package/docs/guide/16-files.md +23 -0
  16. package/docs/guide/18-cli.md +4 -0
  17. package/package.json +1 -1
  18. package/public/app.css +40 -1
  19. package/public/app.js +69 -1
  20. package/src/appfiles.ts +18 -2
  21. package/src/builder/advisor.ts +7 -2
  22. package/src/builder/appsheets.ts +53 -13
  23. package/src/builder/appwizard.ts +5 -3
  24. package/src/builder/code-editor.ts +6 -1
  25. package/src/builder/components.ts +85 -3
  26. package/src/builder/forms.ts +1 -1
  27. package/src/builder/shared.ts +39 -6
  28. package/src/builder/subscriptions.ts +30 -4
  29. package/src/cli/replace.ts +6 -2
  30. package/src/i18n/ar.ts +2 -0
  31. package/src/i18n/cs.ts +2 -0
  32. package/src/i18n/da.ts +2 -0
  33. package/src/i18n/de.ts +2 -0
  34. package/src/i18n/el.ts +2 -0
  35. package/src/i18n/es.ts +2 -0
  36. package/src/i18n/fi.ts +2 -0
  37. package/src/i18n/fr.ts +2 -0
  38. package/src/i18n/he.ts +2 -0
  39. package/src/i18n/it.ts +2 -0
  40. package/src/i18n/ja.ts +2 -0
  41. package/src/i18n/ko.ts +2 -0
  42. package/src/i18n/nb.ts +2 -0
  43. package/src/i18n/pl.ts +2 -0
  44. package/src/i18n/pt.ts +2 -0
  45. package/src/i18n/ru.ts +2 -0
  46. package/src/i18n/sv.ts +2 -0
  47. package/src/i18n/tr.ts +2 -0
  48. package/src/i18n/uk.ts +2 -0
  49. package/src/i18n/zh.ts +2 -0
  50. package/src/i18n.ts +4 -0
  51. package/src/metadata.ts +14 -1
  52. package/src/runtime/account.ts +5 -5
  53. package/src/runtime/component-settings.ts +42 -0
  54. package/src/runtime/context.ts +4 -0
  55. package/src/runtime/devbar.ts +16 -13
  56. package/src/runtime/documents.ts +29 -3
  57. package/src/runtime/engine.ts +2 -2
  58. package/src/runtime/facets.ts +34 -3
  59. package/src/runtime/grid.ts +22 -4
  60. package/src/runtime/items.ts +14 -11
  61. package/src/runtime/locale.ts +4 -1
  62. package/src/runtime/logic.ts +12 -7
  63. package/src/runtime/regions.ts +4 -3
  64. package/src/runtime/render.ts +8 -2
  65. package/src/runtime/report.ts +16 -10
  66. package/src/runtime/routes.ts +9 -6
  67. package/src/runtime/shortcuts.ts +43 -0
  68. package/src/security.ts +2 -1
  69. package/src/subscriptions.ts +67 -0
@@ -8,6 +8,7 @@ import type { Button, Region } from '../metadata.ts';
8
8
  import { isAuthorized, pageAllowed } from './authz.ts';
9
9
  import { bindValues, dbg, publicError, stripSemicolon, substitute, timed, type PageContext } from './context.ts';
10
10
  import { renderItems } from './items.ts';
11
+ import { expandShortcuts } from './shortcuts.ts';
11
12
  import { fillItems, linkAttrs, linkColumns } from './links.ts';
12
13
  import { renderCalendar } from './calendar.ts';
13
14
  import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './charts.ts';
@@ -66,7 +67,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
66
67
  for (const e of b.menu ?? []) {
67
68
  if (!(await isAuthorized(ctx, e.authz))) continue;
68
69
  const ic = e.icon ? html`${icon(e.icon)} ` : '';
69
- const confirm = e.confirm ? raw(` data-confirm="${esc(e.confirm)}"`) : '';
70
+ const confirm = e.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, e.confirm, 'text'))}"`) : '';
70
71
  if (e.page) {
71
72
  if (!(await pageAllowed(ctx, e.page))) continue;
72
73
  entries.push(html`<a ${linkAttrs(ctx, e.page, e.items ?? {})}${confirm}>${ic}${e.label}</a>`);
@@ -82,7 +83,7 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
82
83
 
83
84
  export async function renderButton(ctx: PageContext, b: Button) {
84
85
  const cls = `btn${b.hot ? ' btn-hot' : ''}${b.name === 'DELETE' ? ' btn-danger' : ''}${templateClasses('button', b.template_options)}`;
85
- const confirm = b.confirm ? raw(` data-confirm="${esc(b.confirm)}"`) : '';
86
+ const confirm = b.confirm ? raw(` data-confirm="${esc(await expandShortcuts(ctx, b.confirm, 'text'))}"`) : '';
86
87
  const badge = b.badge || b.badge_query ? await badgeOf(ctx, b) : '';
87
88
  if (b.action === 'menu') return renderMenu(ctx, b, cls, badge);
88
89
  if (b.action === 'redirect') {
@@ -401,7 +402,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
401
402
  break;
402
403
  case 'static': {
403
404
  const itemsHtml = await renderItems(ctx, items, hidden);
404
- body = html`${raw(substitute(r.source ?? '', ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
405
+ body = html`${raw(substitute(await expandShortcuts(ctx, r.source ?? '', 'html'), ctx, esc))}${itemsHtml.length ? html`<div class="form-grid">${itemsHtml}</div>` : ''}`;
405
406
  break;
406
407
  }
407
408
  }
@@ -292,9 +292,14 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
292
292
  </form>
293
293
  </div>
294
294
  </details>`
295
- : html`<a href="${ctx.base}/login">${t('login.sign_in_link')}</a>`
295
+ : // (sprint 79, UI-3; APEX: the user menu of a public page) signed out: Appearance and Sign in in the user menu
296
+ ctx.locale.themeChoice || styleChoice(ctx.app)
297
+ ? html`<details class="menu t-user"><summary>${icon('user')}<span>${t('login.sign_in_link')}</span></summary>
298
+ <div class="menu-panel align-right">${themeSwitch(ctx, here(ctx))}${styleSwitch(ctx, here(ctx))}
299
+ <div class="menu-section"><a href="${ctx.base}/login">${icon('arrow-right')} ${t('login.sign_in_link')}</a></div></div></details>`
300
+ : html`<a href="${ctx.base}/login">${t('login.sign_in_link')}</a>`
296
301
  : ctx.locale.themeChoice || styleChoice(ctx.app)
297
- ? html`<details class="menu t-user"><summary>${icon('settings')}<span class="sr-only">${t('theme.label')}</span></summary>
302
+ ? html`<details class="menu t-user"><summary title="${t('theme.label')}">${icon('settings')}<span class="sr-only">${t('theme.label')}</span></summary>
298
303
  <div class="menu-panel align-right">${themeSwitch(ctx, here(ctx))}${styleSwitch(ctx, here(ctx))}</div></details>`
299
304
  : ''}
300
305
  </header>
@@ -359,6 +364,7 @@ const timeZoneMeta = (ctx: PageContext) =>
359
364
 
360
365
  export async function renderPage(ctx: PageContext) {
361
366
  const t = ctx.locale.t;
367
+ ctx.wholePage = true;
362
368
  const hidden = initiallyHidden(ctx);
363
369
  const defaultButton = [...ctx.vis!.buttons.values()].find((b) => b.hot && b.action === 'submit');
364
370
  const pageItems = await renderItems(ctx, ctx.page.items.filter((i) => i.region_id === null), hidden);
@@ -1,3 +1,4 @@
1
+ import { componentSetting } from './component-settings.ts';
1
2
  import { icon } from '../icons.ts';
2
3
  import pg from 'pg';
3
4
  import { templateClasses } from './template-options.ts';
@@ -237,7 +238,7 @@ export function selectionOf(page: { items: { name: string }[] }, r: Region) {
237
238
 
238
239
  export function reportState(ctx: PageContext, r: Region): ReportState {
239
240
  const p = ctx.params;
240
- const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || 15;
241
+ const size = parseInt(p.get(key(r, 'n')) ?? '', 10) || Number(r.config.page_size) || componentSetting(ctx.app.component_settings, 'report_rows');
241
242
  const st: ReportState = {
242
243
  search: (p.get(key(r, 'q')) ?? '').trim(),
243
244
  searchCol: (p.get(key(r, 'qc')) ?? '').trim() || null,
@@ -567,17 +568,20 @@ export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr:
567
568
  </nav>`;
568
569
  }
569
570
 
570
- /** (sprint 76) Rows per page as a select in the pager (a GET form; app.js sends it on change, else its button). */
571
+ /** (sprint 76) Rows per page as a select in the pager (a detached GET form; app.js sends it on change, else its button). */
571
572
  function sizeChooser(ctx: PageContext, r: Region, info: PageInfo): Raw | '' {
572
573
  if (r.type !== 'report' || r.config.interactive === false) return '';
573
574
  const st = reportState(ctx, r);
574
575
  const keep = [...ctx.params.entries()].filter(([k]) => k !== key(r, 'n') && k !== key(r, 'p') && k !== key(r, 'k') && !['clear', 'cs'].includes(k));
575
576
  const t = ctx.locale.t;
576
577
  if (info.total !== null && info.total <= PAGE_SIZES[0] && st.size === PAGE_SIZES[0]) return '';
577
- return html`<form method="get" action="${ctx.base}/${ctx.page.page_no}" class="pager-size">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}
578
+ // the region sits inside the page's form: the GET form is detached, the select and its button join it by its id
579
+ const form = `rn${r.id}`;
580
+ ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}${ctx.dialog ? html`<input type="hidden" name="dialog" value="1">` : ''}</form>`);
581
+ return html`<span class="pager-size">
578
582
  <label for="r${r.id}_size">${t('report.rows_per_page')}</label>
579
- <select id="r${r.id}_size" name="${key(r, 'n')}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
580
- <button class="btn btn-sm no-js-only">${t('report.go')}</button></form>`;
583
+ <select id="r${r.id}_size" name="${key(r, 'n')}" form="${form}" data-go>${[...new Set([...PAGE_SIZES, st.size])].sort((a, b) => a - b).map((n) => html`<option value="${n}"${n === st.size ? raw(' selected') : ''}>${n}</option>`)}</select>
584
+ <button class="btn btn-sm no-js-only" form="${form}">${t('report.go')}</button></span>`;
581
585
  }
582
586
 
583
587
  /** (sprint 76) The most values a column heading's menu lists. */
@@ -788,6 +792,8 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
788
792
  const st = reportState(ctx, r);
789
793
  const searchable = r.config.searchable !== false;
790
794
  const interactive = r.config.interactive !== false && searchable;
795
+ // (sprint 78; APEX: a faceted search's results have no search bar) one search field: the facets'
796
+ const searchBox = !ctx.page.regions.some((x) => x.type === 'facets' && x.config.search === true && Number(x.config.report) === r.id && ctx.vis?.regions.has(x.id) !== false);
791
797
 
792
798
  let res: pg.QueryResult<any[]>;
793
799
  let pageNo = st.page;
@@ -1124,14 +1130,14 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1124
1130
  ? html`<div class="report-toolbar">
1125
1131
  ${filterItems}
1126
1132
  ${searchable
1127
- ? html`<div class="search" role="search">
1128
- <div class="search-field">${interactive
1133
+ ? html`<div class="search"${searchBox ? raw(' role="search"') : ''}>
1134
+ ${searchBox ? html`<div class="search-field">${interactive
1129
1135
  ? html`<label class="sr-only" for="r${r.id}_qc">${t('report.search_column')}</label>
1130
- <select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
1131
- <option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`
1136
+ <span class="search-scope-wrap" title="${t('report.search_column')}">${icon('search')}<select id="r${r.id}_qc" class="search-scope" name="${key(r, 'qc')}" form="${searchForm}">
1137
+ <option value="">${t('report.all_columns')}</option>${cols.map(({ f }) => html`<option value="${f.name}"${f.name === st.searchCol ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select></span>`
1132
1138
  : ''}
1133
1139
  <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_all')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}"></div>
1134
- <button class="btn" form="${searchForm}">${t('report.go')}</button>
1140
+ <button class="btn no-js-only" form="${searchForm}">${t('report.go')}</button>` : ''}
1135
1141
  ${actionsMenu}
1136
1142
  </div>`
1137
1143
  : ''}
@@ -44,7 +44,7 @@ import { PassThrough } from 'node:stream';
44
44
  import { reportPdf } from './pdf.ts';
45
45
  import { renderDocument } from './documents.ts';
46
46
  import { pwaHead } from './pwa.ts';
47
- import { resolveLocale, THEME_COOKIE, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
47
+ import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
48
48
  import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
49
49
  import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
50
50
  import { passwordResetRoutes } from './password-reset.ts';
@@ -122,13 +122,14 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
122
122
  // the browser's time zone (sent before signing in) stays with the new session
123
123
  if (typeof oldSession.state.__TZ === 'string') s.state.__TZ = oldSession.state.__TZ;
124
124
  // the account's preferences: light/dark, language and time zone
125
- const pref = await runtime.one<{ theme_pref: string; language: string | null; time_zone: string | null }>(
125
+ const pref = await runtime.one<{ theme_pref: string | null; language: string | null; time_zone: string | null }>(
126
126
  'select theme_pref, language, time_zone from meta.account where lower(username) = lower($1)', [username]);
127
127
  if (pref) {
128
- s.state.__THEME = pref.theme_pref;
128
+ // (sprint 79) no choice (null): the application's own mode
129
+ if (pref.theme_pref) s.state.__THEME = pref.theme_pref;
129
130
  if (pref.language) s.state.__LANG = pref.language;
130
131
  if (pref.time_zone) s.state.__TZ_PREF = pref.time_zone;
131
- if (a.theme?.user_choice !== false) reply.setCookie(THEME_COOKIE, pref.theme_pref, { path: '/', sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 365 * 86400 });
132
+ if (pref.theme_pref && a.theme?.user_choice !== false) reply.setCookie(THEME_COOKIE, pref.theme_pref, themeCookieOptions(base));
132
133
  }
133
134
  // the account's style variant for this app (honoured only while the app offers it: styles.ts)
134
135
  const style = await runtime.one<{ style: string }>(
@@ -140,7 +141,8 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
140
141
  if (shared) s.state.__SHARED = shared;
141
142
  await saveState(s);
142
143
 
143
- if (a.app_processes.some((p) => p.point === 'after_login')) {
144
+ // the application processes "after login", then (102) the application computations "after authentication"
145
+ if (a.app_processes.some((p) => p.point === 'after_login') || (a.app_computations ?? []).some((c) => c.point === 'after_authentication')) {
144
146
  const home = (await loadPage(a.id, a.home_page)) ?? ({ page_no: a.home_page, items: [], regions: [], buttons: [], dynamic_actions: [], validations: [], processes: [], computations: [], branches: [] } as unknown as Page);
145
147
  const ctx: PageContext = {
146
148
  app: a, page: home, session: s, base, params: new URLSearchParams(), request: '', user: username,
@@ -150,6 +152,7 @@ export async function signIn(req: FastifyRequest, reply: FastifyReply, a: App, o
150
152
  await appTx(txContext(ctx), async (c) => {
151
153
  ctx.client = c;
152
154
  await runAppProcesses(ctx, 'after_login');
155
+ await runComputations(ctx, 'after_authentication');
153
156
  });
154
157
  await saveState(s);
155
158
  }
@@ -1024,7 +1027,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1024
1027
  if (!item || !vis.editable.has(item.name)) throw new Forbidden(ctx.locale.t('error.unknown_item'));
1025
1028
  if (item.config?.cascade_parents) applyPostedItems(ctx, body, list(item.config.cascade_parents));
1026
1029
  const page = Math.min(Math.max(Math.floor(Number(body.p)) || 0, 0), 10_000);
1027
- return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n));
1030
+ return searchLov(ctx, item, String(body.q ?? '').slice(0, 200), page, popupPageSize(item, body.n, ctx.app.component_settings));
1028
1031
  });
1029
1032
  return reply.send(out);
1030
1033
  } catch (e) {
@@ -0,0 +1,43 @@
1
+ import { esc } from '../html.ts';
2
+ import { publicError, type PageContext } from './context.ts';
3
+ import { computeValue } from './logic.ts';
4
+
5
+ // Shortcuts (migration 103; APEX: Shared Components → Shortcuts). "NAME" (an
6
+ // upper-case name in double quotes) in a static region's content, an item's
7
+ // help text and a button's or menu entry's confirm message is replaced by the
8
+ // application's shortcut of that name, before &ITEM. substitutions. Other
9
+ // quoted words (class="NAV" with no shortcut NAV) stay as they are.
10
+ //
11
+ // text the value; escaped where it goes into HTML
12
+ // html the value as HTML
13
+ // function_body PL/pgSQL returning the text (HTML), run once per request
14
+ // as the application's role
15
+
16
+ const NAME = /"([A-Z][A-Z0-9_]{0,59})"/g;
17
+
18
+ /** A shortcut's text (html: as it goes into HTML), computed once per request; null: no such shortcut. */
19
+ async function valueOf(ctx: PageContext, name: string, asHtml: boolean): Promise<string | null> {
20
+ const s = ctx.app.shortcuts?.find((x) => x.name === name);
21
+ if (!s) return null;
22
+ if (s.type === 'text') return asHtml ? esc(s.value) : s.value;
23
+ if (s.type === 'html') return s.value;
24
+ const cache = (ctx.shortcutValues ??= new Map());
25
+ if (!cache.has(name)) {
26
+ try {
27
+ cache.set(name, (await computeValue(ctx, { type: 'function_body', expression: s.value })) ?? '');
28
+ } catch (e) {
29
+ ctx.errors.page.push(await publicError(ctx, e, `shortcut "${name}"`));
30
+ cache.set(name, '');
31
+ }
32
+ }
33
+ return cache.get(name)!;
34
+ }
35
+
36
+ /** The text with its "NAME" shortcuts replaced (as HTML or as plain text). */
37
+ export async function expandShortcuts(ctx: PageContext, text: string, as: 'html' | 'text'): Promise<string> {
38
+ if (!ctx.app.shortcuts?.length || !text.includes('"')) return text;
39
+ const names = new Set([...text.matchAll(NAME)].map((m) => m[1]));
40
+ const values = new Map<string, string | null>();
41
+ for (const n of names) values.set(n, await valueOf(ctx, n, as === 'html'));
42
+ return text.replace(NAME, (m, n: string) => values.get(n) ?? m);
43
+ }
package/src/security.ts CHANGED
@@ -96,7 +96,8 @@ export function securityHeaders(app: FastifyInstance) {
96
96
  // the app's own pages may use the camera (photos, scanning) and the position (location items); nothing else may
97
97
  reply.header('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(self)');
98
98
  const type = String(reply.getHeader('content-type') ?? '');
99
- if (type.startsWith('text/html')) {
99
+ // a file's own stricter policy (downloads, uploaded files: default-src 'none'; sandbox) is kept
100
+ if (type.startsWith('text/html') && !reply.hasHeader('content-security-policy')) {
100
101
  // No inline scripts and no inline styles: behaviour lives in /static/app.js,
101
102
  // styling in /static/app.css. The only <style> a page has (theme colours,
102
103
  // chart geometry) carries this response's nonce; style="" attributes are refused.
@@ -219,3 +219,70 @@ export async function publish(masterId: number, kind: string, name: string, user
219
219
 
220
220
  export const unsubscribe = async (appId: number, kind: string, name: string) =>
221
221
  ((await owner.query('delete from meta.subscription where app_id = $1 and kind = $2 and name = $3', [appId, kind, name])).rowCount ?? 0) > 0;
222
+
223
+ // ------------------------------------------------------------------ (105) component groups
224
+ // A component group names some of an application's subscribable components
225
+ // ("kind:name"); another application subscribes to all of them at once.
226
+
227
+ /** The kinds a component group can hold: every subscribable kind but the theme. */
228
+ export const GROUP_KINDS = (Object.keys(KINDS) as Kind[]).filter((k) => k !== 'theme');
229
+
230
+ /** Members as the application has them ("lov:JOBS"), and a message for each that it doesn't have. */
231
+ export async function checkGroupMembers(appId: number, members: string[]) {
232
+ const ok: string[] = [];
233
+ const problems: string[] = [];
234
+ for (const m of members.map((x) => x.trim()).filter(Boolean)) {
235
+ const at = m.indexOf(':');
236
+ const kind = m.slice(0, at).trim();
237
+ const name = m.slice(at + 1).trim();
238
+ if (at < 1 || !isKind(kind) || kind === 'theme') {
239
+ problems.push(`${m}: write kind:name, the kind one of ${GROUP_KINDS.join(', ')}.`);
240
+ continue;
241
+ }
242
+ const k = KINDS[kind];
243
+ const row = await owner.one<{ n: string }>(`select ${k.key} as n from ${k.table} where app_id = $1 and lower(${k.key}) = lower($2)`, [appId, name]);
244
+ if (!row) problems.push(`${m}: the application has no ${k.label.toLowerCase()} ${name}.`);
245
+ else if (!ok.includes(`${kind}:${row.n}`)) ok.push(`${kind}:${row.n}`);
246
+ }
247
+ return { members: ok, problems };
248
+ }
249
+
250
+ /** Component groups of the theme and library applications appId can subscribe to. */
251
+ export async function groupsOffered(appId: number) {
252
+ return (
253
+ await owner.query<{ master_id: number; master_name: string; name: string; description: string | null; members: string[] }>(
254
+ `select m.id as master_id, m.name as master_name, g.name, g.description, g.members
255
+ from meta.component_group g join meta.app m on m.id = g.app_id
256
+ where m.app_type in ('theme', 'library') and m.id <> $1 and meta.app_workspace(m.id) = meta.app_workspace($1)
257
+ order by lower(m.name), m.id, lower(g.name)`,
258
+ [appId],
259
+ )
260
+ ).rows;
261
+ }
262
+
263
+ /** Subscribe appId to every member of a master's component group; members the master no longer has are skipped. */
264
+ export async function subscribeGroup(appId: number, masterId: number, group: string, username: string) {
265
+ const g = await owner.one<{ members: string[] }>('select members from meta.component_group where app_id = $1 and name = $2', [masterId, group]);
266
+ if (!g) throw new SubscriptionError('Choose a component group.');
267
+ const done: string[] = [];
268
+ const skipped: string[] = [];
269
+ for (const m of g.members) {
270
+ const at = m.indexOf(':');
271
+ const kind = m.slice(0, at);
272
+ const name = m.slice(at + 1);
273
+ if (!isKind(kind) || kind === 'theme') continue;
274
+ const k = KINDS[kind];
275
+ if (!(await owner.one(`select 1 as ok from ${k.table} where app_id = $1 and ${k.key} = $2`, [masterId, name]))) {
276
+ skipped.push(m);
277
+ continue;
278
+ }
279
+ try {
280
+ await subscribe(appId, masterId, kind, name, username);
281
+ done.push(m);
282
+ } catch (e) {
283
+ if (!(e instanceof SubscriptionError)) throw e;
284
+ skipped.push(m);
285
+ }
286
+ }
287
+ return { done, skipped };
288
+ }