pgkiln 0.87.0 → 0.89.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.
@@ -561,6 +561,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
561
561
 
562
562
  const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
563
563
  html`<button class="btn btn-sm${hot ? ' btn-hot' : ''}" name="mode" value="${m}" title="${label} (${key})" data-ws-key="${key}">${icon(ic)}<span>${label}</span></button>`;
564
+ const runsAsText = `${tx('ws.runs_as', { who: String(runsAs) })}${searchSchema ? `; ${tx('ws.search_path_plain', { schema: searchSchema })}` : ''}`;
564
565
  const main = html`${chartCss ? html`<style nonce="${req.cspNonce}">${raw(chartCss.replace(/</g, ''))}</style>` : ''}<div class="ws-page">
565
566
  <h1 class="sr-only">${tx('ws.title')}</h1>
566
567
  ${workshopTabs('sql', s)}
@@ -615,20 +616,9 @@ async function worksheet(req: Req, reply: FastifyReply) {
615
616
  ${toolBtn('script', 'file', tx('ws.run_script'), 'F5')}
616
617
  ${toolBtn('explain', 'route', tx('ws.tab.explain'), 'F10')}
617
618
  ${toolBtn('autotrace', 'activity', tx('ws.tab.autotrace'), 'F6')}
618
- <label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> ${tx('ws.continue')}</label>
619
+ <button type="button" class="btn btn-sm btn-icon" data-ws-suggest title="${tx('ws.suggest_title')}" hidden>${icon('list')}<span class="sr-only">${tx('ws.suggest')}</span></button>
620
+ <button type="button" class="btn btn-sm btn-icon" data-ws-check title="${tx('ws.check_title')}" hidden>${icon('check-circle')}<span class="sr-only">${tx('ws.check')}</span></button>
619
621
  <span class="ws-spacer"></span>
620
- <button type="button" class="btn btn-sm btn-icon" data-ws-format title="${tx('ws.format_title')}" hidden>${icon('sliders')}<span class="sr-only">${tx('ws.format')}</span></button>
621
- <button type="button" class="btn btn-sm btn-icon" data-ws-case title="${tx('ws.case_title')}" hidden><span aria-hidden="true" class="ws-aa">Aa</span><span class="sr-only">${tx('ws.case')}</span></button>
622
- <button type="button" class="btn btn-sm btn-icon" data-ws-find title="${tx('ws.find_title')}" hidden>${icon('search')}<span class="sr-only">${tx('ws.find')}</span></button>
623
- <a class="btn btn-sm btn-icon" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="${tx('ws.load_title')}">${icon('upload')}<span class="sr-only">${tx('ws.load')}</span></a>
624
- <details class="menu ws-keys"><summary class="btn btn-sm btn-icon" title="${tx('ws.keys')}">?<span class="sr-only">${tx('ws.keys')}</span></summary>
625
- <div class="menu-panel align-right"><div class="menu-section"><dl class="ws-keylist">
626
- <dt>Ctrl+Enter</dt><dd>${tx('ws.run_statement')}</dd><dt>F5</dt><dd>${tx('ws.run_script')}</dd><dt>F10</dt><dd>${tx('ws.tab.explain')}</dd><dt>F6</dt><dd>${tx('ws.tab.autotrace')}</dd>
627
- <dt>Ctrl+S</dt><dd>${tx('ws.key.save')}</dd><dt>Ctrl+F7</dt><dd>${tx('ws.format')}</dd><dt>Ctrl+B</dt><dd>${tx('ws.case')}</dd><dt>Ctrl+F / Ctrl+H</dt><dd>${tx('ws.key.find')}</dd>
628
- <dt>Ctrl+G</dt><dd>${tx('ws.key.goto')}</dd><dt>Ctrl+/</dt><dd>${tx('ws.key.comment')}</dd><dt>Ctrl+Space</dt><dd>${tx('ws.key.suggest')}</dd><dt>F1</dt><dd>${tx('ws.key.help')}</dd><dt>Escape</dt><dd>${tx('ws.key.fullscreen')}</dd>
629
- </dl>
630
- <label class="ws-kwcase" hidden>${tx('ws.kwcase')} <select data-ws-kwcase><option value="lower">${tx('ws.lower')}</option><option value="upper">${tx('ws.upper')}</option></select></label>
631
- <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>${tx('ws.help')}</a></div></div></details>
632
622
  <span class="ws-txn" data-ws-txn data-state="${tState}" data-statements="${txn?.statements ?? 0}" data-started="${txn?.startedMs ?? ''}" data-last="${txn?.lastMs ?? ''}" data-idle="${txnIdleSeconds()}" data-note="${tNote ?? ''}" data-now="${Date.now()}" hidden>
633
623
  <label class="ws-runas"><span>${tx('ws.commit_label')}</span><select name="txn" data-ws-txn-mode title="${tx('ws.txn.mode_title')}">
634
624
  <option value="">${tx('ws.autocommit')}</option><option value="on"${txnOn ? raw(' selected') : ''}>${tx('ws.transaction')}</option></select></label>
@@ -636,17 +626,25 @@ async function worksheet(req: Req, reply: FastifyReply) {
636
626
  <button class="btn btn-sm" name="mode" value="commit" data-ws-txn-commit title="${tx('ws.txn.commit_title')}"${txn ? '' : raw(' disabled')}>${icon('check')}<span>${tx('ws.commit_label')}</span></button>
637
627
  <button class="btn btn-sm" name="mode" value="rollback" data-ws-txn-rollback title="${tx('ws.txn.rollback_title')}"${txn ? '' : raw(' disabled')}>${icon('undo')}<span>${tx('ws.rollback')}</span></button>
638
628
  </span>
639
- <label class="ws-runas"><span>${tx('ws.run_as')}</span>
640
- <select name="as_app" title="${tx('ws.run_as_title')}">
641
- <option value="">${db === owner ? tx('ws.owner_conn') : tx('ws.ws_login')}</option>
642
- ${apps.map((a) => html`<option value="${a.id}"${asApp?.id === a.id ? raw(' selected') : ''}>${a.name} (${a.db_role})</option>`)}
643
- </select></label>
644
- <button type="button" class="btn btn-sm btn-icon" data-ws-clear title="${tx('ws.clear_title')}" hidden>${icon('trash')}<span class="sr-only">${tx('ws.clear')}</span></button>
645
629
  <button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="${tx('ws.fullscreen_title')}" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">${tx('ws.fullscreen')}</span></button>
630
+ <!-- (UX-1) the rest of the tools in one menu, so the toolbar is one row and the editor keeps its height -->
631
+ <details class="menu ws-more"><summary class="btn btn-sm btn-icon" title="${tx('ws.more')}"><span aria-hidden="true" class="ws-dots">⋯</span><span class="sr-only">${tx('ws.more')}</span></summary>
632
+ <div class="menu-panel align-right"><div class="menu-section ws-more-tools">
633
+ <label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> ${tx('ws.continue')}</label>
634
+ <button type="button" class="ws-menu-item" data-ws-format title="${tx('ws.format_title')}" hidden>${icon('sliders')} ${tx('ws.format')} <kbd>Ctrl+F7</kbd></button>
635
+ <button type="button" class="ws-menu-item" data-ws-case title="${tx('ws.case_title')}" hidden><span aria-hidden="true" class="ws-aa">Aa</span> ${tx('ws.case')} <kbd>Ctrl+B</kbd></button>
636
+ <button type="button" class="ws-menu-item" data-ws-find title="${tx('ws.find_title')}" hidden>${icon('search')} ${tx('ws.find')} <kbd>Ctrl+F</kbd></button>
637
+ <a class="ws-menu-item" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="${tx('ws.load_title')}">${icon('upload')} ${tx('ws.load')}</a>
638
+ <button type="button" class="ws-menu-item" data-ws-clear title="${tx('ws.clear_title')}" hidden>${icon('trash')} ${tx('ws.clear')}</button>
639
+ </div>
640
+ <div class="menu-section"><strong class="ws-more-head">${tx('ws.keys')}</strong><dl class="ws-keylist">
641
+ <dt>Ctrl+Enter</dt><dd>${tx('ws.run_statement')}</dd><dt>F5</dt><dd>${tx('ws.run_script')}</dd><dt>F10</dt><dd>${tx('ws.tab.explain')}</dd><dt>F6</dt><dd>${tx('ws.tab.autotrace')}</dd>
642
+ <dt>Ctrl+S</dt><dd>${tx('ws.key.save')}</dd><dt>Ctrl+F7</dt><dd>${tx('ws.format')}</dd><dt>Ctrl+B</dt><dd>${tx('ws.case')}</dd><dt>Ctrl+F / Ctrl+H</dt><dd>${tx('ws.key.find')}</dd>
643
+ <dt>Ctrl+G</dt><dd>${tx('ws.key.goto')}</dd><dt>Ctrl+/</dt><dd>${tx('ws.key.comment')}</dd><dt>Ctrl+Space</dt><dd>${tx('ws.key.suggest')}</dd><dt>Tab</dt><dd>${tx('ws.key.indent')}</dd><dt>F1</dt><dd>${tx('ws.key.help')}</dd><dt>Escape</dt><dd>${tx('ws.key.fullscreen')}</dd>
644
+ </dl>
645
+ <label class="ws-kwcase" hidden>${tx('ws.kwcase')} <select data-ws-kwcase><option value="lower">${tx('ws.lower')}</option><option value="upper">${tx('ws.upper')}</option></select></label>
646
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>${tx('ws.help')}</a></div></div></details>
646
647
  </div>
647
- <p class="ws-runs-as muted small">${tx('ws.runs_as', { who: runsAs })}${searchSchema ? html`; ${tx.rich('ws.search_path', { schema: html`<code>${searchSchema}</code>`, change: html`<a href="${BASE}/sql">${tx('ws.change')}</a>` })}` : ''}. <span data-ws-commit-note>${txnOn
648
- ? tx('ws.commit.txn', { idle: idleLabel(tx) })
649
- : tx('ws.commit.auto')}</span></p>
650
648
  ${fileError ? html`<p class="alert alert-error ws-file-error" role="alert">${fileError}</p>` : ''}
651
649
  ${txnError ? html`<p class="alert alert-error ws-txn-alert" role="alert">${txnError}</p>` : txnInfo ? html`<p class="alert alert-info ws-txn-alert" role="status">${txnInfo}</p>` : ''}
652
650
  <div class="ws-wtabs" role="group" aria-label="${tx('ws.worksheets')}" hidden></div>
@@ -666,12 +664,23 @@ async function worksheet(req: Req, reply: FastifyReply) {
666
664
  <textarea id="ws-sql" name="sql" aria-label="SQL" class="code sql-editor" rows="14" spellcheck="false" data-code="plpgsql">${text || (isPost ? '' : 'select current_user, version();')}</textarea>
667
665
  </div>
668
666
  <div class="ws-split ws-split-h" role="separator" aria-orientation="horizontal" aria-label="${tx('ws.resize_out')}" tabindex="0" data-ws-split="out" hidden></div>
669
- <div class="ws-output" data-active="${active}">
667
+ <div class="ws-output" data-active="${active}"${!run && !req.query?.tab ? raw(' data-empty') : ''}>
670
668
  <div class="ws-tabs" role="tablist" aria-label="${tx('ws.output')}">${TABS.map(([k]) => html`<button type="button" role="tab" id="ws-tab-${k}" aria-controls="ws-pane-${k}" aria-selected="${k === active ? 'true' : 'false'}" data-ws-tab="${k}">${tx(`ws.tab.${k}`)}</button>`)}</div>
671
669
  ${TABS.map(([k]) => html`<section class="ws-pane" id="ws-pane-${k}" role="tabpanel" aria-labelledby="ws-tab-${k}" data-pane="${k}"><h2 class="ws-pane-title">${tx(`ws.tab.${k}`)}</h2>${panes[k]}</section>`)}
672
670
  </div>
673
671
  </section>
674
- <footer class="ws-status" role="status" aria-live="polite">${status}</footer>
672
+ <footer class="ws-status">
673
+ <span class="ws-status-run" role="status" aria-live="polite">${status}</span>
674
+ <!-- (UX-1) who the statements run as: a chip in the status bar; the explanation is its description -->
675
+ <label class="ws-runas ws-runas-chip" title="${runsAsText}"><span>${tx('ws.run_as')}</span>
676
+ <select name="as_app" aria-describedby="ws-runs-as">
677
+ <option value="">${db === owner ? tx('ws.owner_conn') : tx('ws.ws_login')}</option>
678
+ ${apps.map((a) => html`<option value="${a.id}"${asApp?.id === a.id ? raw(' selected') : ''}>${a.name} (${a.db_role})</option>`)}
679
+ </select></label>
680
+ <span class="sr-only" id="ws-runs-as">${tx('ws.runs_as', { who: runsAs })}${searchSchema ? html`; ${tx('ws.search_path_plain', { schema: searchSchema })}` : ''}. <span data-ws-commit-note>${txnOn
681
+ ? tx('ws.commit.txn', { idle: idleLabel(tx) })
682
+ : tx('ws.commit.auto')}</span></span>
683
+ </footer>
675
684
  </form>
676
685
  ${downloadable ? html`<form method="post" action="${BASE}/sql/unload" id="ws-dl" class="ws-dl-form">${csrf(s)}<input type="hidden" name="source" value="query"><input type="hidden" name="query" value="${downloadable}"><input type="hidden" name="header" value="1"><input type="hidden" name="bom" value="1"></form>` : ''}
677
686
  </div>`;
@@ -121,6 +121,22 @@ async function systemMessage(s: Session, admin: boolean) {
121
121
  return region(tx('wh.system'), html`${checks}${body}${admins}`, '', 'system-message');
122
122
  }
123
123
 
124
+ /** (UX-13) What an administrator must see first: lockout risks and a newer pgkiln, above the tools (details: System message). */
125
+ async function attention(s: Session, admin: boolean) {
126
+ if (!admin) return '';
127
+ const tx = tr(s);
128
+ const lockout = await attentionCount();
129
+ const check = await releaseCheckEnabled();
130
+ const r = check.on ? cachedRelease()?.release : null;
131
+ const newer = r && newerVersion(r.version, VERSION) ? r : null;
132
+ const items = [
133
+ lockout ? html`<li>${icon('alert')} ${tx('wh.lockout', { count: lockout })} <a href="${BASE}/instance#lockout-checks">${tx('wh.lockout_open')}</a></li>` : '',
134
+ newer ? html`<li>${icon('bell')} ${tx('wh.available', { version: newer.version })} <a href="#system-message">${tx('wh.release_notes')}</a></li>` : '',
135
+ ].filter(Boolean);
136
+ return items.length ? html`<section class="alert alert-warning wh-attention" role="status" aria-labelledby="wh-attention-h">
137
+ <h2 id="wh-attention-h">${tx('wh.attention')}</h2><ul>${items}</ul></section>` : '';
138
+ }
139
+
124
140
  /** (Instance settings page) the release check: on, off, or as the environment says. */
125
141
  export async function releaseCheckRegion(s: Session) {
126
142
  const check = await releaseCheckEnabled();
@@ -194,6 +210,7 @@ export async function workspaceHomeRoutes(app: FastifyInstance) {
194
210
  const main = html`<div class="ab-home wh-home">
195
211
  <div class="ab-main">
196
212
  <h1 class="wh-title">${s.workspace ? tx('wh.title_ws', { name: s.workspace.name }) : tx('wh.title')}</h1>
213
+ ${await attention(s, admin)}
197
214
  <nav class="ab-tiles ab-tiles-wrap wh-tools" aria-label="${tx('wh.tools')}">
198
215
  ${card(`${BASE}/apps`, 'grid', tx('rail.app_builder'), tx('wh.card.apps'))}
199
216
  ${sql ? card(`${BASE}/sql`, 'database', tx('rail.sql_workshop'), tx('wh.card.sql')) : ''}
package/src/metadata.ts CHANGED
@@ -99,6 +99,8 @@ export interface App {
99
99
  pwa_short_name: string | null;
100
100
  pwa_has_icon: boolean;
101
101
  pwa_offline_pages: boolean;
102
+ /** (168) fetch every navigation page for offline use, again after this many hours; null: off */
103
+ pwa_prefetch_hours: number | null;
102
104
  pwa_offline_submit: boolean;
103
105
  /** (074) users may turn on push notifications (src/push.ts) */
104
106
  pwa_push: boolean;
@@ -448,7 +450,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
448
450
  // No caching on purpose: edits made in the builder show up on the next request.
449
451
  export async function loadApp(alias: string) {
450
452
  return runtime.one<App>(
451
- `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
453
+ `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_prefetch_hours, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
452
454
  a.availability, a.availability_message, a.availability_users, a.feedback, a.notifications, a.live_badges_s, a.error_function, a.session_warning_seconds, a.component_settings,
453
455
  a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
454
456
  coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
@@ -38,9 +38,14 @@ export function pwaHead(a: Pick<App, 'alias' | 'pwa' | 'theme'>): Raw | '' {
38
38
  }
39
39
 
40
40
  /** <body> data attributes app.js uses: register the service worker, queue offline submissions. */
41
- export function pwaBody(a: Pick<App, 'alias' | 'pwa' | 'pwa_offline_submit'>, user: string): Record<string, string> {
41
+ export function pwaBody(a: Pick<App, 'alias' | 'pwa' | 'pwa_offline_submit' | 'pwa_offline_pages' | 'pwa_prefetch_hours'>, user: string): Record<string, string> {
42
42
  if (!a.pwa) return {};
43
- return { 'data-sw': `/a/${a.alias}/sw.js`, ...(a.pwa_offline_submit ? { 'data-offline-queue': '1', 'data-user': user } : {}) };
43
+ return {
44
+ 'data-sw': `/a/${a.alias}/sw.js`,
45
+ ...(a.pwa_offline_submit ? { 'data-offline-queue': '1', 'data-user': user } : {}),
46
+ // (168) app.js hands the navigation's pages to the service worker, which keeps them for offline use
47
+ ...(a.pwa_offline_pages && a.pwa_prefetch_hours ? { 'data-prefetch': String(a.pwa_prefetch_hours) } : {}),
48
+ };
44
49
  }
45
50
 
46
51
  // ------------------------------------------------------------------ icons (PNG, no image library)
@@ -175,7 +180,7 @@ export async function pwaRoutes(app: FastifyInstance) {
175
180
  const base = `/a/${a.alias}`;
176
181
  // the settings the worker needs, as a JSON literal (no user input in it)
177
182
  const version = staticVersion();
178
- const config = JSON.stringify({ base, offlinePages: a.pwa_offline_pages, offlineSubmit: a.pwa_offline_submit, version });
183
+ const config = JSON.stringify({ base, offlinePages: a.pwa_offline_pages, prefetchHours: a.pwa_offline_pages ? a.pwa_prefetch_hours : null, offlineSubmit: a.pwa_offline_submit, version });
179
184
  return reply
180
185
  .type('text/javascript; charset=utf-8')
181
186
  .header('cache-control', 'no-cache')
@@ -444,24 +444,37 @@ export async function runtimeRoutes(app: FastifyInstance) {
444
444
  const normalised = normaliseReportParams(ctx.params);
445
445
  if (normalised !== null) return reply.redirect(`${ctx.base}/${ctx.page.page_no}${normalised ? `?${normalised}` : ''}`);
446
446
 
447
+ // (168) the service worker fetching the page for offline use (pwa_prefetch_hours): the page runs read-only,
448
+ // its session state isn't kept and nothing waiting for the next page (a message) is taken; a page that runs
449
+ // processes when it opens is refused (kept only when the user opens it)
450
+ const prefetch = req.headers['x-pgkiln-prefetch'] === '1';
451
+ if (prefetch && (ctx.dialog || ctx.page.processes.some((p) => p.point === 'load')))
452
+ return reply.code(409).header('cache-control', 'no-store').send('Not kept for offline use: this page runs processes when it opens.');
453
+
447
454
  if (!applyUrlItems(ctx)) return forbidden(ctx, reply, ctx.locale.t('error.checksum'), `checksum error: ${req.url}`);
448
455
  // a master grid's selected row (signed link) into its item
449
456
  applyRowSelection(ctx);
450
- const flash = takeFlash(ctx.session);
451
- if (flash) ctx.messages.push(flash);
452
- const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
453
- if (flashError) ctx.errors.page.push(flashError);
454
- const saved = takeFlash(ctx.session, '__GRID_SAVED');
455
- if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
457
+ if (!prefetch) {
458
+ const flash = takeFlash(ctx.session);
459
+ if (flash) ctx.messages.push(flash);
460
+ const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
461
+ if (flashError) ctx.errors.page.push(flashError);
462
+ const saved = takeFlash(ctx.session, '__GRID_SAVED');
463
+ if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
464
+ }
456
465
  // Actions → Download CSV / Excel / PDF: r<region id>_csv=1, _xlsx=1 or _pdf=1
457
466
  const downloadKey = [...ctx.params.keys()].find((k) => /^r\d+_(csv|xlsx|pdf)$/.test(k));
458
467
  // a document template: ?doc=NAME (see documents.ts)
459
468
  const docName = ctx.params.get('doc');
460
469
 
470
+ if (prefetch && (docName || downloadKey)) return reply.code(409).header('cache-control', 'no-store').send('Not kept for offline use.');
471
+
461
472
  let result: { html?: string; streamed?: boolean; file?: Buffer; type?: string; name?: string; redirect?: string; download?: Download };
462
473
  try {
463
474
  result = await appTx(txContext(ctx), async (c) => {
464
475
  ctx.client = c;
476
+ // (168) a prefetch writes nothing: an application process or region that tries fails
477
+ if (prefetch) await c.query('set local transaction_read_only = on');
465
478
  await timed(ctx, 4, 'page', 'page authorization', () => checkPageAccess(ctx));
466
479
  await timed(ctx, 4, 'page', 'application processes (before page)', () => runAppProcesses(ctx, 'before_page'));
467
480
  // (086) items kept per user: their values from the user's preferences
@@ -516,6 +529,12 @@ export async function runtimeRoutes(app: FastifyInstance) {
516
529
  if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `page ${ctx.page.page_no}`);
517
530
  throw e;
518
531
  }
532
+ if (prefetch) {
533
+ // a redirect (a branch, a sign-in) or anything but the page itself is not kept on the device
534
+ logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'page_prefetch', ip: ctx.ip, elapsedMs: Math.round(performance.now() - started) });
535
+ if (result.html === undefined) return reply.code(409).header('cache-control', 'no-store').send('Not kept for offline use.');
536
+ return reply.type('text/html').header('cache-control', 'private, no-store').send(result.html);
537
+ }
519
538
  await saveState(ctx.session);
520
539
  if (result.redirect) return reply.redirect(result.redirect, 303);
521
540
  logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'page_view', ip: ctx.ip, elapsedMs: Math.round(performance.now() - started) });
package/src/session.ts CHANGED
@@ -36,6 +36,8 @@ export interface Session {
36
36
  /** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
37
37
  workspaces?: BuilderWorkspace[];
38
38
  workspace?: BuilderWorkspace | null;
39
+ /** Builder only (UX-3): this request's path, so the rail marks the right section */
40
+ path?: string;
39
41
  /** Builder only (sprint 99): an administrator of the installation (the Administration menu's Database users) */
40
42
  instanceAdmin?: boolean;
41
43
  /** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */