pgkiln 0.49.0 → 0.50.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 +1 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/01-installation.md +3 -0
- package/docs/guide/03-builder.md +49 -6
- package/docs/guide/12-development.md +21 -8
- package/package.json +1 -1
- package/public/builder.css +61 -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/builder/changelog.ts +1 -1
- package/src/builder/data-modeler.ts +1 -1
- package/src/builder/db-users.ts +2 -2
- 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/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/table-editor.ts +2 -2
- package/src/builder/worksheet.ts +171 -23
- package/src/session.ts +5 -0
- package/src/sqlworksheet.ts +9 -6
- package/src/worksheet-txn.ts +270 -0
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);
|
package/src/session.ts
CHANGED
|
@@ -91,8 +91,13 @@ export async function saveState(s: Session) {
|
|
|
91
91
|
export async function destroySession(reply: FastifyReply, s: Session, path: string) {
|
|
92
92
|
await runtime.query('delete from meta.session where id = $1', [s.id]);
|
|
93
93
|
reply.clearCookie(cookieName(s.app_id), { path });
|
|
94
|
+
for (const fn of endHooks) await fn(s.id).catch(() => {});
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** (sprint 107) Called when a session is destroyed (sign-out, a new sign-in): the worksheet's transactions end with it. */
|
|
98
|
+
const endHooks: ((sessionId: string) => Promise<unknown>)[] = [];
|
|
99
|
+
export const onSessionEnd = (fn: (sessionId: string) => Promise<unknown>) => endHooks.push(fn);
|
|
100
|
+
|
|
96
101
|
/** Take (and clear) a one-shot flash message stored in session state. */
|
|
97
102
|
export function takeFlash(s: Session, key = '__FLASH') {
|
|
98
103
|
const msg = s.state[key] ?? null;
|
package/src/sqlworksheet.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { isNumeric } from './runtime/report.ts';
|
|
|
3
3
|
import { parsePlan, planText, type Plan } from './sqlplan.ts';
|
|
4
4
|
import { splitScript, type ScriptStatement } from './sqlscript.ts';
|
|
5
5
|
import { skipQuoted } from './binds.ts';
|
|
6
|
-
import type { Client } from './db.ts';
|
|
7
6
|
|
|
8
7
|
// (sprint 91, Database Actions DA-1/DA-2) The SQL Workshop's worksheet: which statements a run takes
|
|
9
8
|
// (the one at the cursor, a selection, or all of them), and running them on one connection with a log
|
|
@@ -144,9 +143,10 @@ export interface RunOptions {
|
|
|
144
143
|
|
|
145
144
|
/**
|
|
146
145
|
* Run statements on `c` (a connection of its own, destroyed by the caller: the SQL may change the role or
|
|
147
|
-
* settings). Each statement autocommits, like Database Actions
|
|
146
|
+
* settings). Each statement autocommits, like Database Actions, unless the caller opened a transaction on `c`
|
|
147
|
+
* (sprint 107, the worksheet's transaction mode). Notices are collected per statement.
|
|
148
148
|
*/
|
|
149
|
-
export async function runStatements(c:
|
|
149
|
+
export async function runStatements(c: pg.ClientBase, text: string, statements: ScriptStatement[], opts: RunOptions = {}): Promise<WorksheetRun> {
|
|
150
150
|
const started = performance.now();
|
|
151
151
|
const notices: Notice[] = [];
|
|
152
152
|
let current = 0;
|
|
@@ -206,7 +206,7 @@ export async function runStatements(c: Client, text: string, statements: ScriptS
|
|
|
206
206
|
* EXPLAIN the statement (Explain Plan: nothing runs) or EXPLAIN ANALYZE it inside a transaction that is
|
|
207
207
|
* rolled back (Autotrace: it runs, its changes don't stay).
|
|
208
208
|
*/
|
|
209
|
-
export async function explainStatement(c:
|
|
209
|
+
export async function explainStatement(c: pg.ClientBase, s: ScriptStatement, analyze: boolean, inTransaction = false): Promise<WorksheetRun> {
|
|
210
210
|
const started = performance.now();
|
|
211
211
|
const r: WorksheetResult = { n: 1, line: s.line, sql: s.sql, start: s.start, end: s.end, status: 'not_run', ms: 0 };
|
|
212
212
|
let plan: string | undefined;
|
|
@@ -216,7 +216,9 @@ export async function explainStatement(c: Client, s: ScriptStatement, analyze: b
|
|
|
216
216
|
r.error = 'Only SQL statements have a plan.';
|
|
217
217
|
} else {
|
|
218
218
|
try {
|
|
219
|
-
|
|
219
|
+
// (sprint 107) in a worksheet's open transaction: a savepoint, so the plan sees the transaction's changes and
|
|
220
|
+
// Autotrace's own are undone while the transaction's stay (a failing EXPLAIN doesn't abort it either)
|
|
221
|
+
await c.query(inTransaction ? 'savepoint pgkiln_explain' : 'begin');
|
|
220
222
|
const out = await c.query<{ 'QUERY PLAN': unknown }>(`explain (format json, ${analyze ? 'analyze, buffers, ' : ''}verbose, costs) ${s.sql}`);
|
|
221
223
|
const json = out.rows[0]?.['QUERY PLAN'];
|
|
222
224
|
tree = parsePlan(typeof json === 'string' ? JSON.parse(json) : json, analyze);
|
|
@@ -229,7 +231,8 @@ export async function explainStatement(c: Client, s: ScriptStatement, analyze: b
|
|
|
229
231
|
r.error = `${err.message}${err.code ? ` (${err.code})` : ''}`;
|
|
230
232
|
r.errorLine = s.line;
|
|
231
233
|
} finally {
|
|
232
|
-
await c.query('rollback').catch(() => {});
|
|
234
|
+
if (inTransaction) await c.query('rollback to savepoint pgkiln_explain').then(() => c.query('release savepoint pgkiln_explain')).catch(() => {});
|
|
235
|
+
else await c.query('rollback').catch(() => {});
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
r.ms = Math.round(performance.now() - started);
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { onClosePools, owner, type Db } from './db.ts';
|
|
4
|
+
import { onSessionEnd, sessionLimits } from './session.ts';
|
|
5
|
+
import { isSchemaDdl } from './changelog.ts';
|
|
6
|
+
|
|
7
|
+
// (sprint 107, DA-2) Transaction mode of the SQL Worksheet. Database Actions autocommits every statement;
|
|
8
|
+
// in transaction mode a worksheet tab keeps one connection of its own with an open transaction between
|
|
9
|
+
// runs, until Commit, Rollback, a COMMIT / ROLLBACK typed in the editor, or an idle timeout.
|
|
10
|
+
//
|
|
11
|
+
// The registry lives in this process only (no table): a transaction is a live connection, so it can't
|
|
12
|
+
// outlive the server anyway. Each open transaction is bound to the builder session that started it, its
|
|
13
|
+
// developer, workspace and worksheet tab, and has a random id; a lookup always starts from the caller's own
|
|
14
|
+
// session id, so one developer can never reach another one's connection. Limits: a few per developer and
|
|
15
|
+
// per instance (each holds a database connection outside the pools), an idle timeout (rolled back by a
|
|
16
|
+
// sweeper) and the connection's idle_in_transaction_session_timeout a little later as the backstop. The
|
|
17
|
+
// connection is a plain pg.Client made from the workshop's (or the "Run as" application's) pool settings,
|
|
18
|
+
// never a pooled one: SET ROLE and settings typed by the developer die with it.
|
|
19
|
+
|
|
20
|
+
const intEnv = (name: string, dflt: number, min: number, max: number) => {
|
|
21
|
+
const n = Number(process.env[name]);
|
|
22
|
+
return Number.isFinite(n) && n >= min && n <= max ? Math.floor(n) : dflt;
|
|
23
|
+
};
|
|
24
|
+
/** Seconds without a run before an open transaction is rolled back (WORKSHEET_TXN_IDLE_SECONDS, default 5 minutes). */
|
|
25
|
+
export const txnIdleSeconds = () => intEnv('WORKSHEET_TXN_IDLE_SECONDS', 300, 10, 86_400);
|
|
26
|
+
/** Open transactions per developer (WORKSHEET_TXN_MAX_PER_DEVELOPER, default 3) and per server (WORKSHEET_TXN_MAX, default 20). */
|
|
27
|
+
export const txnMaxPerDeveloper = () => intEnv('WORKSHEET_TXN_MAX_PER_DEVELOPER', 3, 1, 100);
|
|
28
|
+
export const txnMax = () => intEnv('WORKSHEET_TXN_MAX', 20, 1, 1000);
|
|
29
|
+
/** The database's own backstop: this much after the idle timeout it ends the connection itself. */
|
|
30
|
+
const BACKSTOP_SECONDS = 60;
|
|
31
|
+
/** How long the note of a transaction the server ended (timeout, workspace switch) stays for the worksheet. */
|
|
32
|
+
const NOTE_MS = 60 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
export interface WorksheetTxn {
|
|
35
|
+
/** random handle (shown to the page, never trusted for a lookup on its own) */
|
|
36
|
+
id: string;
|
|
37
|
+
sessionId: string;
|
|
38
|
+
username: string;
|
|
39
|
+
workspaceId: number;
|
|
40
|
+
tab: string;
|
|
41
|
+
client: pg.Client;
|
|
42
|
+
/** the application whose role the transaction runs as (Run as), or null */
|
|
43
|
+
asAppId: number | null;
|
|
44
|
+
/** who it runs as, for the badge */
|
|
45
|
+
runsAs: string;
|
|
46
|
+
startedMs: number;
|
|
47
|
+
lastMs: number;
|
|
48
|
+
/** statements run in the current transaction */
|
|
49
|
+
statements: number;
|
|
50
|
+
/** a run (or commit / rollback) is going on */
|
|
51
|
+
busy: boolean;
|
|
52
|
+
/** DDL that succeeded in the transaction: it goes into the Change Log at COMMIT, not before */
|
|
53
|
+
pendingDdl: string[];
|
|
54
|
+
defaultSchema: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type TxnState = 'open' | 'aborted' | 'none';
|
|
58
|
+
|
|
59
|
+
const open = new Map<string, WorksheetTxn>();
|
|
60
|
+
const notes = new Map<string, { message: string; at: number }>();
|
|
61
|
+
const keyOf = (sessionId: string, tab: string) => `${sessionId}\u0000${tab}`;
|
|
62
|
+
|
|
63
|
+
/** A worksheet tab's key as the page sends it (src/builder/worksheet.ts uses the same rule). */
|
|
64
|
+
export const validTab = (tab: unknown) => typeof tab === 'string' && /^[a-z0-9]{1,20}$/.test(tab);
|
|
65
|
+
|
|
66
|
+
/** The open transaction of this session's worksheet tab. */
|
|
67
|
+
export function findTxn(sessionId: string, tab: string): WorksheetTxn | undefined {
|
|
68
|
+
return validTab(tab) ? open.get(keyOf(sessionId, tab)) : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Open, aborted (a statement failed: only ROLLBACK helps) or none (committed or rolled back by a typed statement). */
|
|
72
|
+
export function txnState(t: WorksheetTxn | undefined): TxnState {
|
|
73
|
+
const s = t?.client.getTransactionStatus();
|
|
74
|
+
return s === 'T' ? 'open' : s === 'E' ? 'aborted' : 'none';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Why the server ended this tab's last transaction (idle timeout, …), if it did. */
|
|
78
|
+
export function txnNote(sessionId: string, tab: string): string | null {
|
|
79
|
+
const n = notes.get(keyOf(sessionId, tab));
|
|
80
|
+
if (!n) return null;
|
|
81
|
+
if (Date.now() - n.at > NOTE_MS) {
|
|
82
|
+
notes.delete(keyOf(sessionId, tab));
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return n.message;
|
|
86
|
+
}
|
|
87
|
+
export const clearTxnNote = (sessionId: string, tab: string) => notes.delete(keyOf(sessionId, tab));
|
|
88
|
+
|
|
89
|
+
/** Open transactions (tests and the instance's monitor). */
|
|
90
|
+
export const openTxnCount = (username?: string) => [...open.values()].filter((t) => username === undefined || t.username === username).length;
|
|
91
|
+
|
|
92
|
+
/** Statements PostgreSQL refuses inside a transaction block (refused before they abort it): the command, or ''. */
|
|
93
|
+
export const outsideTxnOnly = (sql: string): string => {
|
|
94
|
+
const s = sql.replace(/^(\s|--[^\n]*\n|\/\*[\s\S]*?\*\/)+/, '').toLowerCase();
|
|
95
|
+
return /^(vacuum|alter\s+system|(create|drop)\s+(database|tablespace)|(create|drop)\s+(unique\s+)?index\s+concurrently|reindex\b[^;]*\bconcurrently|alter\s+table\b[^;]*\bdetach\s+partition\b[^;]*\bconcurrently|prepare\s+transaction|commit\s+prepared|rollback\s+prepared)\b/.exec(s)?.[0].replace(/\s+/g, ' ').toUpperCase() ?? '';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export interface BeginOptions {
|
|
99
|
+
sessionId: string;
|
|
100
|
+
username: string;
|
|
101
|
+
workspaceId: number;
|
|
102
|
+
tab: string;
|
|
103
|
+
/** the pool whose login the connection uses (the workshop's, or the Run as application's) */
|
|
104
|
+
db: Db;
|
|
105
|
+
asApp?: { id: number; db_role: string } | null;
|
|
106
|
+
runsAs: string;
|
|
107
|
+
searchSchema: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Start a transaction for a worksheet tab: a connection of its own, BEGIN. A string is a refusal. */
|
|
111
|
+
export async function beginTxn(o: BeginOptions): Promise<WorksheetTxn | string> {
|
|
112
|
+
if (!validTab(o.tab)) return 'Transaction mode needs the worksheet tab (JavaScript).';
|
|
113
|
+
const key = keyOf(o.sessionId, o.tab);
|
|
114
|
+
if (open.has(key)) return 'This worksheet already has an open transaction.';
|
|
115
|
+
const mine = openTxnCount(o.username);
|
|
116
|
+
if (mine >= txnMaxPerDeveloper())
|
|
117
|
+
return `You have ${mine} worksheets with an open transaction, the most allowed: commit or roll back one of them first.`;
|
|
118
|
+
if (open.size >= txnMax()) return 'The server has as many open worksheet transactions as it allows: try again later, or use Autocommit.';
|
|
119
|
+
const idle = txnIdleSeconds();
|
|
120
|
+
const opts = o.db.pool.options;
|
|
121
|
+
const client = new pg.Client({
|
|
122
|
+
connectionString: opts.connectionString,
|
|
123
|
+
password: opts.password,
|
|
124
|
+
ssl: opts.ssl,
|
|
125
|
+
application_name: 'pgkiln-worksheet-txn',
|
|
126
|
+
connectionTimeoutMillis: 10_000,
|
|
127
|
+
idle_in_transaction_session_timeout: (idle + BACKSTOP_SECONDS) * 1000,
|
|
128
|
+
});
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const t: WorksheetTxn = {
|
|
131
|
+
id: randomBytes(16).toString('hex'), sessionId: o.sessionId, username: o.username, workspaceId: o.workspaceId, tab: o.tab, client,
|
|
132
|
+
asAppId: o.asApp?.id ?? null, runsAs: o.runsAs, startedMs: now, lastMs: now, statements: 0, busy: true, pendingDdl: [], defaultSchema: o.searchSchema ?? 'public',
|
|
133
|
+
};
|
|
134
|
+
open.set(key, t); // before the first await: the limits count it at once
|
|
135
|
+
notes.delete(key);
|
|
136
|
+
// the database ended the connection (its backstop timeout, a restart, pg_terminate_backend)
|
|
137
|
+
const gone = () => {
|
|
138
|
+
if (open.get(key) !== t) return;
|
|
139
|
+
open.delete(key);
|
|
140
|
+
notes.set(key, { message: 'The database ended the transaction\'s connection: its changes were rolled back.', at: Date.now() });
|
|
141
|
+
};
|
|
142
|
+
client.on('error', gone);
|
|
143
|
+
client.on('end', gone);
|
|
144
|
+
try {
|
|
145
|
+
await client.connect();
|
|
146
|
+
if (o.searchSchema) await client.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(o.searchSchema)}, public`]);
|
|
147
|
+
if (o.asApp) {
|
|
148
|
+
await client.query(`select set_config('pgkiln.app_id', $1, false)`, [String(o.asApp.id)]);
|
|
149
|
+
await client.query(`set role ${pg.escapeIdentifier(o.asApp.db_role)}`);
|
|
150
|
+
}
|
|
151
|
+
await client.query('begin');
|
|
152
|
+
} catch (e) {
|
|
153
|
+
open.delete(key);
|
|
154
|
+
await client.end().catch(() => {});
|
|
155
|
+
return `The transaction could not start: ${(e as Error).message}`;
|
|
156
|
+
} finally {
|
|
157
|
+
t.busy = false;
|
|
158
|
+
}
|
|
159
|
+
startSweeper();
|
|
160
|
+
return t;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** BEGIN again on the transaction's connection after a COMMIT / ROLLBACK typed in the editor. */
|
|
164
|
+
export async function beginAgain(t: WorksheetTxn) {
|
|
165
|
+
await t.client.query('begin');
|
|
166
|
+
t.startedMs = Date.now();
|
|
167
|
+
t.statements = 0;
|
|
168
|
+
t.pendingDdl = [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* End a transaction: COMMIT or ROLLBACK, then close its connection. The tag is what the database answered
|
|
173
|
+
* (COMMIT of a failed transaction answers ROLLBACK). `note` is kept for the worksheet (the server ended it).
|
|
174
|
+
*/
|
|
175
|
+
export async function endTxn(t: WorksheetTxn, action: 'commit' | 'rollback', note?: string): Promise<{ tag: string; error?: string; ddl: string[] }> {
|
|
176
|
+
const key = keyOf(t.sessionId, t.tab);
|
|
177
|
+
if (open.get(key) === t) open.delete(key);
|
|
178
|
+
if (note) {
|
|
179
|
+
notes.set(key, { message: note, at: Date.now() });
|
|
180
|
+
if (notes.size > 1000) notes.delete(notes.keys().next().value!); // the oldest
|
|
181
|
+
}
|
|
182
|
+
let tag = action.toUpperCase();
|
|
183
|
+
let error: string | undefined;
|
|
184
|
+
try {
|
|
185
|
+
if (t.client.getTransactionStatus() !== 'I') {
|
|
186
|
+
const r = await t.client.query(action);
|
|
187
|
+
tag = (r.command ?? tag).toUpperCase();
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
error = (e as Error).message;
|
|
191
|
+
tag = 'ROLLBACK';
|
|
192
|
+
} finally {
|
|
193
|
+
await t.client.end().catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
return { tag, error, ddl: action === 'commit' && tag === 'COMMIT' ? t.pendingDdl : [] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Count a run's statements in the transaction and keep its DDL for the Change Log until COMMIT. A COMMIT
|
|
200
|
+
* (or END) typed in the editor hands back the DDL it committed; a ROLLBACK (or ABORT, or COMMIT of a failed
|
|
201
|
+
* transaction, which answers ROLLBACK) drops it.
|
|
202
|
+
*/
|
|
203
|
+
export function accountRun(t: WorksheetTxn, results: { status: string; sql: string; tag?: string }[]): string[] {
|
|
204
|
+
const committed: string[] = [];
|
|
205
|
+
for (const r of results) {
|
|
206
|
+
if (r.status !== 'ok') continue;
|
|
207
|
+
const tag = (r.tag ?? '').toUpperCase();
|
|
208
|
+
if (tag === 'COMMIT') {
|
|
209
|
+
committed.push(...t.pendingDdl);
|
|
210
|
+
t.pendingDdl = [];
|
|
211
|
+
t.statements = 0;
|
|
212
|
+
} else if (tag === 'ROLLBACK' && !/^\s*rollback\s+(work\s+|transaction\s+)?to\b/i.test(r.sql)) {
|
|
213
|
+
t.pendingDdl = [];
|
|
214
|
+
t.statements = 0;
|
|
215
|
+
} else if (tag !== 'BEGIN' && tag !== 'START') {
|
|
216
|
+
t.statements++;
|
|
217
|
+
if (isSchemaDdl(r.sql)) t.pendingDdl.push(r.sql);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return committed;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Roll back and close every transaction of a session (it ended: sign-out, a new sign-in). */
|
|
224
|
+
export async function endSessionTxns(sessionId: string, note: string) {
|
|
225
|
+
await Promise.all([...open.values()].filter((t) => t.sessionId === sessionId).map((t) => endTxn(t, 'rollback', note)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The idle timeout and ended sessions: roll back. `now` lets tests move the clock. */
|
|
229
|
+
export async function sweepTxns(now = Date.now()) {
|
|
230
|
+
const idleMs = txnIdleSeconds() * 1000;
|
|
231
|
+
const minutes = Math.round(txnIdleSeconds() / 6) / 10;
|
|
232
|
+
const ended: Promise<unknown>[] = [];
|
|
233
|
+
for (const t of open.values())
|
|
234
|
+
if (!t.busy && now - t.lastMs > idleMs)
|
|
235
|
+
ended.push(endTxn(t, 'rollback', `The transaction was rolled back after ${minutes} minute${minutes === 1 ? '' : 's'} without a run (${t.statements} statement${t.statements === 1 ? '' : 's'} undone).`));
|
|
236
|
+
await Promise.all(ended);
|
|
237
|
+
const sessions = [...new Set([...open.values()].map((t) => t.sessionId))];
|
|
238
|
+
if (sessions.length) {
|
|
239
|
+
// a session that ended or expired (its row may stay a while after it expires)
|
|
240
|
+
const { idleMinutes, maxHours } = sessionLimits();
|
|
241
|
+
const alive = new Set((await owner.query<{ id: string }>(
|
|
242
|
+
`select id::text as id from meta.session where id::text = any ($1)
|
|
243
|
+
and last_seen > now() - make_interval(mins => $2) and created_at > now() - make_interval(hours => $3)`, [sessions, idleMinutes, maxHours])
|
|
244
|
+
.catch(() => ({ rows: sessions.map((id) => ({ id })) }))).rows.map((r) => r.id));
|
|
245
|
+
await Promise.all(sessions.filter((id) => !alive.has(id)).map((id) => endSessionTxns(id, 'The builder session ended: the transaction was rolled back.')));
|
|
246
|
+
}
|
|
247
|
+
for (const [k, n] of notes) if (now - n.at > NOTE_MS) notes.delete(k);
|
|
248
|
+
if (!open.size) stopSweeper();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let sweeper: NodeJS.Timeout | null = null;
|
|
252
|
+
function startSweeper() {
|
|
253
|
+
if (sweeper) return;
|
|
254
|
+
const every = Math.max(1000, Math.min(15_000, (txnIdleSeconds() * 1000) / 4));
|
|
255
|
+
sweeper = setInterval(() => void sweepTxns().catch((e) => console.error('worksheet transactions:', (e as Error).message)), every);
|
|
256
|
+
sweeper.unref();
|
|
257
|
+
}
|
|
258
|
+
function stopSweeper() {
|
|
259
|
+
if (sweeper) clearInterval(sweeper);
|
|
260
|
+
sweeper = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Everything rolled back (the server stops). */
|
|
264
|
+
export async function endAllTxns(note = 'The server stopped: the transaction was rolled back.') {
|
|
265
|
+
stopSweeper();
|
|
266
|
+
await Promise.all([...open.values()].map((t) => endTxn(t, 'rollback', note)));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
onClosePools(() => endAllTxns());
|
|
270
|
+
onSessionEnd((sessionId) => endSessionTxns(sessionId, 'You signed out: the transaction was rolled back.'));
|