/* 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, matchesProvType, 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 = `
Input gates only${hiddenCount > 0 ? ` (${hiddenCount} hidden) ` : ''}
`;
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
? `${
relations.map(rel =>
`${escapeHtml(rel.regclass)} `
).join('')
} `
: '';
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 `
${visible.map(({ c }) => {
const cls = isRightAlignedType(c.type_name) ? ' class="is-right"' : '';
return `${escapeHtml(c.name)} `;
}).join('')}
${rel.rows.map(r => `
${visible.map(({ c, i }) => {
const id = `${rel.regclass}:${r.uuid}:${i+1}`;
const cls = isRightAlignedType(c.type_name) ? ' is-right' : '';
return `${formatCell(r.values[i], c.type_name)} `;
}).join('')}
`).join('')}
`;
}).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 = ' No saved queries yet. ';
return;
}
const oneLine = (s) => s.replace(/\s+/g, ' ').trim();
menu.innerHTML = arr.map((sql, i) =>
`${escapeHtml(oneLine(sql).slice(0, 200))} `
).join('') + 'Clear history ';
}
function open() {
renderMenu();
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) open(); else close();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
});
menu.addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
if (li.dataset.clear) {
saveHistory([]);
renderMenu();
return;
}
const arr = loadHistory();
const i = Number(li.dataset.i);
const sql = arr[i];
if (sql == null) return;
const ta = document.getElementById('request');
ta.value = sql;
ta.dispatchEvent(new Event('input'));
_historyCursor = i;
close();
ta.focus();
});
}
let _currentConn = null;
async function fetchConnInfo() {
const el = document.getElementById('conn-info');
// The trigger is the wrapping (plug icon +
// status dot). We set the tooltip on the button so hovering the
// icon also surfaces it; the inner span carries only the colour
// class (.is-offline / connected).
const trigger = document.getElementById('conn-dot');
const dot = document.querySelector('.wp-nav__dot');
if (!el) return;
let reason = '';
try {
const resp = await fetch('/api/conn');
if (!resp.ok) {
// /api/conn responds with 503 + JSON {error, reason} when the
// pool can't reach PG. Pull the reason out for the dot tooltip.
try {
const body = await resp.json();
reason = body.reason || body.error || `HTTP ${resp.status}`;
} catch (_) {
reason = `HTTP ${resp.status}`;
}
throw new Error(reason);
}
const c = await resp.json();
_currentConn = c;
// Surface the numeric server version on the global so circuit.js
// can gate version-specific dropdown options (currently the
// `temporal` compiled semiring, which depends on tstzmultirange
// and is therefore PG14+ only). Re-syncs the eval strip if it has
// already been initialised, since /api/conn lands asynchronously.
window.ProvsqlStudio.serverVersion = Number(c.server_version) || 0;
if (window.ProvsqlStudio.syncCompiledSemiringAvailability) {
window.ProvsqlStudio.syncCompiledSemiringAvailability();
}
// Footer version chip: ProvSQL extension version (NULL when the
// extension is not installed on the connected database) and
// Studio package version. Discreet — runs once per /api/conn poll
// and is a no-op if the spans aren't present (e.g. tests).
const extEl = document.getElementById('version-ext');
const studioEl = document.getElementById('version-studio');
if (extEl) extEl.textContent = c.extension_version ? `ProvSQL ${c.extension_version}` : '';
if (studioEl) studioEl.textContent = c.studio_version ? `Studio ${c.studio_version}` : '';
el.textContent = `${c.user}@${c.database}`;
if (c.host) el.title = `host: ${c.host}`;
if (dot) dot.classList.remove('is-offline');
if (trigger) {
// Surface only the server endpoint. The DB name is already
// shown right next to the dot in the switcher chip, so we
// don't repeat it; user@host:port is what the chip can't say.
const where = c.host
? (c.port ? `${c.host}:${c.port}` : c.host)
: 'local socket';
trigger.title = `connected to ${c.user}@${where} (click to change)`;
}
// No DSN was given on the CLI and no PG* env hinted at a DB, so
// the server fell back to the postgres maintenance DB. Show a
// dismissible banner pointing the user at the switcher; once
// they pick a real DB the flag clears server-side.
_renderDbAutoHint(!!c.db_is_auto);
// Render search_path with `provsql` shown as a locked chip so the
// user can tell at a glance which segment is enforced by Studio.
// The compose helper on the server already pinned provsql to the
// end; we just style that segment.
const sp = document.getElementById('searchpath-val');
if (sp) {
const path = c.search_path || '';
const parts = path.split(',').map(s => s.trim()).filter(Boolean);
sp.innerHTML = parts.map(p => {
if (p === 'provsql' || p === '"provsql"') {
return ` provsql `;
}
return escapeHtml(p);
}).join(', ');
}
} catch (e) {
// Don't clobber _currentConn or the displayed identity on a
// transient blip: the chip keeps showing the last-known db name
// (so the dropdown still works); only the dot turns red and the
// tooltip explains.
if (dot) dot.classList.add('is-offline');
if (trigger) {
trigger.title = `Cannot reach the database: ${reason || e.message || 'cannot connect to PostgreSQL'}`;
}
}
setupDbSwitcher();
}
function setupDbSwitcher() {
const btn = document.getElementById('conn-info');
const menu = document.getElementById('dbmenu');
if (!btn || !menu || btn.dataset.bound === '1') return;
btn.dataset.bound = '1';
async function openMenu() {
btn.setAttribute('aria-expanded', 'true');
menu.hidden = false;
menu.innerHTML = 'loading… ';
try {
const resp = await fetch('/api/databases');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const dbs = await resp.json();
const cur = _currentConn ? _currentConn.database : null;
menu.innerHTML = dbs.map(name =>
`${escapeHtml(name)} `
).join('') || '(no accessible databases) ';
} catch (e) {
menu.innerHTML = `Failed: ${escapeHtml(e.message)} `;
}
}
function closeMenu() {
btn.setAttribute('aria-expanded', 'false');
menu.hidden = true;
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.hidden) openMenu(); else closeMenu();
});
document.addEventListener('click', (e) => {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) closeMenu();
});
menu.addEventListener('click', async (e) => {
const li = e.target.closest('li[data-db]');
if (!li) return;
const target = li.dataset.db;
if (_currentConn && target === _currentConn.database) {
closeMenu();
return;
}
menu.innerHTML = 'switching… ';
let resp;
try {
resp = await fetch('/api/conn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ database: target }),
});
} catch (err) {
closeMenu();
return;
}
if (!resp.ok) {
closeMenu();
return;
}
// Reloading is the cleanest way to reset every cached relation list,
// result table, circuit cache, etc., to the new database's contents.
// The previous query is meaningless against the new database (table
// names rarely match), so we wipe the textarea : push the current
// text to history first so the user can recover it via Alt+↑.
const ta = document.getElementById('request');
if (ta) pushHistory(ta.value);
sessionStorage.removeItem('ps.sql');
sessionStorage.removeItem('ps.sql.ran');
sessionStorage.removeItem('ps.lastRunSql');
if (ta) ta.value = '';
window.location.reload();
});
}
/* ──────── Config popover (provsql.active + provsql.verbose_level) ──────── */
function setupConfigPanel() {
const btn = document.getElementById('config-btn');
const panel = document.getElementById('config-panel');
const active = document.getElementById('cfg-active');
const verb = document.getElementById('cfg-verbose');
const status = document.getElementById('cfg-status');
if (!btn || !panel || !active || !verb) return;
let loaded = false;
const verbOut = document.getElementById('cfg-verbose-out');
const depth = document.getElementById('cfg-depth');
const depthOut = document.getElementById('cfg-depth-out');
const sidebarRows = document.getElementById('cfg-sidebar-rows');
const resultRows = document.getElementById('cfg-result-rows');
const circuitNodes = document.getElementById('cfg-circuit-nodes');
const timeout = document.getElementById('cfg-timeout');
const sp = document.getElementById('cfg-search-path');
const tsp = document.getElementById('cfg-tool-search-path');
const simplify = document.getElementById('cfg-simplify-on-load');
const mcSeed = document.getElementById('cfg-monte-carlo-seed');
const rvSamples = document.getElementById('cfg-rv-mc-samples');
async function loadConfig() {
try {
const resp = await fetch('/api/config');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const cfg = await resp.json();
const eff = cfg.effective || {};
active.checked = (eff['provsql.active'] || 'on') !== 'off';
verb.value = eff['provsql.verbose_level'] || '0';
if (verbOut) verbOut.textContent = verb.value;
if (simplify) {
simplify.checked = (eff['provsql.simplify_on_load'] || 'on') !== 'off';
}
if (mcSeed && eff['provsql.monte_carlo_seed'] != null) {
mcSeed.value = String(eff['provsql.monte_carlo_seed']);
}
if (rvSamples && eff['provsql.rv_mc_samples'] != null) {
rvSamples.value = String(eff['provsql.rv_mc_samples']);
}
const opts = cfg.options || {};
if (depth && opts.max_circuit_depth != null) {
depth.value = String(opts.max_circuit_depth);
if (depthOut) depthOut.textContent = depth.value;
}
if (sidebarRows && opts.max_sidebar_rows != null) {
sidebarRows.value = String(opts.max_sidebar_rows);
}
if (resultRows && opts.max_result_rows != null) {
resultRows.value = String(opts.max_result_rows);
}
if (circuitNodes && opts.max_circuit_nodes != null) {
circuitNodes.value = String(opts.max_circuit_nodes);
}
if (timeout && opts.statement_timeout_seconds != null) {
timeout.value = String(opts.statement_timeout_seconds);
}
if (sp && opts.search_path != null) {
sp.value = opts.search_path;
}
if (tsp && opts.tool_search_path != null) {
tsp.value = opts.tool_search_path;
}
loaded = true;
showStatus('');
} catch (e) {
showStatus(`Failed to load: ${e.message}`, true);
}
}
function showStatus(msg, isErr) {
if (!msg) {
status.hidden = true;
status.classList.remove('is-error');
return;
}
status.textContent = msg;
status.classList.toggle('is-error', !!isErr);
status.hidden = false;
}
async function setGuc(name, value) {
try {
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: name, value: String(value) }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${resp.status}`);
}
showStatus(`Saved: ${name} = ${value}`);
// Auto-clear the status after a couple of seconds.
clearTimeout(setGuc._t);
setGuc._t = setTimeout(() => showStatus(''), 2000);
} catch (e) {
showStatus(e.message, true);
}
}
function open() {
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
if (!loaded) loadConfig();
}
function close() {
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.hidden) open(); else close();
});
document.addEventListener('click', (e) => {
if (!panel.hidden && !panel.contains(e.target) && e.target !== btn) close();
});
active.addEventListener('change', () => {
setGuc('provsql.active', active.checked ? 'on' : 'off');
});
if (simplify) {
simplify.addEventListener('change', () => {
setGuc('provsql.simplify_on_load', simplify.checked ? 'on' : 'off');
});
}
if (mcSeed) {
// -1 means "non-deterministic"; clamp absurd negatives but allow
// any non-negative literal seed (including 0).
mcSeed.addEventListener('change', () => {
const raw = parseInt(mcSeed.value, 10);
const n = Number.isFinite(raw) ? Math.max(-1, raw) : -1;
mcSeed.value = String(n);
setGuc('provsql.monte_carlo_seed', n);
});
}
if (rvSamples) {
// 0 is meaningful (disables the MC fallback); clamp negatives.
rvSamples.addEventListener('change', () => {
const raw = parseInt(rvSamples.value, 10);
const n = Number.isFinite(raw) ? Math.max(0, raw) : 10000;
rvSamples.value = String(n);
setGuc('provsql.rv_mc_samples', n);
});
}
// Live-update the value display as the slider drags; only POST on
// release (`change`) so we don't hammer /api/config every step.
verb.addEventListener('input', () => {
if (verbOut) verbOut.textContent = verb.value;
});
verb.addEventListener('change', () => {
const n = Math.max(0, Math.min(100, parseInt(verb.value || '0', 10) || 0));
verb.value = String(n);
if (verbOut) verbOut.textContent = verb.value;
setGuc('provsql.verbose_level', n);
});
if (depth) {
depth.addEventListener('input', () => {
if (depthOut) depthOut.textContent = depth.value;
});
depth.addEventListener('change', () => {
const n = Math.max(1, Math.min(50, parseInt(depth.value || '8', 10) || 8));
depth.value = String(n);
if (depthOut) depthOut.textContent = depth.value;
setGuc('max_circuit_depth', n);
});
}
if (sidebarRows) {
sidebarRows.addEventListener('change', async () => {
const n = Math.max(1, Math.min(5000, parseInt(sidebarRows.value || '100', 10) || 100));
sidebarRows.value = String(n);
await setGuc('max_sidebar_rows', n);
// Refresh the where-mode sidebar so the new cap takes effect
// immediately rather than on the next mode entry.
if (document.body.classList.contains('mode-where')) {
refreshRelations();
}
});
}
if (resultRows) {
resultRows.addEventListener('change', () => {
const n = Math.max(1, Math.min(100000, parseInt(resultRows.value || '1000', 10) || 1000));
resultRows.value = String(n);
setGuc('max_result_rows', n);
});
}
if (circuitNodes) {
circuitNodes.addEventListener('change', () => {
const n = Math.max(10, Math.min(10000, parseInt(circuitNodes.value || '200', 10) || 200));
circuitNodes.value = String(n);
setGuc('max_circuit_nodes', n);
});
}
if (timeout) {
timeout.addEventListener('change', () => {
const n = Math.max(1, Math.min(3600, parseInt(timeout.value || '30', 10) || 30));
timeout.value = String(n);
setGuc('statement_timeout_seconds', n);
});
}
if (sp) {
// Persist on blur and on Enter so the user's typing isn't saved
// mid-edit. The trailing `, provsql` is enforced server-side; the
// user only types the leading schemas they care about.
const commit = () => {
const v = (sp.value || '').trim();
sp.value = v;
setGuc('search_path', v);
// Refresh the search_path readout under the query box so it
// reflects the new value immediately rather than on the next
// 5s connection-info poll.
fetchConnInfo();
};
sp.addEventListener('blur', commit);
sp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sp.blur(); }
});
}
if (tsp) {
// provsql.tool_search_path: same blur-and-Enter commit pattern as
// search_path. Empty string falls back to the server's PATH.
const commitTsp = () => {
const v = (tsp.value || '').trim();
tsp.value = v;
setGuc('tool_search_path', v);
};
tsp.addEventListener('blur', commitTsp);
tsp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); tsp.blur(); }
});
}
// Probability-display decimals : pure UI setting (no server roundtrip),
// persisted in localStorage so the choice survives reloads. The eval
// strip's float branch reads getProbDecimals() at render time.
const probDec = document.getElementById('cfg-prob-decimals');
if (probDec) {
probDec.value = String(getProbDecimals());
probDec.addEventListener('change', () => {
const n = Math.max(0, Math.min(15, parseInt(probDec.value || '4', 10)));
probDec.value = String(n);
try { localStorage.setItem('ps.probDecimals', String(n)); } catch {}
});
}
}
// Default rounding for probability results. Configurable via the Config
// panel's "Probability decimals" field; falls back to 4 when unset.
function getProbDecimals() {
const raw = (() => { try { return localStorage.getItem('ps.probDecimals'); } catch { return null; } })();
const n = parseInt(raw || '', 10);
return Number.isFinite(n) && n >= 0 && n <= 15 ? n : 4;
}
window.ProvsqlStudio.getProbDecimals = getProbDecimals;
// Schema browser: top-nav button opening a popover that lists every
// SELECT-able relation grouped by schema, with a search box and click
// to insert "schema.relation" at the cursor in the textarea. The
// /api/schema fetch is lazy (first open) and the result is cached for
// the page session: if the schema actually changes (CREATE TABLE etc.),
// a page reload re-fetches.
function setupSchemaPanel() {
const btn = document.getElementById('schema-btn');
const panel = document.getElementById('schema-panel');
const body = document.getElementById('schema-body');
const search = document.getElementById('schema-search');
if (!btn || !panel || !body || !search) return;
let loaded = false;
let entries = [];
async function load() {
body.innerHTML = 'Loading…
';
try {
const resp = await fetch('/api/schema');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
entries = await resp.json();
loaded = true;
// Successful reload : clear the dirty flag so subsequent opens
// reuse the cache until the next exec.
if (window.ProvsqlStudio?.metadata) {
window.ProvsqlStudio.metadata.schemaDirty = false;
}
render();
} catch (e) {
body.innerHTML =
`Failed to load schema: ${escapeHtml(e.message || String(e))}
`;
}
}
function render() {
const q = (search.value || '').trim().toLowerCase();
const filtered = q
? entries.filter(r =>
r.schema.toLowerCase().includes(q) ||
r.table.toLowerCase().includes(q) ||
// Skip the bookkeeping `provsql` column so search results
// match the visible column list : typing "provsql" should not
// match every provenance-tracked relation through this hidden
// column (use the PROV pill for that).
r.columns.some(c => c.name !== 'provsql' && c.name.toLowerCase().includes(q))
)
: entries;
if (filtered.length === 0) {
body.innerHTML = 'No matches.
';
return;
}
const bySchema = new Map();
for (const r of filtered) {
if (!bySchema.has(r.schema)) bySchema.set(r.schema, []);
bySchema.get(r.schema).push(r);
}
// Within each schema, surface provenance-tracked tables first
// (the user's primary working surface), then plain tables/views,
// then mapping tables (rarely useful to inspect on their own;
// they are auto-discovered by the eval strip). Alphabetical
// within each rank, preserved by JS Array.sort's stability.
const relRank = r => r.is_mapping ? 2 : (r.has_provenance ? 0 : 1);
for (const rels of bySchema.values()) {
rels.sort((a, b) => relRank(a) - relRank(b));
}
let html = '';
for (const [schemaName, rels] of bySchema) {
html += '';
html += `
${escapeHtml(schemaName)} `;
for (const r of rels) {
const qname = `${r.schema}.${r.table}`;
// `bare_resolves` is the Python side's authoritative answer to
// "would `SELECT ... FROM
` find this exact relation
// under the current search_path?". Use the bare name only when
// the answer is yes; otherwise qualify so the click prefill
// actually executes against the relation the user clicked.
const insert = r.bare_resolves ? r.table : qname;
// Hide the bookkeeping `provsql` uuid column from the user-visible
// column list : its presence is what the PROV pill already signals.
const visibleCols = r.columns.filter(c => c.name !== 'provsql');
// Provenance-tracked tables (not mappings, not views) can have
// a provenance mapping created on any of their columns. Render
// each column name as a clickable span so the user can prefill
// the corresponding `create_provenance_mapping(...)` call.
const canMap = r.has_provenance && !r.is_mapping && r.kind === 'table';
// ProvSQL-extended column types carry circuit references rather
// than plain scalars, so query operators on them are intercepted by
// the planner hook. Flag them with a small terracotta pill so the
// user can spot them in the schema panel; matchesProvType handles
// both the unqualified form (provsql on search_path) and the
// qualified one.
const colPill = c => {
if (matchesProvType(c.type, 'random_variable')) {
return `rv `;
}
if (matchesProvType(c.type, 'agg_token')) {
return `agg `;
}
return '';
};
const cols = visibleCols.map(c => {
const pill = colPill(c);
// create_provenance_mapping needs a column whose value tags an
// input gate. random_variable / agg_token columns don't carry an
// extractable tag value (each row is a circuit gate or composite,
// not a scalar), so the click affordance would prefill a
// meaningless call. Render the column name as plain text in that
// case.
if (canMap && !pill) {
return `${escapeHtml(c.name)} `;
}
return `${escapeHtml(c.name)}${pill}`;
}).join(', ');
// Mapping is the more specific classification: a mapping view
// typically also carries an implicit provsql column (the planner
// re-injects it for any view that selects from a provenance-tracked
// table), but tagging it as both is noisy. Show only "mapping".
const showProv = r.has_provenance && !r.is_mapping;
const provCls = showProv ? ' wp-schema__rel--prov' : '';
// The PROV badge is split into PROV-TID / PROV-BID / PROV-OPAQUE
// when the per-relation metadata is known (1.6.0+). Older
// schemas leave `prov_kind` null and we fall back to a bare
// "prov". The qualified form is discreet on purpose : it
// matters most for probabilistic query evaluation (TID =
// independent leaves, BID = block-correlated) but is
// meaningful in other settings too. OPAQUE warns the user
// that the safe-query rewriter will refuse on the table.
let provLabel = 'prov';
let provTip = 'Provenance-tracked (provsql uuid column)';
let provKindCls = '';
if (r.prov_kind === 'tid') {
provLabel = 'prov-tid';
provTip = 'Provenance-tracked, independent leaves (TID): '
+ 'one independent random variable per row, '
+ 'standard probabilistic semantics.';
provKindCls = ' wp-schema__rel-prov--tid';
} else if (r.prov_kind === 'bid') {
provLabel = 'prov-bid';
provTip = 'Provenance-tracked, block-correlated (BID): '
+ 'rows sharing the same block key are mutually '
+ 'exclusive (set via repair_key).';
provKindCls = ' wp-schema__rel-prov--bid';
} else if (r.prov_kind === 'opaque') {
// OPAQUE keeps the bare "prov" label : the muted-tone
// pill is enough to flag "kind not certified" and
// "prov-opaque" reads as redundant against the tooltip.
// Mirrors the convention used by the result-table pill.
// Wording covers both flavours of opaque : tables marked
// opaque via user-supplied provsql values (set_table_info
// or the provenance_guard trigger), and views whose body
// the planner-hook classifier cannot certify TID / BID
// (e.g. multi-source join, sublink).
provTip = 'Provenance-tracked, opaque kind: the relation '
+ 'either carries user-supplied or shared provsql '
+ 'tokens, or its body has structure the classifier '
+ 'cannot certify TID / BID (multi-source join, '
+ 'sublink). The safe-query rewriter refuses to '
+ 'fire on it.';
provKindCls = ' wp-schema__rel-prov--opaque';
}
const provBadge = showProv
? `${provLabel} `
: '';
const mapBadge = r.is_mapping
? `mapping `
: '';
const titleSuffix =
(showProv ? ' · provenance-tracked' : '')
+ (r.is_mapping ? ' · provenance mapping' : '');
// Column count for the tooltip mirrors the comma-separated
// list rendered above: `visibleCols` is the post-filter view
// (hiding the bookkeeping `provsql` column when present), so
// its length is authoritative. Naive `r.columns.length - 1`
// would undercount views by one : views' planner-injected
// `provsql` column never lands in pg_attribute, so it isn't
// in `r.columns` to begin with, and the subtraction would
// remove a non-existent entry.
const visibleCount = visibleCols.length;
// add/remove_provenance only target plain tables (the underlying
// ALTER TABLE rejects views/matviews); mappings already serve a
// separate purpose so we don't offer the toggle on them.
const canAddRemove = r.kind === 'table' && !r.is_mapping;
let actions = '';
if (canAddRemove) {
if (r.has_provenance) {
actions =
``
+ ` prov `;
} else {
actions =
``
+ ` prov `;
}
}
// Outer is a div with role=button so the inner action buttons can
// be real elements (nested buttons aren't valid HTML).
html +=
``
+ `${escapeHtml(r.table)} `
+ `${escapeHtml(r.kind)} `
+ provBadge
+ mapBadge
+ (actions ? `${actions} ` : '');
if (cols) {
// `cols` is already escaped per-column inside the map above
// (it's a mix of HTML spans and escaped text), so don't double-escape.
html += `${cols} `;
}
html += `
`;
}
html += ' ';
}
body.innerHTML = html;
}
function insertAtCursor(text) {
const ta = document.getElementById('request');
if (!ta) return;
const start = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
const end = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(end);
const newPos = start + text.length;
ta.setSelectionRange(newPos, newPos);
ta.focus();
// Notify the syntax-highlight overlay (it listens for `input`).
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
// Used by the per-relation action buttons (add/remove_provenance):
// they generate a complete standalone query, so blow the previous
// textarea content away rather than concatenate.
function replaceQuery(text) {
const ta = document.getElementById('request');
if (!ta) return;
ta.value = text;
ta.setSelectionRange(text.length, text.length);
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
body.addEventListener('click', (e) => {
// Action elements (add/remove_provenance row buttons,
// create-mapping column spans) replace the textarea with a complete
// SELECT call so the user can review and run it directly. Match
// these first so they don't fall through to the row-level qname
// insert.
const action = e.target.closest('[data-action]');
if (action) {
const a = action.dataset.action;
const q = action.dataset.qname;
let sql;
if (a === 'add-prov' && q) {
sql = `SELECT add_provenance('${q}');`;
} else if (a === 'remove-prov' && q) {
sql = `SELECT remove_provenance('${q}');`;
} else if (a === 'create-mapping' && q && action.dataset.col) {
const col = action.dataset.col;
const table = action.dataset.table || q;
// Default mapping name `_ _mapping` is a sensible
// starting point; the user can tweak it in the textarea before
// hitting Send.
const mname = `${table}_${col}_mapping`;
sql = `SELECT create_provenance_mapping('${mname}', '${q}', '${col}');`;
}
if (sql) {
replaceQuery(sql);
close();
}
return;
}
const rel = e.target.closest('.wp-schema__rel');
if (!rel || !rel.dataset.qname) return;
// Clicking a relation row replaces the textarea with a ready-to-run
// SELECT * FROM ; in practice that is what the user
// wants nine times out of ten, so saving the keystrokes wins over
// the bare-name insert.
replaceQuery(`SELECT * FROM ${rel.dataset.qname};`);
close();
});
// The relation row is now a div role=button (so the inner action
// s nest validly). Wire Enter/Space so keyboard users can
// still activate the row insert.
body.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const rel = e.target.closest('.wp-schema__rel');
if (!rel || rel !== e.target || !rel.dataset.qname) return;
e.preventDefault();
replaceQuery(`SELECT * FROM ${rel.dataset.qname};`);
close();
});
search.addEventListener('input', () => { if (loaded) render(); });
search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
});
function open() {
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
// Re-fetch on open if either we never loaded, or an exec since the
// last load may have changed the schema (the dirty flag is set by
// runQuery and by the toolbar refresh button).
const dirty = window.ProvsqlStudio?.metadata?.schemaDirty;
if (!loaded || dirty) load();
setTimeout(() => search.focus(), 0);
}
function close() {
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.hidden) open(); else close();
});
document.addEventListener('click', (e) => {
if (!panel.hidden && !panel.contains(e.target) && e.target !== btn) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !panel.hidden) close();
});
// Toolbar refresh button. Forces a reload of the three metadata
// caches; schema reloads in place if its panel is open, the eval-
// strip caches reload lazily on next dropdown open via their dirty
// flags. Also briefly spins the icon so the user sees feedback even
// when the panel is closed.
const refreshBtn = document.getElementById('metadata-refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
if (window.ProvsqlStudio?.metadata) {
window.ProvsqlStudio.metadata.invalidateAll();
}
const icon = refreshBtn.querySelector('i');
if (icon) {
icon.classList.add('fa-spin');
setTimeout(() => icon.classList.remove('fa-spin'), 600);
}
if (!panel.hidden) load();
});
}
}
function setupGucToggles() {
const fs = document.getElementById('prov-scheme-fieldset');
const up = document.getElementById('opt-update-prov');
if (!fs || !up) return;
const radios = fs.querySelectorAll('input[name="prov-scheme"]');
// Persisted across mode switches. The stored value is the user's
// last *circuit-mode* pick; Where UI mode locks the selector to
// `where` but does not overwrite the stored value, so a
// circuit→where→circuit round-trip preserves the user's pick.
// `boolean`/`semiring`/`where`; default `semiring`.
const savedMode = sessionStorage.getItem('ps.opt.provScheme') || 'semiring';
const savedUpdate = sessionStorage.getItem('ps.opt.updateProv') === '1';
const setMode = (m) => {
radios.forEach((r) => { r.checked = (r.value === m); });
};
if (mode === 'where') {
setMode('where');
fs.classList.add('is-locked');
fs.title = 'Where UI mode requires where-provenance (the cell-highlight wrap depends on it). '
+ 'Switch to Circuit mode to pick Boolean or Semiring.';
radios.forEach((r) => { r.disabled = true; });
} else {
setMode(savedMode === 'where' || savedMode === 'boolean' ? savedMode : 'semiring');
}
up.checked = savedUpdate;
fs.addEventListener('change', () => {
if (mode === 'where') return;
const picked = fs.querySelector('input[name="prov-scheme"]:checked');
if (picked) sessionStorage.setItem('ps.opt.provScheme', picked.value);
});
up.addEventListener('change', () => {
sessionStorage.setItem('ps.opt.updateProv', up.checked ? '1' : '0');
});
}
function setupCircuitMode() {
document.getElementById('sidebar-title').textContent = 'Provenance Circuit';
document.getElementById('sidebar-body').innerHTML = circuitSidebarHtml();
document.getElementById('result-legend').innerHTML =
' Click a UUID / agg_token cell in the result to inspect its circuit.';
// Click handler on result-body for UUID/agg_token cells. We rely on the
// cell having data-circuit-uuid when it's clickable; set during render.
// data-row-prov (also set during render) is forwarded so the eval
// strip's "Condition on" auto-preset reflects the row the user just
// clicked, including when the target was the row's random_variable
// cell (whose scene root is the RV itself, not the row's prov).
document.getElementById('result-body').addEventListener('click', (e) => {
const cell = e.target.closest('.wp-result__cell.is-clickable');
if (!cell || !cell.dataset.circuitUuid) return;
loadCircuit(cell.dataset.circuitUuid, { rowProv: cell.dataset.rowProv || '' });
});
// If a query was carried over (mode switch / preload), run it so the
// user has clickable cells immediately; otherwise wait for them to type.
const carry = preloadCircuitUuid;
// Coming from where mode's per-row "→ Circuit" jump: the carried UUID
// was minted by a where-provenance wrap, so the same query must run
// with where_provenance on here for the resulting circuit to contain
// the project/eq gates the user is trying to inspect. Force the
// selector to the `where` flavour (the user can switch it back for
// follow-up runs); the radio flip is programmatic so it does not
// fire `change` and does not get persisted to sessionStorage.
if (carry) {
const r = document.querySelector('input[name="prov-scheme"][value="where"]');
if (r) r.checked = true;
}
// Load circuit.js so its init() can wire the toolbar buttons (zoom,
// show-uuids). loadCircuit() also calls this, but only
// when a circuit is being rendered : without the unconditional load
// here, a circuit-mode page that just runs a query (no carry, no
// immediate circuit fetch) would leave the toolbar buttons unbound.
//
// Microtask deferral: `ensureCircuitLib` closes over
// `_circuitLibPromise`, which is declared with `let` further down in
// this IIFE. setupCircuitMode runs synchronously during IIFE eval,
// so calling it directly here would hit a TDZ. Queueing as a
// microtask defers the call until after the IIFE returns, by which
// time the binding is initialised. The function is idempotent (it
// caches the promise), so the later loadCircuit() callers piggyback.
queueMicrotask(ensureCircuitLib);
// Auto-replay only when the carried query had actually been executed
// (carriedRan), or when we arrived via a where→circuit jump (carry):
// the jump implies a successful query run in where mode whose row
// the user clicked. On plain reload / unrun draft, do NOT re-execute
// (side-effects like add_provenance must not fire on their own).
const shouldReplay =
(carriedRan || carry) && document.getElementById('request').value.trim();
if (shouldReplay) {
runQuery({ preventDefault() {} }).then(() => {
if (carry) loadCircuit(carry);
});
} else if (carry) {
// No query but a preload UUID: render the circuit directly.
loadCircuit(carry);
}
}
async function loadCircuit(uuid, opts) {
await ensureCircuitLib();
window.ProvsqlCircuit.showLoading();
const depthArg = opts && Number.isFinite(opts.depth) ? opts.depth : null;
const url = `/api/circuit/${encodeURIComponent(uuid)}`
+ (depthArg != null ? `?depth=${depthArg}` : '');
let resp;
try {
resp = await fetch(url);
} catch (e) {
window.ProvsqlCircuit.showError(`Network error: ${e.message}`);
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
// 413: structured "circuit too large" payload. Render the
// actionable banner with a "Render at depth N-1" retry button
// that re-fires loadCircuit at a lower depth.
if (resp.status === 413 && err && err.error === 'circuit too large') {
// Pass rootUuid so the eval strip can still target the
// unrendered circuit: evaluation only needs the token, not a
// displayed DAG.
window.ProvsqlCircuit.showTooLarge(
err,
(lowerDepth) => loadCircuit(uuid, { depth: lowerDepth }),
{ rootUuid: uuid },
);
return;
}
window.ProvsqlCircuit.showError(err.error || `HTTP ${resp.status}`);
return;
}
const scene = await resp.json();
window.ProvsqlCircuit.renderCircuit(scene, {
rowProv: (opts && opts.rowProv) || '',
});
}
let _circuitLibPromise = null;
function ensureCircuitLib() {
if (window.ProvsqlCircuit) return Promise.resolve(window.ProvsqlCircuit);
if (_circuitLibPromise) return _circuitLibPromise;
_circuitLibPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/circuit.js';
s.onload = () => { try { window.ProvsqlCircuit.init(); } catch (e) {} resolve(window.ProvsqlCircuit); };
s.onerror = () => reject(new Error('failed to load circuit.js'));
document.body.appendChild(s);
});
return _circuitLibPromise;
}
function circuitSidebarHtml() {
return `
Provenance Circuit
Click a UUID cell to render.
`;
}
/* ──────── shared helpers ──────── */
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '"');
}
function formatCell(v, typeName) {
// UUID-typed columns render as a short/full pair so the circuit
// panel's "Show UUIDs" toggle can flip between abbreviated and full
// display via a body-level CSS class : no re-render needed. The
// outer span carries the full value as a title so hover always
// reveals the original even when collapsed.
const lowerType = (typeName || '').toLowerCase();
if ((lowerType === 'uuid' || lowerType === 'random_variable') && v != null && v !== '') {
const s = String(v);
// Match circuit.js's shortUuid so both views render UUIDs the same
// way: 4 hex chars + ellipsis is enough for cursory same/different
// identification; the full value is a "Show UUIDs" click away.
const shortStr = s.length > 4 ? s.slice(0, 4) + '…' : s;
return ``
+ `${escapeHtml(shortStr)} `
+ `${escapeHtml(s)} `
+ ` `;
}
// Multi-line text values (e.g. provsql.view_circuit's ASCII tree
// dump, or any TEXT column that contains newlines) need pre-wrap +
// monospace so newlines and indentation survive : the table cell's
// default whitespace handling collapses them otherwise.
if (typeof v === 'string' && v.indexOf('\n') !== -1) {
return `${escapeHtml(v)} `;
}
return escapeHtml(v == null ? '' : v);
}
// PostgreSQL pg_type names that conventionally render right-aligned:
// numerics (int / numeric / float / money) plus date/time/interval. Also
// agg_token, whose visible glyph is a number ("3 (*)") even though its
// underlying storage is a UUID.
const RIGHT_ALIGNED_TYPES = new Set([
'int2', 'int4', 'int8', 'smallint', 'integer', 'bigint',
'numeric', 'decimal',
'float4', 'float8', 'real',
'money',
'agg_token',
'random_variable',
'date', 'time', 'timetz', 'timestamp', 'timestamptz', 'interval',
'uuid',
]);
function isRightAlignedType(typeName) {
return RIGHT_ALIGNED_TYPES.has((typeName || '').toLowerCase());
}
// Recognise a ProvSQL-extended column type regardless of search_path:
// `random_variable` and `agg_token` may surface either unqualified
// (provsql is on the search_path) or qualified (`provsql.random_variable`).
// Used by the schema panel's column-list pills and by the result-table
// header to flag the same columns once the data is rendered.
function matchesProvType(typeName, base) {
const s = String(typeName || '').toLowerCase();
return s === base || s === `provsql.${base}`;
}
// Connection-editor popover anchored to the green/red status dot.
// Lets the user paste an arbitrary libpq DSN (host, port, user,
// password, dbname, options) so they can switch server / role
// without restarting the studio. Uses POST /api/conn { dsn: "…" }.
function setupConnEditor() {
const dot = document.getElementById('conn-dot');
const panel = document.getElementById('dsn-panel');
const input = document.getElementById('dsn-input');
const apply = document.getElementById('dsn-apply');
const status = document.getElementById('dsn-status');
if (!dot || !panel || !input || !apply || dot.dataset.bound === '1') return;
dot.dataset.bound = '1';
function showStatus(msg, isErr) {
if (!status) return;
if (!msg) { status.hidden = true; status.textContent = ''; status.classList.remove('is-error'); return; }
status.hidden = false;
status.textContent = msg;
status.classList.toggle('is-error', !!isErr);
}
function open() {
// Prefill with the password-stripped DSN the server reports.
// The user retypes the password when switching role/host if
// needed; we don't echo secrets back.
if (_currentConn && _currentConn.dsn) input.value = _currentConn.dsn;
panel.hidden = false;
dot.setAttribute('aria-expanded', 'true');
showStatus('');
input.focus();
input.select();
}
function close() {
panel.hidden = true;
dot.setAttribute('aria-expanded', 'false');
}
dot.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.hidden) open(); else close();
});
document.addEventListener('click', (e) => {
if (!panel.hidden && !panel.contains(e.target) && e.target !== dot) close();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); close(); }
else if (e.key === 'Enter') { e.preventDefault(); apply.click(); }
});
apply.addEventListener('click', async () => {
const dsn = (input.value || '').trim();
if (!dsn) { showStatus('Empty DSN', true); return; }
apply.disabled = true;
showStatus('Connecting…');
try {
const resp = await fetch('/api/conn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dsn }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
showStatus(data.reason || data.error || `HTTP ${resp.status}`, true);
return;
}
// Refresh the page so cached relations / circuit / result table
// reflect the new server's contents (same approach as the DB
// switcher's POST handler). sessionStorage carries the SQL
// textarea across the reload.
const ta = document.getElementById('request');
if (ta) sessionStorage.setItem('ps.sql', ta.value);
window.location.reload();
} catch (e) {
showStatus(`Network error: ${e.message}`, true);
} finally {
apply.disabled = false;
}
});
}
setupConnEditor();
// Show / clear the "no DB selected" hint banner. Lives at the top of
// the page (above the wp-shell grid) so it's the first thing the user
// sees on a freshly-launched studio with no --dsn. Single instance
// keyed by id so repeated /api/conn polls don't duplicate it.
function _renderDbAutoHint(show) {
const id = 'db-auto-hint';
let el = document.getElementById(id);
if (!show) {
if (el) el.remove();
return;
}
if (el) return;
el = document.createElement('div');
el.id = id;
el.className = 'wp-db-hint';
el.innerHTML =
` `
+ `No database picked at launch: currently on the postgres `
+ `maintenance DB. `
+ ``
+ `Pick a database… `;
// Insert directly after ; using body.firstChild was unsafe
// because whitespace text nodes sit before the nav element and
// pushed the banner above it.
const nav = document.querySelector('.wp-nav');
if (nav && nav.parentNode) {
nav.parentNode.insertBefore(el, nav.nextSibling);
} else {
document.body.appendChild(el);
}
// Open the database menu (the user@dbname chip in the top-right of
// the nav) on click. The chip's own click toggles the dropdown, so
// we forward the event to it. Crucially, the original click event
// ALSO bubbles up to a document-level handler in setupDbSwitcher
// that closes any open menu when the click target isn't the chip
// : without stopPropagation here, the menu would open and instantly
// close again.
const pick = document.getElementById('db-auto-pick');
if (pick) pick.onclick = (e) => {
e.stopPropagation();
const chip = document.getElementById('conn-info');
if (chip) chip.click();
};
}
})();
// Per-kind metadata used by the result-table `provsql` column header
// pill. Labels deliberately mirror the schema panel's prov-tid /
// prov-bid / prov-opaque pills so the two affordances read as the
// same idea. Lives at module scope rather than inside runQuery so
// the hoisted renderBlocks function can read it without hitting the
// const TDZ on the first call.
const CLASSIFIER_LABELS = {
tid: 'prov-tid',
bid: 'prov-bid',
// OPAQUE falls back to the bare "prov" label : the muted-tone
// styling on the pill is enough to signal "kind unknown" and
// "prov-opaque" reads as redundant against the explainer tooltip.
opaque: 'prov',
};
const CLASSIFIER_EXPLAINERS = {
tid: 'The query result is independent at the row level (TID): '
+ 'distinct output rows have disjoint lineages.',
bid: 'The query result is block-correlated (BID): rows sharing '
+ 'a block key value are mutually exclusive.',
opaque: 'The query result is opaque: correlations across rows are '
+ 'not certified by the classifier.',
};
/* Global runQuery: invoked by the form's inline onsubmit. POSTs to /api/exec
and renders the response into the result section. */
async function runQuery(ev) {
ev.preventDefault();
const env = window.__provsqlStudio || { mode: 'where', escapeHtml: s => s, escapeAttr: s => s, formatCell: v => v };
const sqlText = document.getElementById('request').value;
const head = document.getElementById('result-head');
const body = document.getElementById('result-body');
const count = document.getElementById('result-count');
const time = document.getElementById('result-time');
// Loading state.
body.innerHTML = `Running… `;
count.textContent = '…';
time.textContent = '…';
// Clear the previous run's truncation hint and notice / error banners
// so they don't linger next to the new query's "running…" placeholder.
// renderError still writes into result-banners on a failed POST, so
// wiping here is safe : the success path repopulates them on render.
const truncMark = document.getElementById('result-truncated');
if (truncMark) {
truncMark.textContent = '';
truncMark.hidden = true;
}
const banners = document.getElementById('result-banners');
if (banners) banners.innerHTML = '';
const t0 = performance.now();
// Wipe any previous circuit so it doesn't linger next to the new query's
// result. The user may click a UUID cell in the new result to render a
// fresh DAG; until then the canvas should be empty.
if (env.mode === 'circuit') {
window.ProvsqlCircuit?.clearScene?.();
}
// Cancel-button wiring: tag the in-flight request so /api/cancel/
// can resolve it back to the backend pid. The Send -> Cancel swap is
// deferred 100ms so very fast queries (which return before the timer
// fires) never flicker the row; clearTimeout in the finally branch
// cancels the swap, and the same finally restores Send unconditionally.
const requestId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const runBtn = document.getElementById('run-btn');
const cancelBtn = document.getElementById('cancel-btn');
if (cancelBtn) {
cancelBtn.dataset.requestId = requestId;
cancelBtn.disabled = false;
}
const swapTimer = setTimeout(() => {
if (cancelBtn) cancelBtn.hidden = false;
if (runBtn) runBtn.hidden = true;
}, 100);
const upEl = document.getElementById('opt-update-prov');
const provSchemeEl = document.querySelector('input[name="prov-scheme"]:checked');
let resp;
try {
resp = await fetch('/api/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sql: sqlText,
mode: env.mode,
prov_scheme: env.mode === 'where' ? 'where' : (provSchemeEl ? provSchemeEl.value : 'semiring'),
update_provenance: upEl ? upEl.checked : false,
request_id: requestId,
}),
});
} catch (e) {
renderError(`Network error: ${e.message}`);
return false;
} finally {
clearTimeout(swapTimer);
if (cancelBtn) cancelBtn.hidden = true;
if (runBtn) runBtn.hidden = false;
}
const dt = Math.round(performance.now() - t0);
time.textContent = dt;
if (!resp.ok) {
renderError(`HTTP ${resp.status}`);
return false;
}
const payload = await resp.json();
renderBlocks(payload.blocks || [], !!payload.wrapped, payload.notices || []);
// Append the just-submitted query to the persistent history (skipping
// exact-duplicate consecutive entries). We do this regardless of the
// server's outcome so users can recall a query that errored to fix it.
if (env.pushHistory) env.pushHistory(sqlText);
// Record the just-run SQL so a subsequent mode/database switch knows
// whether the textarea content was actually executed (the carry handler
// compares this to the textarea value to decide whether to set the
// ran-flag, which gates auto-replay in the new mode).
try { sessionStorage.setItem('ps.lastRunSql', sqlText); } catch {}
// After every successful exec in where mode, re-fetch relations so
// add_provenance results show up live.
if (env.mode === 'where' && env.refreshRelations) env.refreshRelations();
// Mark all metadata caches dirty: the user may have just run a CREATE
// TABLE, add_provenance, create_provenance_mapping, or a CREATE
// FUNCTION that defines a new custom-semiring wrapper. Each panel
// re-fetches lazily on next open.
if (window.ProvsqlStudio?.metadata?.invalidateAll) {
window.ProvsqlStudio.metadata.invalidateAll();
}
return false;
// Render a single NOTICE / WARNING / ERROR / INFO banner. Severity drives
// colour + icon; the literal severity tag is omitted (the visual style
// already conveys it). ProvSQL-emitted messages all carry a "ProvSQL: "
// prefix (provsql_error.h's macros prepend it); strip the prefix and
// render it as a brand pill so the source is obvious without duplicating
// it inline.
function renderDiag(severity, message, sqlstate) {
const sev = String(severity || 'NOTICE').toUpperCase();
let cls, icon;
if (sev === 'ERROR' || sev === 'FATAL' || sev === 'PANIC') {
cls = 'wp-error'; icon = 'fa-exclamation-circle';
} else if (sev === 'WARNING') {
cls = 'wp-warning'; icon = 'fa-exclamation-triangle';
} else {
cls = 'wp-notice'; icon = 'fa-info-circle';
}
const raw = message || '';
const m = raw.match(/^ProvSQL:\s*(.*)$/s);
const badge = m ? 'ProvSQL ' : '';
const text = m ? m[1] : raw;
// XX000 is the generic "internal_error" catch-all that provsql_error()
// raises (the C macro doesn't set a specific errcode); appending it
// adds noise without information, so skip it.
const tail = (sqlstate && sqlstate !== 'XX000')
? ` (SQLSTATE ${env.escapeHtml(sqlstate)})`
: '';
// Long messages (parse-tree dumps from provsql.verbose_level >= 50,
// full pre/post-rewrite SQL at >= 20) collapse behind a so
// they don't push the result table off-screen. The first line stays
// visible as the summary; clicking the disclosure triangle reveals
// the rest.
const newlineIdx = text.indexOf('\n');
const isLong = newlineIdx >= 0 && (
(text.match(/\n/g) || []).length > 1 || text.length > 240
);
if (isLong) {
const head = text.slice(0, newlineIdx);
const rest = text.slice(newlineIdx + 1);
return ``
+ ` ${badge}${env.escapeHtml(head)}${tail} `
+ `${env.escapeHtml(rest)}
`
+ ` `;
}
return ` ${badge}${env.escapeHtml(text)}${tail}
`;
}
// Recognises the classifier NOTICE emitted by the planner hook when
// provsql.classify_top_level is on. Three shapes :
// "ProvSQL: query result is (sources: schema.t1, schema.t2)"
// "ProvSQL: query result is (no provenance-tracked sources)"
// "ProvSQL: query result is OPAQUE"
// The OPAQUE form omits the parenthetical because the source list
// is only partial when the shape gate trips (a SubLink, set
// operation, GROUP BY ... hides relations from the rtable walk),
// and surfacing partial sources would falsely suggest completeness.
// Returns { kind, sources } on a match (sources is an array of
// identifier strings, possibly empty), or null otherwise.
function parseClassifierNotice(message) {
if (!message) return null;
const m = String(message).match(
/^ProvSQL:\s*query result is (TID|BID|OPAQUE)(?:\s*\((.*)\))?\s*$/
);
if (!m) return null;
const kind = m[1].toLowerCase();
const tail = (m[2] || '').trim();
let sources = [];
if (tail.startsWith('sources:')) {
sources = tail.slice('sources:'.length).split(',')
.map(s => s.trim())
.filter(s => s.length > 0);
}
return { kind, sources };
}
function renderBlocks(blocks, wrapped, notices) {
// /api/exec returns zero or more error blocks (from earlier failed
// statements) followed by the final block. We render only the final
// block in the result table; everything informational (failed-prelude
// errors, server NOTICE / WARNING messages, Studio's own observations
// via severity=INFO) goes into the dedicated #result-banners slot
// above the table : putting s straight into
is invalid
// HTML and the browser hoists them into the first .
const final = blocks[blocks.length - 1];
const earlier = blocks.slice(0, -1);
// Reset the truncation marker on every render; the rows branch
// re-shows it when final.truncated. Status / error / empty paths
// therefore never leak a stale "first N; more available" hint.
const truncMark = document.getElementById('result-truncated');
if (truncMark) {
truncMark.textContent = '';
truncMark.hidden = true;
}
const banners = document.getElementById('result-banners');
let bannerHtml = '';
// Earlier-failed prelude statements: render each as an ERROR banner
// alongside notices/warnings.
for (const b of earlier) {
if (b.kind === 'error') {
bannerHtml += renderDiag('ERROR', b.message, b.sqlstate);
}
}
// Server-side notices/warnings + Studio's own INFO observations.
// The classifier NOTICE emitted by the provsql.classify_top_level
// GUC is hoisted out of the banner stream and used to upgrade the
// result-table's `provsql` column header pill from a plain "prov"
// to "prov-tid" / "prov-bid" / "prov-opaque" with the sources
// surfaced in the hover tooltip. Last one wins when multiple
// statements emit NOTICEs (we surface the user-visible last
// SELECT's classification).
let classifyInfo = null;
for (const n of (notices || [])) {
const classified = parseClassifierNotice(n.message);
if (classified) {
classifyInfo = classified;
continue;
}
bannerHtml += renderDiag(n.severity, n.message);
}
if (banners) banners.innerHTML = bannerHtml;
if (!final) {
head.innerHTML = '';
body.innerHTML = ' (no statements) ';
count.textContent = 0;
return;
}
if (final.kind === 'error') {
// Append the final error to the same banner stack as earlier errors
// and notices, so the visual treatment is uniform (icon + ProvSQL
// pill + same colours + multi-line preserved). The result table
// collapses to empty.
if (banners) {
banners.innerHTML += renderDiag('ERROR', final.message, final.sqlstate);
}
head.innerHTML = '';
body.innerHTML = '';
count.textContent = 0;
return;
}
if (final.kind === 'status') {
head.innerHTML = '';
body.innerHTML = `${env.escapeHtml(final.message)}${final.rowcount != null ? ` · ${final.rowcount} tuples affected` : ''} `;
count.textContent = final.rowcount != null ? final.rowcount : 0;
return;
}
if (final.kind === 'rows') {
const allCols = final.columns;
const isWhere = env.mode === 'where';
const isCircuit = env.mode === 'circuit';
// The studio session has provsql.aggtoken_text_as_uuid = on, so
// agg_token cells arrive as bare UUIDs. The server pre-resolves
// each unique UUID's "value (*)" via agg_token_value_text and
// ships the map in final.agg_display; the front-end renders the
// friendly form while keeping the UUID for click-through.
const aggDisplay = final.agg_display || {};
// Hide rewriter-injected columns (__prov, __wprov) from display, but
// keep them indexed so we can still build per-cell data-sources and
// per-row jump buttons. The bare `provsql` UUID column is hidden in
// where mode (it duplicates the highlighting metadata) but kept in
// circuit mode so users can click it to render the row's DAG.
const displayIdx = [];
let provIdx = -1, wprovIdx = -1;
allCols.forEach((c, i) => {
if (c.name === '__prov') provIdx = i;
else if (c.name === '__wprov') wprovIdx = i;
else if (c.name === 'provsql' && isWhere) { /* hidden in where mode */ }
else displayIdx.push(i);
});
// Pick the row's provenance UUID for the auto-conditioning hint
// we stamp on each clickable cell below: prefer the rewriter's
// __prov column when present (always set on tracked queries with
// provsql.active), otherwise fall back to a user-selected
// `provsql` column. Used by circuit-mode click-through so the
// eval strip's "Condition on" input can default to the row's
// provenance even when the click target is the `random_variable`
// cell (whose scene root is the RV itself, not the row's prov).
let rowProvIdx = provIdx;
if (rowProvIdx === -1) {
const i = allCols.findIndex(c => c.name === 'provsql');
if (i !== -1) rowProvIdx = i;
}
const headExtra = (isWhere && wrapped) ? ' ' : '';
// Header decoration mirrors the schema-panel column list: every
// gets a title attribute with the Postgres type so the user can hover
// any column to discover its type, plus a small pill for columns
// with ProvSQL semantics:
// - terracotta `rv` for `random_variable` (operators lifted to gates)
// - terracotta `agg` for `agg_token` (UUID + running value)
// - purple `prov` for the `provsql` uuid column (the row's
// provenance gate from add_provenance)
// The first two key off type_name; the third keys off the column
// name because `provsql` is just `uuid` at the type level.
const matches = env.matchesProvType || ((t, b) => String(t || '').toLowerCase() === b);
// The `provsql` column pill is upgraded to a kind-aware variant
// (prov-tid / prov-bid / prov-opaque) when the classifier NOTICE
// emitted by provsql.classify_top_level identifies the kind of
// the user's query result. Hover surfaces the explainer plus
// the list of provenance-tracked source relations. Without a
// classifier NOTICE (older extension, classifier off, no
// sources walked) the pill stays the plain "prov" form.
const provLabel = classifyInfo
? (CLASSIFIER_LABELS[classifyInfo.kind] || 'prov')
: 'prov';
const provClassMod = classifyInfo
? ' wp-result__col-prov--' + classifyInfo.kind
: '';
let provTip = `provsql: the row's provenance gate UUID (added by add_provenance)`;
if (classifyInfo) {
const explain = CLASSIFIER_EXPLAINERS[classifyInfo.kind]
|| 'Query-time provenance classification.';
// OPAQUE NOTICEs no longer carry a sources list (the
// partial form they used to emit was misleading), so we
// omit the "Sources: ..." line for OPAQUE entirely.
let srcLine = '';
if (classifyInfo.kind !== 'opaque') {
srcLine = classifyInfo.sources.length
? 'Sources: ' + classifyInfo.sources.join(', ')
: 'No provenance-tracked sources.';
}
provTip = srcLine ? explain + '\n\n' + srcLine : explain;
}
const headerPill = (col) => {
const typeName = col.type_name || '';
if (matches(typeName, 'random_variable')) {
return `rv `;
}
if (matches(typeName, 'agg_token')) {
return `agg `;
}
if (col.name === 'provsql') {
return `${provLabel} `;
}
return '';
};
head.innerHTML = displayIdx.map(i => {
const col = allCols[i];
const typeName = col.type_name || '';
const alignCls = env.isRightAlignedType(typeName) ? ' is-right' : '';
const titleAttr = typeName ? ` title="${env.escapeAttr(typeName)}"` : '';
// Wrap the column name in its own span so callers that read
// header text (e.g. tests, accessibility tooling) can grab the
// name independently of the trailing pill.
const nameHtml = `${env.escapeHtml(col.name)} `;
return ` ${nameHtml}${headerPill(col)} `;
}).join('') + headExtra;
body.innerHTML = final.rows.map(r => {
const sources = wrapped && wprovIdx >= 0
? parseWhereProvenance(r[wprovIdx], displayIdx)
: null;
// Row's provenance UUID, attached to every clickable cell of
// this row so circuit-mode click-through can carry the row
// context into the eval strip's "Condition on" auto-preset.
const rowProv = rowProvIdx >= 0 && r[rowProvIdx]
? String(r[rowProvIdx])
: '';
const rowProvAttr = rowProv
? ` data-row-prov="${env.escapeAttr(rowProv)}"`
: '';
const cells = displayIdx.map((idx, di) => {
const col = allCols[idx];
const typeName = (col.type_name || '').toLowerCase();
const value = r[idx];
const dataSrc = sources ? sources[di] || '' : '';
const sourcesAttr = dataSrc ? ` data-sources="${env.escapeAttr(dataSrc)}"` : '';
let extraCls = '';
let extraAttr = '';
// random_variable is binary-coercible with uuid and its on-wire
// text form is a bare UUID, so it click-throughs the same way
// a uuid cell does.
if (isCircuit && (typeName === 'uuid' || typeName === 'random_variable') && value) {
extraCls = ' is-clickable';
extraAttr = ` data-circuit-uuid="${env.escapeAttr(String(value))}"${rowProvAttr}`;
}
// agg_token cells: their on-wire text is the underlying UUID
// (because provsql.aggtoken_text_as_uuid is on for studio
// sessions). Make them clickable in circuit mode like
// regular UUID cells; the cell's text content is replaced
// with the "value (*)" form pulled from agg_display, and the
// tooltip carries the UUID so users can confirm which
// circuit the cell points at without inspecting the DOM.
let displayValue = value;
if (typeName === 'agg_token' && value) {
if (isCircuit) {
extraCls = (extraCls + ' is-clickable').trim();
extraAttr = ` data-circuit-uuid="${env.escapeAttr(String(value))}"${rowProvAttr}`;
if (extraCls.length) extraCls = ' ' + extraCls;
}
extraAttr += ` title="${env.escapeAttr(String(value))}"`;
const friendly = aggDisplay[value];
if (friendly) displayValue = friendly;
}
if (env.isRightAlignedType(typeName)) extraCls += ' is-right';
return ``;
}).join('');
const jumpBtn = (isWhere && wrapped && provIdx >= 0 && r[provIdx])
? ` Circuit `
: '';
return `${cells}${jumpBtn} `;
}).join('');
count.textContent = final.rows.length;
const truncated = document.getElementById('result-truncated');
if (truncated) {
if (final.truncated && final.max_rows != null) {
truncated.textContent = ` (first ${final.max_rows}; more available)`;
truncated.hidden = false;
} else {
truncated.textContent = '';
truncated.hidden = true;
}
}
// Auto-render the single clickable UUID: when a circuit-mode
// query returns exactly one cell the user could click through
// to a DAG, save them the click. Two+ candidates stay
// ambiguous (let the user pick); zero means nothing to render.
// A subsequent where-mode-jump preloadCircuit still runs after
// this via setupCircuitMode's then() callback and overwrites
// the auto-rendered scene with the user-chosen one.
//
// Implementation: dispatch a synthetic click on the cell so the
// existing click handler (wired inside the setupCircuitMode IIFE)
// takes care of loadCircuit + ensureCircuitLib + the eval-strip
// re-bind. This file's `renderBlocks` is at module-global
// scope and can't reach `loadCircuit` directly, but clicking the
// cell DOM element travels through whichever listener was
// installed for the current mode.
if (isCircuit) {
const clickable = body.querySelectorAll('[data-circuit-uuid]');
if (clickable.length === 1) {
clickable[0].click();
}
}
}
}
function renderError(msg) {
const banners = document.getElementById('result-banners');
if (banners) banners.innerHTML = renderDiag('ERROR', msg);
head.innerHTML = '';
body.innerHTML = '';
count.textContent = 0;
}
/* Parse the text returned by where_provenance(provenance()).
Format (from src/WhereCircuit.cpp / where_provenance.cpp):
{[table:uuid:col;table:uuid:col],[table:uuid:col],[]}
Outer braces, comma-separated groups (one per output column of the
wrapped query), each group is square-bracketed, semicolon-separated
`table:uuid:col` entries. The groups are in inner-SELECT column order,
which matches `allCols` exactly, so groups[displayIdx[i]] is the
source list for the i-th displayed column. We return one
ready-to-set `data-sources` string per displayed column. */
function parseWhereProvenance(wprovText, displayIdx) {
if (!wprovText) return null;
const m = String(wprovText).match(/^\{(.*)\}$/s);
if (!m) return null;
const inner = m[1];
// Split top-level groups by `,`. Groups themselves don't contain commas
// (they use `;` between items), so this is safe.
const groups = inner.length === 0 ? [] : splitTopLevel(inner, ',');
const perGroup = groups.map(g => {
const gm = g.match(/^\[(.*)\]$/s);
if (!gm) return '';
return gm[1]; // already `table:uuid:col;table:uuid:col`
});
// Map each displayed column to its group in the inner-SELECT order.
// Hidden columns (provsql, __prov, __wprov) are skipped over.
return displayIdx.map(i => perGroup[i] || '');
}
function splitTopLevel(s, sep) {
const out = [];
let depth = 0, last = 0;
for (let i = 0; i < s.length; ++i) {
const c = s[i];
if (c === '[' || c === '{' || c === '(') depth++;
else if (c === ']' || c === '}' || c === ')') depth--;
else if (c === sep && depth === 0) {
out.push(s.slice(last, i));
last = i + 1;
}
}
out.push(s.slice(last));
return out;
}
}