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.
- package/SECURITY.md +3 -0
- package/db/migrations/119_workspace_home.sql +30 -0
- package/db/migrations/120_datasets.sql +87 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/01-installation.md +5 -0
- package/docs/guide/03-builder.md +140 -11
- package/docs/guide/04-pages-and-regions.md +25 -1
- package/docs/guide/12-development.md +47 -11
- package/examples/hr/hr_63_datasets.sql +29 -0
- package/package.json +1 -1
- package/public/app.css +6 -0
- package/public/builder.css +95 -3
- package/public/code-editor.css +32 -79
- package/public/code-editor.js +58 -3
- package/public/data-modeler.css +17 -14
- package/public/worksheet.css +14 -7
- package/public/worksheet.js +119 -3
- package/src/app.ts +5 -1
- package/src/builder/changelog.ts +1 -1
- package/src/builder/data-modeler.ts +1 -1
- package/src/builder/datasets.ts +472 -0
- package/src/builder/db-users.ts +2 -2
- package/src/builder/home.ts +12 -7
- package/src/builder/instance.ts +2 -0
- package/src/builder/object-browser.ts +4 -4
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/quicksql.ts +1 -1
- package/src/builder/reporter.ts +52 -3
- package/src/builder/routes.ts +3 -3
- package/src/builder/scheduling.ts +2 -2
- package/src/builder/scripts.ts +1 -1
- package/src/builder/sql-charts.ts +1 -1
- package/src/builder/sql-monitor.ts +1 -1
- package/src/builder/subscriptions.ts +1 -0
- package/src/builder/table-editor.ts +2 -2
- package/src/builder/ui.ts +7 -5
- package/src/builder/worksheet.ts +171 -23
- package/src/builder/workspace-home.ts +283 -0
- package/src/builder/workspaces.ts +7 -4
- package/src/datasets.ts +367 -0
- package/src/i18n/ar.ts +16 -0
- package/src/i18n/cs.ts +16 -0
- package/src/i18n/da.ts +16 -0
- package/src/i18n/de.ts +16 -0
- package/src/i18n/el.ts +16 -0
- package/src/i18n/es.ts +16 -0
- package/src/i18n/fi.ts +16 -0
- package/src/i18n/fr.ts +16 -0
- package/src/i18n/he.ts +16 -0
- package/src/i18n/it.ts +16 -0
- package/src/i18n/ja.ts +16 -0
- package/src/i18n/ko.ts +16 -0
- package/src/i18n/nb.ts +16 -0
- package/src/i18n/pl.ts +16 -0
- package/src/i18n/pt.ts +16 -0
- package/src/i18n/ru.ts +16 -0
- package/src/i18n/sv.ts +16 -0
- package/src/i18n/tr.ts +16 -0
- package/src/i18n/uk.ts +16 -0
- package/src/i18n/zh.ts +16 -0
- package/src/i18n.ts +32 -0
- package/src/instance.ts +1 -0
- package/src/release-check.ts +139 -0
- package/src/reporting-app.ts +101 -0
- package/src/runtime/data-reporter.ts +199 -32
- package/src/session.ts +7 -0
- package/src/sqlworksheet.ts +9 -6
- package/src/subscriptions.ts +2 -1
- package/src/worksheet-txn.ts +270 -0
- 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
|
: '';
|
package/src/builder/quicksql.ts
CHANGED
|
@@ -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>
|
package/src/builder/reporter.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.');
|
package/src/builder/routes.ts
CHANGED
|
@@ -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'));
|
package/src/builder/scripts.ts
CHANGED
|
@@ -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('
|
|
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">
|
package/src/builder/worksheet.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
// (sprint 106, DA-17) the DDL that succeeded goes into the change log
|
|
391
|
-
|
|
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>)` : ''}.
|
|
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
|
-
|
|
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);
|