/* ProvSQL Studio: entry script.
Wires the shared chrome (mode switcher, example buttons, query form) plus
both mode-specific sidebars: where-mode shows source-relation tables with
hover-highlight, circuit-mode shows the provenance DAG (lazy-loaded
circuit.js). Cross-mode navigation preserves the textarea content via
sessionStorage and offers a per-row "→ Circuit" jump in where mode. */
(function () {
const mode = document.body.classList.contains('mode-circuit') ? 'circuit' : 'where';
// Metadata caches (schema panel, eval-strip mapping picker, eval-strip
// custom-semiring optgroup) lazy-load once and would otherwise stay
// stale for the lifetime of the page. We mark them dirty after every
// successful /api/exec so the next time each panel opens, it
// re-fetches; the toolbar refresh button forces an invalidation
// explicitly (e.g. after schema changes outside the Studio session).
// Each consumer is responsible for clearing its own flag once reload
// succeeds.
const Metadata = {
schemaDirty: true,
mappingsDirty: true,
customsDirty: true,
invalidateAll() {
this.schemaDirty = true;
this.mappingsDirty = true;
this.customsDirty = true;
},
};
window.ProvsqlStudio = window.ProvsqlStudio || {};
window.ProvsqlStudio.metadata = Metadata;
// Carry the textarea + a per-mode preload UUID across navigation.
// The active-tab highlight is driven by CSS off
// so it doesn't flash when JS lags behind initial render.
//
// Two carry channels :
// ps.sql always written : preserves the user's draft across the
// switch so they don't lose what they had typed.
// ps.sql.ran written only if the textarea content matches the
// most-recently-executed SQL (ps.lastRunSql, set by
// runQuery). Drives the auto-replay decision in the new
// mode : we re-run on switch only if the query had
// actually been executed in the original mode. A draft
// sitting in the textarea (page reload, history nav,
// in-flight edit) must NOT auto-execute on switch
// because of side-effecting queries like add_provenance.
document.querySelectorAll('.ps-modeswitch__btn').forEach(btn => {
btn.addEventListener('click', () => {
carryQueryForSwitch();
});
});
// Restore the carried-over query if there is one. carriedRan controls
// auto-replay; it's set only when the carried query had actually been
// run in the previous mode.
const carried = sessionStorage.getItem('ps.sql');
const carriedFromSwitch = carried != null;
const carriedRan = sessionStorage.getItem('ps.sql.ran') === '1';
if (carriedFromSwitch) {
document.getElementById('request').value = carried;
sessionStorage.removeItem('ps.sql');
sessionStorage.removeItem('ps.sql.ran');
}
function carryQueryForSwitch() {
const sql = document.getElementById('request').value;
sessionStorage.setItem('ps.sql', sql);
const lastRun = sessionStorage.getItem('ps.lastRunSql');
if (sql && lastRun === sql) {
sessionStorage.setItem('ps.sql.ran', '1');
} else {
sessionStorage.removeItem('ps.sql.ran');
}
}
// Expose so the where→circuit jump and the database-switch handler can
// reuse the same carry rule.
window.ProvsqlStudio.carryQueryForSwitch = carryQueryForSwitch;
// If the previous page asked us to preload a circuit (via "→ Circuit"
// button on a where-mode result row), pull the UUID out now so circuit-mode
// setup can fire it after the result table renders.
const preloadCircuitUuid = sessionStorage.getItem('ps.preloadCircuit');
sessionStorage.removeItem('ps.preloadCircuit');
// GUC toggles. In where mode, where_provenance is forced on (the wrap
// calls where_provenance(...) and would otherwise return all-empty); the
// toggle is locked. In circuit mode, both are user-controlled.
setupGucToggles();
// Connection chip in the top nav: pull the live current_user /
// current_database() once at page load, then poll every 5s so the
// dot turns terracotta if the server stops responding (e.g. PG was
// restarted, network blip) and back to green when it recovers. The
// matching server-side log filter (cli.py) drops these polls from
// the access log to keep the console quiet.
fetchConnInfo();
setInterval(fetchConnInfo, 5000);
setupConfigPanel();
setupSchemaPanel();
// ⌘ / Ctrl+Enter submits the query form. Alt+↑/Alt+↓ steps through the
// saved query history without opening the dropdown.
document.getElementById('request').addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
document.querySelector('form.wp-form').requestSubmit();
return;
}
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
stepHistory(e.key === 'ArrowUp' ? +1 : -1);
}
});
setupHistoryDropdown();
// Clear-query button in the editor gutter : wipes the textarea so the
// user can start over without selecting + deleting the previous text.
// The current text is pushed to history first (pushHistory dedupes
// consecutive entries, so an already-saved query won't double up) so
// an accidental clear is one Alt+↑ away from recovery.
document.getElementById('clear-btn')?.addEventListener('click', () => {
const ta = document.getElementById('request');
if (!ta) return;
pushHistory(ta.value);
ta.value = '';
ta.setSelectionRange(0, 0);
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
// Cancel button (sibling of the Send button, hidden by default; runQuery
// unhides it for the duration of an in-flight POST /api/exec). Firing
// POST /api/cancel/ in parallel reaches the server on a different
// worker thread and triggers pg_cancel_backend on the running pid; the
// original /api/exec then comes back with a 57014 the renderer surfaces
// as the standard error banner.
document.getElementById('cancel-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('cancel-btn');
const id = btn.dataset.requestId;
if (!id) return;
btn.disabled = true;
try {
await fetch(`/api/cancel/${encodeURIComponent(id)}`, { method: 'POST' });
} catch (e) {
// Swallow: the in-flight /api/exec will still come back, with or
// without our cancel landing. Re-enable the button so a follow-up
// click is possible if the first didn't make it.
btn.disabled = false;
}
});
// Expose the env that the global runQuery reads (function declarations
// are hoisted, so the named functions below are safe to reference here
// even though they appear later in the IIFE). This MUST happen before
// setupWhereMode / setupCircuitMode runs, because both can auto-replay
// a carry-over query via runQuery, and if __provsqlStudio is still
// undefined at that point the fallback default `{mode: 'where', ...}`
// kicks in : the where-mode wrap then fires on /circuit pages and
// explodes on aggregation circuits with "Wrong type of gate".
window.__provsqlStudio = {
mode, refreshRelations, escapeHtml, escapeAttr, formatCell,
isRightAlignedType, pushHistory,
};
if (mode === 'where') setupWhereMode();
else setupCircuitMode();
// The setup call has to come AFTER the SQL_KEYWORDS const declaration
// below: function declarations are hoisted but `const` is not, so calling
// setupSqlSyntaxHighlight() any earlier would hit the temporal dead zone
// when refresh() invokes highlightSql() and reads SQL_KEYWORDS.
/* ──────── SQL syntax highlight (textarea +
overlay) ──────── */
// Lightweight tokenizer: keyword / function / string / number / comment /
// operator / identifier / whitespace. Single regex with named alternates so
// the relative order is preserved (comments before strings before
// identifiers). Brand-coloured via the hl-* classes in app.css.
const SQL_KEYWORDS = new Set(([
'select','from','where','and','or','not','in','is','null','any','some',
'join','inner','outer','left','right','full','cross','natural','on','using',
'group','by','order','having','distinct','all',
'union','intersect','except','as','with','recursive',
'insert','into','values','update','set','delete','returning',
'create','table','view','index','schema','sequence','extension','function',
'drop','alter','add','column','rename','to',
'primary','key','foreign','references','unique','default','constraint','check',
'case','when','then','else','end',
'limit','offset','fetch','first','rows','row','only',
'true','false','asc','desc','nulls',
'between','like','ilike','similar','overlaps',
'exists','cast','collate','escape',
'begin','commit','rollback','savepoint','transaction','isolation','level',
'grant','revoke','to','from','public','role','user',
'if','exists','temp','temporary','unlogged','materialized',
'analyze','explain','vacuum','copy','do','language',
'array','within','filter','over','partition','window','range','unbounded','preceding','following','current','interval',
]).map(s => s.toLowerCase()));
function escHtml(s) {
return s.replace(/&/g, '&').replace(//g, '>');
}
function highlightSql(text) {
// Token alternates, in priority order:
// 1 = line comment -- ...
// 2 = block comment /* ... */
// 3 = single-quoted string (with escaped '')
// 4 = dollar-quoted string $tag$ ... $tag$
// 5 = double-quoted identifier "..."
// 6 = number
// 7 = identifier
// 8 = operator
// 9 = whitespace (passthrough)
const re = /(--[^\n]*)|(\/\*[\s\S]*?\*\/)|('(?:[^']|'')*'?)|(\$([A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*?\$\5\$)|("(?:[^"]|"")*"?)|(\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|([A-Za-z_][A-Za-z0-9_]*)|([+\-*\/<>=!,;().|&%])|(\s+)/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(text)) !== null) {
// Anything between matches is unrecognized; emit as-is (escaped).
if (m.index > lastIdx) out += escHtml(text.slice(lastIdx, m.index));
lastIdx = re.lastIndex;
if (m[1]) out += `${escHtml(m[1])}`;
else if (m[2]) out += `${escHtml(m[2])}`;
else if (m[3]) out += `${escHtml(m[3])}`;
else if (m[4]) out += `${escHtml(m[4])}`;
else if (m[6]) out += `${escHtml(m[6])}`;
else if (m[7]) out += `${escHtml(m[7])}`;
else if (m[8]) {
const w = m[8];
if (SQL_KEYWORDS.has(w.toLowerCase())) {
out += `${escHtml(w)}`;
} else {
// Function-call heuristic: identifier immediately followed by `(`.
const after = text.charAt(re.lastIndex);
if (after === '(') {
out += `${escHtml(w)}`;
} else {
out += escHtml(w);
}
}
}
else if (m[9]) out += `${escHtml(m[9])}`;
else if (m[10]) out += m[10]; // whitespace passthrough
}
if (lastIdx < text.length) out += escHtml(text.slice(lastIdx));
// Trailing newline keeps the
's last-line height aligned with the
// textarea (which always reserves space for a trailing newline).
return out + '\n';
}
function setupSqlSyntaxHighlight() {
const ta = document.getElementById('request');
const hlPre = document.getElementById('request-hl');
if (!ta || !hlPre) return;
const code = hlPre.querySelector('code');
// Restore the user's preferred textarea height across reloads and
// mode switches. ta.style.height is the same inline style the browser
// writes on vertical drag, so round-tripping through it avoids the
// box-sizing drift you'd get from offsetHeight/clientHeight.
try {
const saved = localStorage.getItem('ps.editorHeight');
if (saved && /^\d+(\.\d+)?px$/.test(saved)) {
const px = parseFloat(saved);
if (px >= 40 && px <= 4000) ta.style.height = saved;
}
} catch (e) { /* localStorage disabled / quota: skip */ }
function refresh() { code.innerHTML = highlightSql(ta.value); }
function syncScroll() {
hlPre.scrollTop = ta.scrollTop;
hlPre.scrollLeft = ta.scrollLeft;
}
ta.addEventListener('input', refresh);
ta.addEventListener('scroll', syncScroll);
// Re-sync after textarea resize: pre is positioned absolutely so its
// box follows automatically, but scroll position can drift. Same
// observer persists the user-set height; the initial firing during
// restore writes back the value we just read, which is harmless.
new ResizeObserver(() => {
syncScroll();
const h = ta.style.height;
if (h) {
try { localStorage.setItem('ps.editorHeight', h); }
catch (e) { /* skip */ }
}
}).observe(ta);
refresh();
}
setupSqlSyntaxHighlight();
/* ──────── Where mode ──────── */
async function setupWhereMode() {
await refreshRelations();
const body = document.getElementById('result-body');
body.addEventListener('mouseover', (e) => onResultHover(e, true));
body.addEventListener('mouseout', (e) => onResultHover(e, false));
body.addEventListener('click', (e) => {
const btn = e.target.closest('[data-jump-circuit]');
if (!btn) return;
// Same carry rule as the mode-switch tab : only auto-replay in the
// new mode if the textarea still matches the last-run SQL. The
// preloadCircuit UUID is always carried so the DAG renders
// regardless of whether we re-run the query.
window.ProvsqlStudio.carryQueryForSwitch();
sessionStorage.setItem('ps.preloadCircuit', btn.dataset.jumpCircuit);
window.location.href = '/circuit';
});
// Quick-nav chips at the top of the sidebar: scroll the sidebar pane
// so the target header lands at the top. We do this explicitly rather
// than via scrollIntoView, because scrollIntoView picks the nearest
// scrollable ancestor and may end up scrolling the page (sticky nav
// hides the header) instead of the sidebar's own overflow pane.
document.getElementById('sidebar-body').addEventListener('click', (e) => {
const btn = e.target.closest('.wp-rel-nav__btn');
if (!btn) return;
const target = document.getElementById(btn.dataset.target);
const sidebar = document.getElementById('sidebar');
if (!target || !sidebar) return;
const offset = target.getBoundingClientRect().top
- sidebar.getBoundingClientRect().top
+ sidebar.scrollTop
- 10; // small breathing gap above the header
sidebar.scrollTo({ top: Math.max(0, offset), behavior: 'smooth' });
});
// Auto-replay only when the carried query had actually been executed
// in the original mode (carriedRan). Plain reloads, history nav, and
// unrun drafts must NOT auto-execute, because re-running a
// side-effecting query (typically add_provenance) on switch is
// dangerous.
if (carriedRan && document.getElementById('request').value.trim()) {
runQuery({ preventDefault() {} });
}
}
// Last fetched /api/relations response, kept so the "Input gates only"
// toggle can re-render without an extra round-trip.
let _lastRelations = [];
const INPUT_ONLY_KEY = 'ps.where.inputOnly';
function inputOnlyEnabled() {
// Default ON : the typical use case is inspecting the source tables
// of a query, which are by construction input-gated.
const v = sessionStorage.getItem(INPUT_ONLY_KEY);
return v === null ? true : v === '1';
}
function setInputOnly(on) {
sessionStorage.setItem(INPUT_ONLY_KEY, on ? '1' : '0');
}
async function refreshRelations() {
let relations;
try {
const resp = await fetch('/api/relations');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
relations = await resp.json();
} catch (e) {
document.getElementById('sidebar-body').innerHTML =
`
Failed to load relations: ${escapeHtml(e.message)}
`;
return;
}
_lastRelations = relations;
renderRelations(relations);
}
function renderRelations(relations) {
const body = document.getElementById('sidebar-body');
if (!relations.length) {
body.innerHTML = '
No provenance-tagged relations. Try SELECT add_provenance(\'mytable\').
';
return;
}
// Apply the "input gates only" filter : a relation passes when its
// first row's provsql token is an input gate. Empty relations
// (first_gate_type == null) are dropped under the filter, since
// there's no input row to point at; rendering them would be noise.
const inputOnly = inputOnlyEnabled();
const totalCount = relations.length;
const filtered = inputOnly
? relations.filter(r => r.first_gate_type === 'input')
: relations;
const hiddenCount = totalCount - filtered.length;
const toggleHtml = `
`;
if (!filtered.length) {
body.innerHTML = toggleHtml +
'
All provenance-tracked relations carry derived gates. Untick the filter to show them.
';
bindInputOnlyToggle();
return;
}
relations = filtered;
// Quick-nav chips at the top: one per relation, click scrolls the
// matching section into view inside the sidebar's own scroll pane.
const navHtml = relations.length > 1
? ``
: '';
body.innerHTML = toggleHtml + navHtml + relations.map(rel => {
// Skip the rewriter-added `provsql` UUID column when displaying; its
// value is already exposed as the row id (used for the hover-highlight).
// where_provenance numbers cells by user-column position (1-indexed,
// ignoring provsql), which matches the original i+1 since provsql sits
// at the end of the column list.
const visible = rel.columns
.map((c, i) => ({ c, i }))
.filter(({ c }) => c.name !== 'provsql');
// When list_relations capped the SELECT, surface the cap inline
// ("100 of ~50000 tuples") so the user knows the sidebar isn't a
// full mirror of the table. The "~" reflects pg_class.reltuples
// being a planner estimate, not an exact count.
const tuples = (() => {
const shown = rel.rows.length;
if (rel.truncated) {
const total = rel.estimated_rows;
if (total != null && total > shown) {
return `${shown} of ~${total} tuples`;
}
return `${shown}+ tuples (capped)`;
}
return `${shown} tuples`;
})();
return `
`;
}).join('');
bindInputOnlyToggle();
}
// Wire the "Input gates only" checkbox emitted by renderRelations. The
// toggle persists in sessionStorage and re-renders from the cached
// /api/relations response : no extra round-trip just to flip the filter.
function bindInputOnlyToggle() {
const cb = document.getElementById('opt-input-only');
if (!cb) return;
cb.addEventListener('change', () => {
setInputOnly(cb.checked);
renderRelations(_lastRelations);
});
}
// Stable, CSS-safe id for a relation's section (avoids periods, quotes, ...).
function sectionId(regclass) {
return 'rel-' + String(regclass).replace(/[^A-Za-z0-9_]/g, '_');
}
// Companion id for the relation's header element (table name + meta).
function headerId(regclass) {
return 'hdr-' + String(regclass).replace(/[^A-Za-z0-9_]/g, '_');
}
function onResultHover(e, on) {
const cell = e.target.closest('.wp-result__cell');
if (!cell) return;
cell.classList.toggle('is-hover', on);
let firstSource = null;
(cell.dataset.sources || '').split(';').filter(Boolean).forEach(id => {
const el = document.getElementById(id);
if (el) {
el.classList.toggle('is-source', on);
if (on && !firstSource) firstSource = el;
}
});
// Bring the first highlighted source into view if it's outside the
// sidebar's scroll viewport. block:'nearest' avoids unnecessary scroll
// when the cell is already visible.
if (on && firstSource) {
firstSource.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
/* ──────── Circuit mode ──────── */
/* ──────── query history ──────── */
const HISTORY_KEY = 'ps.history';
const HISTORY_CAP = 50;
let _historyCursor = -1; // -1 = current draft; 0..N-1 = nth-most-recent saved entry
let _historyDriving = false; // suppress the cursor reset when WE set ta.value
function loadHistory() {
try {
const raw = localStorage.getItem(HISTORY_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveHistory(arr) {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(arr.slice(0, HISTORY_CAP)));
} catch {}
}
function pushHistory(sql) {
const trimmed = String(sql || '').trim();
if (!trimmed) return;
const arr = loadHistory();
if (arr.length && arr[0] === trimmed) return; // skip exact-duplicate consecutive entries
arr.unshift(trimmed);
saveHistory(arr);
}
function stepHistory(direction) {
const arr = loadHistory();
if (!arr.length) return;
const ta = document.getElementById('request');
if (_historyCursor === -1) _draft = ta.value;
const valueAt = (i) => i === -1 ? (_draft || '') : arr[i];
const current = ta.value;
// Skip entries identical to what's already in the textarea so Alt+↑/↓
// always produces a visible change (history can contain consecutive
// duplicates separated by other queries, and the draft can match arr[0]).
let next = _historyCursor + direction;
while (next >= -1 && next < arr.length && valueAt(next) === current) {
next += direction;
}
if (next < -1 || next >= arr.length) return;
_historyCursor = next;
_historyDriving = true;
ta.value = valueAt(next);
ta.dispatchEvent(new Event('input')); // refresh syntax highlight
_historyDriving = false;
}
let _draft = '';
// Reset history-cursor when the USER edits the textarea (so the next
// Alt+↑ starts from the most-recent entry again). When we set ta.value
// ourselves from stepHistory, the synthetic input event must NOT reset
// the cursor : that's what _historyDriving guards against.
document.getElementById('request').addEventListener('input', () => {
if (!_historyDriving) _historyCursor = -1;
});
function setupHistoryDropdown() {
const btn = document.getElementById('history-btn');
const menu = document.getElementById('history-menu');
if (!btn || !menu) return;
function renderMenu() {
const arr = loadHistory();
if (!arr.length) {
menu.innerHTML = '