/* 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 `

${escapeHtml(rel.regclass)}

${tuples} · ${visible.length} cols
${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 ``; }).join('')} `).join('')}
${formatCell(r.values[i], c.type_name)}
`; }).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 `; } else { actions = ``; } } // Outer is a div with role=button so the inner action buttons can // be real