pgkiln 0.49.0 → 0.51.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 (70) hide show
  1. package/SECURITY.md +3 -0
  2. package/db/migrations/119_workspace_home.sql +30 -0
  3. package/db/migrations/120_datasets.sql +87 -0
  4. package/docs/apex-feature-parity.md +7 -6
  5. package/docs/guide/01-installation.md +5 -0
  6. package/docs/guide/03-builder.md +140 -11
  7. package/docs/guide/04-pages-and-regions.md +25 -1
  8. package/docs/guide/12-development.md +47 -11
  9. package/examples/hr/hr_63_datasets.sql +29 -0
  10. package/package.json +1 -1
  11. package/public/app.css +6 -0
  12. package/public/builder.css +95 -3
  13. package/public/code-editor.css +32 -79
  14. package/public/code-editor.js +58 -3
  15. package/public/data-modeler.css +17 -14
  16. package/public/worksheet.css +14 -7
  17. package/public/worksheet.js +119 -3
  18. package/src/app.ts +5 -1
  19. package/src/builder/changelog.ts +1 -1
  20. package/src/builder/data-modeler.ts +1 -1
  21. package/src/builder/datasets.ts +472 -0
  22. package/src/builder/db-users.ts +2 -2
  23. package/src/builder/home.ts +12 -7
  24. package/src/builder/instance.ts +2 -0
  25. package/src/builder/object-browser.ts +4 -4
  26. package/src/builder/querybuilder.ts +1 -1
  27. package/src/builder/quicksql.ts +1 -1
  28. package/src/builder/reporter.ts +52 -3
  29. package/src/builder/routes.ts +3 -3
  30. package/src/builder/scheduling.ts +2 -2
  31. package/src/builder/scripts.ts +1 -1
  32. package/src/builder/sql-charts.ts +1 -1
  33. package/src/builder/sql-monitor.ts +1 -1
  34. package/src/builder/subscriptions.ts +1 -0
  35. package/src/builder/table-editor.ts +2 -2
  36. package/src/builder/ui.ts +7 -5
  37. package/src/builder/worksheet.ts +171 -23
  38. package/src/builder/workspace-home.ts +283 -0
  39. package/src/builder/workspaces.ts +7 -4
  40. package/src/datasets.ts +367 -0
  41. package/src/i18n/ar.ts +16 -0
  42. package/src/i18n/cs.ts +16 -0
  43. package/src/i18n/da.ts +16 -0
  44. package/src/i18n/de.ts +16 -0
  45. package/src/i18n/el.ts +16 -0
  46. package/src/i18n/es.ts +16 -0
  47. package/src/i18n/fi.ts +16 -0
  48. package/src/i18n/fr.ts +16 -0
  49. package/src/i18n/he.ts +16 -0
  50. package/src/i18n/it.ts +16 -0
  51. package/src/i18n/ja.ts +16 -0
  52. package/src/i18n/ko.ts +16 -0
  53. package/src/i18n/nb.ts +16 -0
  54. package/src/i18n/pl.ts +16 -0
  55. package/src/i18n/pt.ts +16 -0
  56. package/src/i18n/ru.ts +16 -0
  57. package/src/i18n/sv.ts +16 -0
  58. package/src/i18n/tr.ts +16 -0
  59. package/src/i18n/uk.ts +16 -0
  60. package/src/i18n/zh.ts +16 -0
  61. package/src/i18n.ts +32 -0
  62. package/src/instance.ts +1 -0
  63. package/src/release-check.ts +139 -0
  64. package/src/reporting-app.ts +101 -0
  65. package/src/runtime/data-reporter.ts +199 -32
  66. package/src/session.ts +7 -0
  67. package/src/sqlworksheet.ts +9 -6
  68. package/src/subscriptions.ts +2 -1
  69. package/src/worksheet-txn.ts +270 -0
  70. package/src/workspace-export.ts +24 -4
@@ -361,7 +361,7 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
361
361
  </form>`;
362
362
 
363
363
  const output = built
364
- ? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
364
+ ? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
365
365
  <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${built.sql}">
366
366
  <div class="buttons"><button class="btn btn-hot">${icon('play')} Run in the SQL Worksheet</button></div></form>`)}`
367
367
  : '';
@@ -43,7 +43,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
43
43
  ${region('PostgreSQL', html`${warnings.length
44
44
  ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${warnings.map((w) => html`<li>Line ${w.line}: ${w.message}</li>`)}</ul></div>`
45
45
  : ''}
46
- <pre class="source ddl" tabindex="0" aria-label="Generated SQL">${ddl}</pre>`)}
46
+ <pre class="source ddl" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${ddl}</pre>`)}
47
47
  </div>
48
48
  <div class="u-spacer"></div>
49
49
  ${region('Save or run', html`<div class="form-grid"><div class="field"><label class="label" for="f_name">Script name</label>
@@ -65,7 +65,7 @@ const clip = (v: string | undefined, max: number) => (v ?? '').trim().slice(0, m
65
65
  * except binary ones.
66
66
  */
67
67
  export async function mergeReporterSettings(
68
- config: Config, b: Body, a: { authz: Set<string> },
68
+ config: Config, b: Body, a: { authz: Set<string>; datasets?: Set<string> },
69
69
  lookup: { byName: (schema: string, table: string) => Promise<DbObject | null>; byOid: (oid: number) => Promise<DbObject | null> } = { byName: findObject, byOid: objectByOid },
70
70
  ): Promise<{ config: Config; errors: string[] }> {
71
71
  const out: Config = { ...config };
@@ -80,6 +80,13 @@ export async function mergeReporterSettings(
80
80
  delete out.share_authz;
81
81
  if (b.sharing === 'off') out.sharing = false;
82
82
  else if (b.sharing?.startsWith('authz:') && a.authz.has(b.sharing.slice(6).toUpperCase())) out.share_authz = b.sharing.slice(6);
83
+ // (sprint 110) who may edit reports, who may unpublish and delete anyone's, published reports
84
+ for (const k of ['edit_authz', 'admin_authz'] as const) {
85
+ delete out[k];
86
+ if (b[k] && a.authz.has(b[k]!.toUpperCase())) out[k] = b[k];
87
+ }
88
+ delete out.publishing;
89
+ if (b.publishing === 'true') out.publishing = true;
83
90
 
84
91
  const sources: Source[] = [];
85
92
  const existing = sourcesOf({ config });
@@ -87,6 +94,13 @@ export async function mergeReporterSettings(
87
94
  const s = existing.find((x) => x.id === b[`s${i}_key`]);
88
95
  if (!s || sources.some((x) => x.id === s.id)) continue;
89
96
  if (b[`s${i}_remove`] === 'true') continue;
97
+ if (s.dataset) {
98
+ // (sprint 110) a dataset: its columns are the dataset's (SQL Workshop → Data Reporter); label and description may differ here
99
+ const label = clip(b[`s${i}_label`], 80);
100
+ const description = clip(b[`s${i}_description`], 300);
101
+ sources.push({ id: s.id, dataset: s.dataset, ...(label ? { label } : {}), ...(description ? { description } : {}) } as Source);
102
+ continue;
103
+ }
90
104
  const obj = await lookup.byName(s.schema, s.table);
91
105
  const names = new Set(obj?.columns.map((c) => c.name));
92
106
  const columns: SourceColumn[] = [];
@@ -129,6 +143,17 @@ export async function mergeReporterSettings(
129
143
  sources.push({ id, label, schema: obj.schema, table: obj.table, columns });
130
144
  }
131
145
  }
146
+ // (sprint 110) a dataset of the workspace as a new source
147
+ if (b.new_dataset) {
148
+ const name = b.new_dataset.trim().toLowerCase();
149
+ if (!a.datasets?.has(name)) errors.push('Choose one of the workspace\'s datasets.');
150
+ else if (sources.length >= MAX_SOURCES) errors.push(`A region offers at most ${MAX_SOURCES} data sources.`);
151
+ else {
152
+ let id = name.slice(0, 36);
153
+ for (let n = 2; sources.some((s) => s.id === id); n++) id = `${name.slice(0, 36)}_${n}`;
154
+ sources.push({ id, dataset: name } as Source);
155
+ }
156
+ }
132
157
  if (sources.length) out.sources = sources;
133
158
  else delete out.sources;
134
159
  return { config: out, errors };
@@ -145,8 +170,23 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
145
170
  const objects = await objectList(app?.db_role ?? null);
146
171
  const sources = sourcesOf({ config: cfg });
147
172
  const sharing = cfg.sharing === false ? 'off' : typeof cfg.share_authz === 'string' && cfg.share_authz ? `authz:${cfg.share_authz}` : '';
173
+ const datasets = (await owner.query<{ name: string; label: string }>('select name, label from meta.dataset where workspace_id = meta.app_workspace($1) order by name', [appId])).rows;
148
174
  const sourceSets = await Promise.all(
149
175
  sources.map(async (src, i) => {
176
+ if (src.dataset) {
177
+ const d = datasets.find((x) => x.name === src.dataset);
178
+ return html`<fieldset class="prop-group"><legend>${src.label || d?.label || src.id} <span class="muted">(dataset ${src.dataset})</span></legend>
179
+ <input type="hidden" name="s${i}_key" value="${src.id}">
180
+ ${!d ? html`<div class="alert alert-error" role="alert">The workspace has no dataset ${src.dataset} (any more).</div>` : ''}
181
+ <div class="form-grid">
182
+ <div class="field"><label class="label" for="${id(`s${i}_id`)}">Static id</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
183
+ <div class="field"><label class="label" for="${id(`s${i}_label`)}">Label</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${d?.label ?? ''}"></div>
184
+ <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">Description (shown to users)</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}" placeholder="the dataset's"></div>
185
+ </div>
186
+ <p class="muted">Its tables, columns, labels, masks and row filter are the dataset's: <a href="${BASE}/reporter">App Builder → Data Reporter</a>.</p>
187
+ <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> Remove this data source (users' reports on it stop working)</label></div>
188
+ </fieldset>`;
189
+ }
150
190
  const obj = await findObject(src.schema, src.table);
151
191
  const offered = new Map(src.columns.map((c) => [c.name, c]));
152
192
  const all = [...(obj?.columns ?? []).map((c) => ({ name: c.name, type: c.type, missing: false })),
@@ -184,6 +224,12 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
184
224
  <select id="${id('sharing')}" name="sharing">${opt('', 'Every signed-in user', sharing)}${opt('off', 'Nobody (private reports only)', sharing)}${schemes.map((n) => opt(`authz:${n}`, `Authorization: ${n}`, sharing))}</select></div>
185
225
  <div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
186
226
  <input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
227
+ <div class="field"><label class="label" for="${id('edit_authz')}">Who may build and save reports</label>
228
+ <select id="${id('edit_authz')}" name="edit_authz">${opt('', 'Every user', cfg.edit_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.edit_authz ?? ''))}</select>
229
+ <small class="help">Others only run shared reports.</small></div>
230
+ <div class="field"><label class="label" for="${id('admin_authz')}">Who may unpublish and delete anyone's reports</label>
231
+ <select id="${id('admin_authz')}" name="admin_authz">${opt('', 'Nobody', cfg.admin_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.admin_authz ?? ''))}</select></div>
232
+ <div class="field"><label class="check"><input type="checkbox" name="publishing" value="true"${cfg.publishing === true ? raw(' checked') : ''}> Shared reports are published: a stable URL, pins</label></div>
187
233
  <div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
188
234
  <input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
189
235
  </div></fieldset>
@@ -194,7 +240,9 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
194
240
  <div class="field"><label class="label" for="${id('new_id')}">Static id</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="from the name"></div>
195
241
  <div class="field"><label class="label" for="${id('new_label')}">Label</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
196
242
  </div>
197
- <small class="help">All columns are offered at first (except binary ones); untick those users should not see. Prefer a view that shows exactly what business users may report on.</small></fieldset>
243
+ <small class="help">All columns are offered at first (except binary ones); untick those users should not see. Prefer a view that shows exactly what business users may report on.</small>
244
+ ${datasets.length ? html`<div class="form-grid"><div class="field" data-wide><label class="label" for="${id('new_dataset')}">Or a dataset of the workspace</label>
245
+ <select id="${id('new_dataset')}" name="new_dataset"><option value="">- none -</option>${datasets.map((d) => html`<option value="${d.name}">${d.label} (${d.name})</option>`)}</select></div></div>` : ''}</fieldset>
198
246
  <div class="buttons"><button class="btn btn-hot">Save Data Reporter settings</button></div>
199
247
  </form>`;
200
248
  }
@@ -210,7 +258,8 @@ export async function reporterBuilderRoutes(app: FastifyInstance) {
210
258
  : undefined;
211
259
  if (!r) return reply.code(404).send('Not found');
212
260
  const schemes = (await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1', [r.app_id])).rows;
213
- const { config, errors } = await mergeReporterSettings(r.config ?? {}, (req.body ?? {}) as Body, { authz: new Set(schemes.map((x) => x.name.toUpperCase())) });
261
+ const datasets = (await owner.query<{ name: string }>('select name from meta.dataset where workspace_id = meta.app_workspace($1)', [r.app_id])).rows;
262
+ const { config, errors } = await mergeReporterSettings(r.config ?? {}, (req.body ?? {}) as Body, { authz: new Set(schemes.map((x) => x.name.toUpperCase())), datasets: new Set(datasets.map((x) => x.name)) });
214
263
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
215
264
  if (errors.length) flash(s, errors.join(' '), 'error');
216
265
  else flash(s, 'Settings saved.');
@@ -43,11 +43,11 @@ export async function builderRoutes(app: FastifyInstance) {
43
43
  }
44
44
  };
45
45
  /** Where to go after signing in: a builder page only. */
46
- const safeNext = (v: unknown) => (typeof v === 'string' && /^\/builder(\/[A-Za-z0-9_/-]{0,200})?$/.test(v) ? v : BASE);
46
+ const safeNext = (v: unknown) => (typeof v === 'string' && /^\/builder(\/[A-Za-z0-9_/-]{0,200})?$/.test(v) ? v : `${BASE}/home`);
47
47
 
48
48
  app.get(`${BASE}/login`, async (req: Req, reply) => {
49
49
  const s = await getSession(req, reply, null, BASE);
50
- if (s.username) return reply.redirect(BASE);
50
+ if (s.username) return reply.redirect(`${BASE}/home`);
51
51
  const error = takeFlash(s, '__ERROR');
52
52
  await saveState(s);
53
53
  const r = remembered(req);
@@ -99,7 +99,7 @@ export async function builderRoutes(app: FastifyInstance) {
99
99
  const { username = '', password = '' } = req.body ?? {};
100
100
  const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
101
101
  const next = safeNext(req.body?.next);
102
- const again = next === BASE ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
102
+ const again = next === `${BASE}/home` ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
103
103
  const ip = clientIp(req);
104
104
  if (req.body?.__csrf !== s.csrf_token) {
105
105
  flash(s, 'Your session expired. Please try again.', 'error');
@@ -237,7 +237,7 @@ const hiddenSpec = (spec: JobSpec, jobid: string | null) => html`<input type="hi
237
237
  /** The review step of any change: the SQL, Apply (with the digest), Back. */
238
238
  function reviewRegion(s: Session, stmts: string[], action: string, hidden: Raw, o: { back?: Raw | ''; apply?: string; danger?: boolean; extra?: Raw | '' } = {}) {
239
239
  return html`${region('SQL', stmts.length
240
- ? html`<p class="muted u-mt0">These statements run in one transaction on pgkiln's owner connection when you apply them: all of them, or none.</p><pre class="source cron-sql" aria-label="Generated SQL">${stmts.join('\n\n')}</pre>`
240
+ ? html`<p class="muted u-mt0">These statements run in one transaction on pgkiln's owner connection when you apply them: all of them, or none.</p><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${stmts.join('\n\n')}</pre>`
241
241
  : html`<p class="muted u-mt0">Nothing to change.</p>`)}
242
242
  <form method="post" action="${action}" class="cron-form">${csrf(s)}${hidden}<input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">${o.extra ?? ''}
243
243
  <div class="buttons">${stmts.length ? html`<button class="btn ${o.danger ? 'btn-danger' : 'btn-hot'}" name="mode" value="apply">${icon(o.danger ? 'trash' : 'check')} ${o.apply ?? 'Apply'}</button>` : ''}${o.back ?? ''}</div></form>`;
@@ -320,7 +320,7 @@ async function jobPage(reply: FastifyReply, s: Session, st: Extract<CronState, {
320
320
  <dt>State</dt><dd>${job.active ? 'Active' : html`<span class="tag">inactive</span>`}</dd>
321
321
  <dt>Schedule</dt><dd><code>${job.schedule}</code></dd>
322
322
  <dt>Database</dt><dd>${job.database}</dd><dt>Run as</dt><dd>${job.username}</dd><dt>Job id</dt><dd>${job.jobid}</dd>
323
- </dl><pre class="source cron-sql" aria-label="Command">${job.command}</pre>`)}
323
+ </dl><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="Command">${job.command}</pre>`)}
324
324
  ${region('Schedule', schedulePreview(job.schedule, st.timeZone))}
325
325
  ${region('Recent runs', html`${runsTable(runs, false)}<p class="small"><a href="${URL_BASE}/history?job=${job.jobid}">All runs of this job</a> · <a href="${URL_BASE}/history/purge?job=${job.jobid}">Purge its history</a></p>`)}`;
326
326
  return send(reply, s, shell(s, name, crumbs([name]), main, 'sql'));
@@ -92,7 +92,7 @@ export function resultsHtml(results: StatementResult[]) {
92
92
  (r) => html`<li class="script-result">
93
93
  <div class="script-result-head">${statusTag(r)} <strong>#${r.n || '-'}</strong>
94
94
  <span class="muted small">${r.line ? `line ${r.line}` : ''}${r.status === 'ok' ? ` · ${r.command ?? 'done'}${r.rows !== null && r.rows !== undefined ? `, ${r.rows} row(s)` : ''}` : ''}${r.status === 'ok' || r.status === 'error' ? ` · ${r.ms} ms` : ''}</span></div>
95
- <details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source">${r.sql}</pre></details>
95
+ <details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="Statement">${r.sql}</pre></details>
96
96
  ${r.error ? html`<div class="alert ${r.status === 'error' ? 'alert-error' : 'alert-note'}">${r.error}</div>` : ''}
97
97
  ${r.columns?.length
98
98
  ? html`<div class="table-wrap"><table class="report"><thead><tr>${r.columns.map((c) => html`<th>${c}</th>`)}</tr></thead>
@@ -130,7 +130,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
130
130
  <div class="field"><label class="label" for="ch-name">Name</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
131
131
  ${controls}
132
132
  <label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> On the Charts dashboard</label>
133
- <details class="u-mt1"><summary>The query</summary><pre class="source">${ch.sql}</pre></details>
133
+ <details class="u-mt1"><summary>The query</summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="The query">${ch.sql}</pre></details>
134
134
  <div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} Save</button>
135
135
  <button class="btn" name="action" value="worksheet">${icon('code')} Open in the worksheet</button>
136
136
  <button class="btn btn-danger" name="action" value="delete" data-confirm="Delete the chart ${ch.name}?">${icon('trash')} Delete</button></div>
@@ -369,7 +369,7 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
369
369
  const main = html`<h1 class="u-mb1">Statement</h1>
370
370
  <div class="stat-grid u-mb1">${tile('Calls', num(calls))}${tile('Total ms', num(total))}${tile('Mean ms', num(calls ? total / calls : 0))}${tile('Rows', num(sum('rows')))}
371
371
  ${tile('Blocks hit', num(sum('hit')))}${tile('Blocks read', num(sum('read')))}${tile('Temp blocks written', num(sum('temp_written')))}</div>
372
- ${region('Text', html`<pre class="source u-break sm-sql">${sql}</pre>
372
+ ${region('Text', html`<pre class="source u-break sm-sql" data-code-view="sql" tabindex="0" aria-label="SQL text">${sql}</pre>
373
373
  <div class="buttons"><form method="post" action="${URL_BASE}/statements/${queryid}/worksheet">${csrf(s)}<button class="btn">${icon('code')} Open in worksheet</button></form>
374
374
  <a class="btn" href="${URL_BASE}/top">Back to Top SQL</a></div>`)}
375
375
  ${region('By role', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Role</th><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Std dev ms</th><th class="num">Min ms</th><th class="num">Max ms</th><th class="num">Rows</th><th class="num">Hit</th><th class="num">Read</th><th class="num">Temp read</th><th class="num">Temp written</th></tr></thead>
@@ -23,6 +23,7 @@ const TYPE_HELP: Record<AppType, string> = {
23
23
  theme: 'Its theme (colours, navigation, Theme Roller styles) and template components are offered to other applications.',
24
24
  library: 'Its lists of values, authorization schemes, build options, template components and lists are offered to other applications.',
25
25
  boilerplate: 'A starting point: Create application can copy it.',
26
+ reporting: 'Made by App Builder → Data Reporter on datasets of the workspace: its users build, publish and share reports.',
26
27
  };
27
28
 
28
29
  const what = (kind: Kind, name: string) => (kind === 'theme' ? 'Theme' : `${KINDS[kind].label} ${name}`);
@@ -121,7 +121,7 @@ function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schem
121
121
  <div class="field"><label class="label" for="te-part_by">Partition by</label><select id="te-part_by" name="part_by"><option value="">Not partitioned</option>${(['range', 'list', 'hash'] as const).map((p) => html`<option value="${p}"${sel(p, def.partitionBy)}>${p}</option>`)}</select></div>
122
122
  <div class="field"><label class="label" for="te-part_key">Key</label>${input('part_key', def.partitionKey, 'Key', 'class="code" placeholder="created_at"')}</div></div>
123
123
  <small class="help">The partitions themselves are tables made with CREATE TABLE … PARTITION OF in the worksheet.</small></fieldset>`}`],
124
- ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
124
+ ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
125
125
  ['output', 'Output', opts.output || html`<p class="muted">Apply shows what happened here.</p>`],
126
126
  ];
127
127
  const tab = sections.some(([k]) => k === opts.tab) ? opts.tab : opts.errors.length ? 'columns' : 'columns';
@@ -203,7 +203,7 @@ async function page(req: Req, reply: FastifyReply) {
203
203
  const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
204
204
  return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
205
205
  }
206
- output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source">${at}</pre></div>`;
206
+ output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
207
207
  tab = 'output';
208
208
  }
209
209
  if (errors.length) tab = 'columns';
package/src/builder/ui.ts CHANGED
@@ -46,7 +46,7 @@ export async function send(reply: FastifyReply, s: Session, body: string) {
46
46
  return reply.type('text/html').send(body);
47
47
  }
48
48
 
49
- export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
49
+ export type Section = 'home' | 'apps' | 'users' | 'sql' | 'developers' | 'gallery' | 'reporter';
50
50
 
51
51
  // ------------------------------------------------------------------ builder head
52
52
  // Builder-only stylesheets and scripts, loaded on every builder page after
@@ -105,21 +105,23 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
105
105
  const rail = (key: Section | 'search' | 'help', href: string, ic: string, label: string, extra: Raw | '' = '') =>
106
106
  html`<li><a href="${href}" class="rail-link" title="${label}"${section === key ? raw(' aria-current="page"') : ''}${extra}>${bicon(ic)}<span class="rail-label">${label}</span></a></li>`;
107
107
  const back = [...crumbs].reverse().find(([, href], i) => i > 0 && href);
108
- // (sprint 74) the default-password warning: on the App Builder home and the Developers page only, until
108
+ // (sprint 74) the default-password warning: on the Workspace home (sprint 109), the App Builder home and the Developers page only, until
109
109
  // dismissed (for this sign-in); a dot on the avatar stays until the password is changed
110
110
  const weak = !!s.state.__WEAK;
111
- const weakBanner = weak && !s.state.__WEAK_HIDDEN && crumbs.length === 1 && (crumbs[0][0] === 'App Builder' || crumbs[0][0] === 'Developers');
111
+ const weakBanner = weak && !s.state.__WEAK_HIDDEN && crumbs.length === 1 && (crumbs[0][0] === 'Home' || crumbs[0][0] === 'App Builder' || crumbs[0][0] === 'Developers');
112
112
  const themeButton = (t: BuilderTheme, ic: string, label: string) =>
113
113
  html`<button name="theme" value="${t}" aria-pressed="${theme === t ? 'true' : 'false'}">${bicon(ic)}<span>${label}</span></button>`;
114
114
  return documentShell(
115
115
  `${title} · Builder`,
116
116
  html`<a class="skip-link" href="#main">Skip to content</a>
117
117
  <nav class="ide-rail" aria-label="Builder">
118
- <a class="rail-logo" href="${BASE}" title="pgkiln Builder">${brandMark()}<span class="sr-only">pgkiln Builder home</span></a>
118
+ <a class="rail-logo" href="${BASE}/home" title="pgkiln Builder">${brandMark()}<span class="sr-only">pgkiln Builder home</span></a>
119
119
  <ul class="rail-list">
120
- ${rail('apps', BASE, 'builder', 'App Builder')}
120
+ ${rail('home', `${BASE}/home`, 'home', 'Home')}
121
+ ${rail('apps', `${BASE}/apps`, 'builder', 'App Builder')}
121
122
  ${s.workspace?.sql_workshop === false ? '' : rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
122
123
  ${rail('gallery', `${BASE}/gallery`, 'package', 'App Gallery')}
124
+ ${rail('reporter', `${BASE}/reporter`, 'pie-chart', 'Data Reporter')}
123
125
  ${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', 'Search this application') : ''}
124
126
  </ul>
125
127
  <ul class="rail-list rail-bottom">
@@ -17,6 +17,7 @@ import { formatSql } from '../sqlformat.ts';
17
17
  import { planView } from './plan-view.ts';
18
18
  import { unloadStatement } from '../unload.ts';
19
19
  import { recordChanges } from '../changelog.ts';
20
+ import { accountRun, beginTxn, endAllTxns, endTxn, findTxn, outsideTxnOnly, txnIdleSeconds, txnNote, txnState, clearTxnNote, type WorksheetTxn } from '../worksheet-txn.ts';
20
21
 
21
22
  // (sprint 91, Database Actions DA-1/DA-2) The SQL Worksheet: one screen with a navigator (schema, object
22
23
  // type, filter; tables and views open to their columns and give SELECT / INSERT / UPDATE / DELETE
@@ -217,6 +218,47 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
217
218
  return { panes: { result, chart: chart ?? html`<p class="muted">Create Chart (Ctrl+F11) draws the last query: a label column and number columns.</p>`, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
218
219
  }
219
220
 
221
+ /** (sprint 107) The idle timeout in words. */
222
+ const idleLabel = () => {
223
+ const sec = txnIdleSeconds();
224
+ return sec % 60 ? `${sec} seconds` : `${sec / 60} minute${sec === 60 ? '' : 's'}`;
225
+ };
226
+
227
+ /** (sprint 107) The transaction badge's text (public/worksheet.js writes the same after a reload or a tab switch). */
228
+ export function txnBadge(state: 'open' | 'aborted' | 'none', statements: number, startedMs: number | null, note: string | null): string {
229
+ if (state === 'none') return note ?? 'No open transaction';
230
+ const min = startedMs ? Math.floor((Date.now() - startedMs) / 60_000) : 0;
231
+ const age = min < 1 ? 'under a minute' : `${min} min`;
232
+ return `${state === 'aborted' ? 'Transaction failed' : 'Transaction open'} · ${statements} statement${statements === 1 ? '' : 's'} · ${age}`;
233
+ }
234
+
235
+ /** (sprint 107) Commit or Rollback a worksheet tab's transaction (the toolbar's buttons, and the JSON endpoint). */
236
+ async function finishTxn(req: Req, s: Session, tab: string, t: WorksheetTxn | undefined, action: 'commit' | 'rollback'): Promise<{ ok: boolean; message: string; busy?: boolean }> {
237
+ if (!t) return { ok: false, message: 'This worksheet has no open transaction.' };
238
+ if (t.busy) return { ok: false, busy: true, message: 'A run is still going on in this worksheet: wait for it to finish.' };
239
+ const n = t.statements;
240
+ const failed = txnState(t) === 'aborted';
241
+ const ended = await endTxn(t, action);
242
+ clearTxnNote(s.id, tab);
243
+ if (ended.ddl.length) await recordChanges({ workspaceId: t.workspaceId, author: s.username ?? '?', source: 'worksheet', defaultSchema: t.defaultSchema, statements: ended.ddl });
244
+ await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ended.tag.toLowerCase() });
245
+ if (ended.error) return { ok: false, message: `${action.toUpperCase()} failed: ${ended.error}. The connection was closed, so the transaction is rolled back.` };
246
+ if (action === 'commit' && ended.tag !== 'COMMIT') return { ok: false, message: 'The transaction had failed, so COMMIT rolled it back: nothing was saved.' };
247
+ const what = `${n} statement${n === 1 ? '' : 's'}`;
248
+ return { ok: true, message: action === 'commit' ? `Committed (${what}).` : `Rolled back (${what} undone${failed ? '; the transaction had failed' : ''}).` };
249
+ }
250
+
251
+ /** (sprint 107) A tab's transaction as JSON, for the badge after a reload or a tab switch. */
252
+ function txnStatus(s: Session, tab: string) {
253
+ const t = findTxn(s.id, tab);
254
+ const state = t && t.workspaceId === currentWorkspace(s) ? txnState(t) : 'none';
255
+ const note = state === 'none' ? txnNote(s.id, tab) : null;
256
+ return {
257
+ state, statements: t?.statements ?? 0, startedMs: t?.startedMs ?? null, lastMs: t?.lastMs ?? null, idleSeconds: txnIdleSeconds(),
258
+ runsAs: t?.runsAs ?? null, note, badge: txnBadge(state, t?.statements ?? 0, t?.startedMs ?? null, note), now: Date.now(),
259
+ };
260
+ }
261
+
220
262
  /** The applications whose database role a run can take (the developer's workspace). */
221
263
  async function runAsApps(s: Session) {
222
264
  return (await owner.query<{ id: number; name: string; db_role: string }>(
@@ -339,7 +381,22 @@ async function worksheet(req: Req, reply: FastifyReply) {
339
381
  return {};
340
382
  }
341
383
  })();
342
- if (isPost && text.trim() && !['nav', 'save', 'save_as', 'rename', 'download'].includes(String(body.mode ?? ''))) {
384
+ // (sprint 107, DA-2) transaction mode: the worksheet tab's open transaction (bound to this session)
385
+ const txnAsked = isPost && body.txn === 'on';
386
+ let txn: WorksheetTxn | undefined = tabKey ? findTxn(s.id, tabKey) : undefined;
387
+ let txnError = '';
388
+ let txnInfo = '';
389
+ if (txn && txn.workspaceId !== currentWorkspace(s)) {
390
+ await endTxn(txn, 'rollback', 'You switched workspaces: the transaction was rolled back.');
391
+ txn = undefined;
392
+ }
393
+ if (isPost && (body.mode === 'commit' || body.mode === 'rollback')) {
394
+ const done = await finishTxn(req, s, tabKey, txn, body.mode === 'commit' ? 'commit' : 'rollback');
395
+ if (!done.busy) txn = undefined;
396
+ if (done.ok) txnInfo = done.message;
397
+ else txnError = done.message;
398
+ }
399
+ if (isPost && text.trim() && !['nav', 'save', 'save_as', 'rename', 'download', 'commit', 'rollback'].includes(String(body.mode ?? ''))) {
343
400
  const m: RunMode = MODES.find((x) => x === body.mode) ?? 'statement';
344
401
  const allRows = body.mode === 'statement_all';
345
402
  chartAsked = body.mode === 'chart';
@@ -362,34 +419,88 @@ async function worksheet(req: Req, reply: FastifyReply) {
362
419
  ...x,
363
420
  sql: applyBinds(applySubstitutions(x.sql, Object.fromEntries(subs.map((n) => [n, value('sub', n)]))), Object.fromEntries(binds.map((n) => [n, value('bind', n)]))),
364
421
  }));
422
+ const explaining = m === 'explain' || m === 'autotrace';
423
+ // (sprint 107) which connection: the tab's transaction (open, or opened now), or one of its own
424
+ const useTxn = statements.length > 0 && txnAsked && (!explaining || (!!txn && txnState(txn) !== 'none'));
365
425
  if (statements.length) {
426
+ const blocked = txnAsked && !explaining ? statements.find((x) => !x.psql && outsideTxnOnly(x.sql)) : undefined;
427
+ if (txn && !txnAsked) txnError = 'This worksheet has an open transaction: Commit or Rollback it first, or switch back to Transaction.';
428
+ else if (txnAsked && !tabKey) txnError = 'Transaction mode needs JavaScript (the worksheet tab it belongs to); this run was refused.';
429
+ else if (txn && (asApp?.id ?? null) !== txn.asAppId) txnError = `This worksheet's transaction runs as ${txn.runsAs}: Commit or Rollback it before changing Run as.`;
430
+ else if (txn?.busy) txnError = 'A run is still going on in this worksheet: wait for it to finish.';
431
+ else if (blocked) txnError = `Line ${blocked.line}: ${outsideTxnOnly(blocked.sql)} can't run inside a transaction. Switch the worksheet to Autocommit to run it.`;
432
+ }
433
+ if (statements.length && !txnError) {
366
434
  ran = { start: statements[0].start, end: statements[statements.length - 1].end };
367
- // a connection of its own, closed afterwards: the SQL may SET ROLE or change settings, which must not
368
- // leak into the builder's pooled connections
369
- const c = await (asApp ? await devDbForApp(asApp.id) : db).pool.connect();
370
435
  const started = performance.now();
371
- try {
372
- if (searchSchema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(searchSchema)}, public`]);
373
- if (asApp) {
374
- await c.query(`select set_config('pgkiln.app_id', $1, false)`, [String(asApp.id)]);
375
- await c.query(`set role ${pg.escapeIdentifier(asApp.db_role)}`);
436
+ const runOn = (c: pg.ClientBase, inTxn: boolean) => explaining
437
+ ? explainStatement(c, statements[0], m === 'autotrace', inTxn)
438
+ : runStatements(c, text, statements, { continueOnError: m === 'script' && continueOnError, gridRows: allRows ? ALL_ROWS : GRID_ROWS });
439
+ const failedRun = (e: unknown): WorksheetRun => ({ results: [{ n: 1, line: statements[0].line, sql: statements[0].sql, start: 0, end: 0, status: 'error', error: (e as Error).message, ms: 0 }], notices: [], failed: 1, ms: 0 });
440
+ if (useTxn) {
441
+ if (!txn) {
442
+ const note = txnNote(s.id, tabKey);
443
+ const began = await beginTxn({ sessionId: s.id, username: s.username!, workspaceId: currentWorkspace(s), tab: tabKey, db: asApp ? await devDbForApp(asApp.id) : db,
444
+ asApp: asApp ?? null, runsAs: asApp ? `the role ${asApp.db_role} of ${asApp.name}` : workshopRunsAs(db), searchSchema });
445
+ if (typeof began === 'string') txnError = began;
446
+ else {
447
+ txn = began;
448
+ if (note) txnInfo = `${note} A new transaction started.`;
449
+ }
450
+ }
451
+ if (txn) {
452
+ const t = txn;
453
+ t.busy = true;
454
+ try {
455
+ run = await runOn(t.client, true);
456
+ } catch (e) {
457
+ run = failedRun(e);
458
+ } finally {
459
+ t.busy = false;
460
+ t.lastMs = Date.now();
461
+ }
462
+ }
463
+ } else {
464
+ // a connection of its own, closed afterwards: the SQL may SET ROLE or change settings, which must not
465
+ // leak into the builder's pooled connections
466
+ const c = await (asApp ? await devDbForApp(asApp.id) : db).pool.connect();
467
+ try {
468
+ if (searchSchema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(searchSchema)}, public`]);
469
+ if (asApp) {
470
+ await c.query(`select set_config('pgkiln.app_id', $1, false)`, [String(asApp.id)]);
471
+ await c.query(`set role ${pg.escapeIdentifier(asApp.db_role)}`);
472
+ }
473
+ run = await runOn(c, false);
474
+ // (sprint 107) a BEGIN typed in Autocommit lasts for the run only: say so when it was left open
475
+ if (!explaining && c.getTransactionStatus() !== 'I') {
476
+ await c.query('rollback').catch(() => {});
477
+ run.notices.push({ level: 'WARNING', message: 'The transaction started with BEGIN was still open at the end of the run and was rolled back. Switch the worksheet to Transaction to keep a transaction open between runs.', n: run.results.filter((r) => r.status !== 'not_run').length, at: new Date().toISOString().slice(11, 23) });
478
+ }
479
+ } catch (e) {
480
+ run = failedRun(e);
481
+ } finally {
482
+ c.release(true);
376
483
  }
377
- run = m === 'explain' || m === 'autotrace'
378
- ? await explainStatement(c, statements[0], m === 'autotrace')
379
- : await runStatements(c, text, statements, { continueOnError: m === 'script' && continueOnError, gridRows: allRows ? ALL_ROWS : GRID_ROWS });
380
- } catch (e) {
381
- run = { results: [{ n: 1, line: statements[0].line, sql: statements[0].sql, start: 0, end: 0, status: 'error', error: (e as Error).message, ms: 0 }], notices: [], failed: 1, ms: 0 };
382
- } finally {
383
- c.release(true);
384
484
  }
385
485
  // the log and history show the statements as written (with :NAME), not with the values
386
486
  if (run) for (const r of run.results) r.sql = picked[r.n - 1]?.sql ?? r.sql;
387
- if (mode === 'statement' || mode === 'script') {
487
+ // (sprint 107) the transaction's statements and DDL; a COMMIT / ROLLBACK typed in the editor ended it
488
+ let committedDdl: string[] | null = null;
489
+ if (txn && run && !explaining) {
490
+ committedDdl = accountRun(txn, run.results);
491
+ if (txnState(txn) === 'none' && findTxn(s.id, tabKey) === txn) {
492
+ await endTxn(txn, 'rollback'); // nothing open: just closes the connection
493
+ txn = undefined;
494
+ txnInfo = 'The transaction ended with the COMMIT or ROLLBACK in the editor; the next run starts a new one.';
495
+ }
496
+ }
497
+ if (txn && findTxn(s.id, tabKey) !== txn) txn = undefined; // the database ended the connection
498
+ if (run && (mode === 'statement' || mode === 'script')) {
388
499
  clearCompletions(); // the statements may have changed tables or grants
389
- if (run) await recordHistory(s, run);
390
- // (sprint 106, DA-17) the DDL that succeeded goes into the change log
391
- if (run) await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'worksheet', defaultSchema: searchSchema ?? 'public',
392
- statements: run.results.filter((r) => r.status === 'ok').map((r) => r.sql) });
500
+ await recordHistory(s, run);
501
+ // (sprint 106, DA-17) the DDL that succeeded goes into the change log (in a transaction: when it commits)
502
+ await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'worksheet', defaultSchema: searchSchema ?? 'public',
503
+ statements: committedDdl ?? run.results.filter((r) => r.status === 'ok').map((r) => r.sql) });
393
504
  const detail = picked.map((x) => x.sql).join(';\n');
394
505
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: detail.length > 2000 ? `${detail.slice(0, 2000)}…` : detail });
395
506
  }
@@ -435,6 +546,12 @@ async function worksheet(req: Req, reply: FastifyReply) {
435
546
  <div class="buttons"><button class="btn btn-hot" name="mode" value="${askValues.mode}">${icon('play')} Run</button><button class="btn" name="mode" value="nav" data-ws-binds-cancel>Cancel</button></div>
436
547
  </dialog>` : '';
437
548
  const runsAs = asApp ? `the role ${asApp.db_role} of ${asApp.name}` : workshopRunsAs(db);
549
+ // (sprint 107) the transaction badge: the real state of the tab's transaction on the server
550
+ const tState = txnState(txn);
551
+ const tNote = !txn && tabKey ? txnNote(s.id, tabKey) : null;
552
+ if (tState === 'aborted' && !txnError) txnError = 'A statement failed, so the transaction is aborted: Rollback ends it (COMMIT would roll it back too).';
553
+ const txnOn = txnAsked || !!txn;
554
+ const badge = txnBadge(tState, txn?.statements ?? 0, txn?.startedMs ?? null, tNote);
438
555
 
439
556
  const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
440
557
  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>`;
@@ -506,6 +623,13 @@ async function worksheet(req: Req, reply: FastifyReply) {
506
623
  </dl>
507
624
  <label class="ws-kwcase" hidden>Format keywords in <select data-ws-kwcase><option value="lower">lower case</option><option value="upper">UPPER CASE</option></select></label>
508
625
  <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>Help on the SQL Worksheet</a></div></div></details>
626
+ <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>
627
+ <label class="ws-runas"><span>Commit</span><select name="txn" data-ws-txn-mode title="Autocommit: each statement commits on its own. Transaction: the statements stay in one transaction until Commit or Rollback.">
628
+ <option value="">Autocommit</option><option value="on"${txnOn ? raw(' selected') : ''}>Transaction</option></select></label>
629
+ <span class="ws-txn-badge" data-ws-txn-badge data-state="${tState}" role="status" title="${tState === 'none' ? '' : `Runs as ${txn?.runsAs ?? ''}`}">${badge}</span>
630
+ <button class="btn btn-sm" name="mode" value="commit" data-ws-txn-commit title="Commit the worksheet's transaction"${txn ? '' : raw(' disabled')}>${icon('check')}<span>Commit</span></button>
631
+ <button class="btn btn-sm" name="mode" value="rollback" data-ws-txn-rollback title="Roll back the worksheet's transaction"${txn ? '' : raw(' disabled')}>${icon('undo')}<span>Rollback</span></button>
632
+ </span>
509
633
  <label class="ws-runas"><span>Run as</span>
510
634
  <select name="as_app" title="Who the statements run as">
511
635
  <option value="">${db === owner ? 'Owner connection' : 'Workspace login'}</option>
@@ -514,9 +638,12 @@ async function worksheet(req: Req, reply: FastifyReply) {
514
638
  <button type="button" class="btn btn-sm btn-icon" data-ws-clear title="Clear the editor" hidden>${icon('trash')}<span class="sr-only">Clear</span></button>
515
639
  <button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="Fullscreen (Escape leaves)" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">Fullscreen</span></button>
516
640
  </div>
517
- <p class="ws-runs-as muted small">Runs as ${runsAs}${searchSchema ? html`; <code>${searchSchema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. Each statement commits on its own.</p>
641
+ <p class="ws-runs-as muted small">Runs as ${runsAs}${searchSchema ? html`; <code>${searchSchema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. <span data-ws-commit-note>${txnOn
642
+ ? `The statements stay in one transaction until Commit or Rollback; it is rolled back after ${idleLabel()} without a run.`
643
+ : 'Each statement commits on its own.'}</span></p>
518
644
  ${fileError ? html`<p class="alert alert-error ws-file-error" role="alert">${fileError}</p>` : ''}
519
- <div class="ws-wtabs" role="tablist" aria-label="Worksheets" hidden></div>
645
+ ${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>` : ''}
646
+ <div class="ws-wtabs" role="group" aria-label="Worksheets" hidden></div>
520
647
  <div class="ws-findbar" hidden>
521
648
  <label class="sr-only" for="ws-find-q">Find</label><input id="ws-find-q" type="search" placeholder="Find" autocomplete="off" data-ws-find-q>
522
649
  <label class="sr-only" for="ws-find-r">Replace with</label><input id="ws-find-r" placeholder="Replace with" autocomplete="off" data-ws-find-r>
@@ -553,6 +680,27 @@ export async function worksheetRoutes(app: FastifyInstance) {
553
680
  // (before sprint 61 SQL Commands was /builder/sql: a post there still runs)
554
681
  app.post(`${BASE}/sql`, worksheet);
555
682
 
683
+ // (sprint 107, DA-2) transaction mode: a tab's transaction (the badge), Commit / Rollback without a reload (closing a
684
+ // worksheet tab, switching to Autocommit). Always the caller's own session: another developer's tab doesn't exist here.
685
+ app.get(`${BASE}/sql/worksheet/txn`, async (req: Req, reply) => {
686
+ const s = await developer(req, reply);
687
+ if (!s) return;
688
+ const tab = String(req.query?.tab ?? '');
689
+ if (!/^[a-z0-9]{1,20}$/.test(tab)) return reply.code(400).send({ error: 'Which worksheet?' });
690
+ return reply.header('Cache-Control', 'no-store').send(txnStatus(s, tab));
691
+ });
692
+ app.post(`${BASE}/sql/worksheet/txn`, async (req: Req, reply) => {
693
+ const s = await developer(req, reply);
694
+ if (!s) return;
695
+ const tab = String(req.body?.tab ?? '');
696
+ const action = req.body?.action;
697
+ if (!/^[a-z0-9]{1,20}$/.test(tab) || (action !== 'commit' && action !== 'rollback')) return reply.code(400).send({ error: 'Which worksheet, and commit or rollback?' });
698
+ const t = findTxn(s.id, tab);
699
+ const done = await finishTxn(req, s, tab, t && t.workspaceId === currentWorkspace(s) ? t : undefined, action);
700
+ return reply.code(done.busy ? 409 : 200).header('Cache-Control', 'no-store').send({ ...done, ...txnStatus(s, tab) });
701
+ });
702
+ app.addHook('onClose', async () => endAllTxns());
703
+
556
704
  // (sprint 93, DA-9) Format: the worksheet's text laid out, keywords in the case asked for
557
705
  app.post(`${BASE}/sql/worksheet/format`, async (req: Req, reply) => {
558
706
  const s = await developer(req, reply);