/* ProvSQL Studio: notebook mode (lazy-loaded by app.js, mirroring
circuit.js). An ordered list of Markdown + SQL cells executed against
a pinned, stateful database session (the "kernel": /api/nb/session +
/api/nb/exec), with per-cell results rendered by the same block
renderer as the shared result pane (app.js makeBlockRenderer).
Persistence: Jupyter nbformat v4 (.ipynb) download / load, plus a
localStorage autosave of the working draft. See
the Notebook-mode section of doc/source/user/studio.rst. */
(function () {
'use strict';
let env = null; // window.__provsqlStudio, passed by init()
let kernel = null; // {sessionId, pid, db} | null
let kernelStarting = null; // in-flight ensureKernel() promise
let cells = []; // [{id, type, source, outputs, count, htmlSnapshot}]
let execCounter = 0;
let running = null; // {cellId, requestId} | null
let mdLibs = null; // promise for marked + DOMPurify
let lastFocused = null; // last cell whose editor had focus
let schemaCache = null; // /api/schema payload for the sidebar
let tabs = []; // [{id, name, db, doc, kernel, resume}]
let activeTabId = null;
let connDb = null; // current connection's database (from /api/conn)
let nextTabId = 1;
let selected = null; // command-mode selected cell (Jupyter-style)
let lastDeleted = null; // {cell, index} for the `z` undo
let lastDKey = 0; // timestamp of the previous lone `d` press
const byId = (id) => document.getElementById(id);
const cellsEl = () => byId('nb-cells');
let nextCellId = 1;
function newCell(type, source) {
return { id: 'c' + (nextCellId++), type, source: source || '',
outputs: null, count: null, htmlSnapshot: null };
}
/* ──────── tabs as database bindings ──────── */
/* Each tab is one notebook plus the database it is bound to. Both
deployments share a single ACTIVE connection (the Playground's
PGlite cannot even open two), so tabs multiplex serially: a tab
whose binding differs from the live connection shows a banner
offering to switch (or to create the database, or to rebind);
activating it never switches silently. Loading an .ipynb opens a
new tab; booting on a different database than the active tab's
binding activates (or creates) a tab bound to it. Inactive tabs
park their notebook as an ipynb doc; their kernels stay alive
until the tab closes or the page unloads. */
function currentTab() {
return tabs.find((t) => t.id === activeTabId) || null;
}
function flushActiveTab() {
const tab = currentTab();
if (!tab) return;
tab.doc = toIpynb();
tab.kernel = kernel;
tab.resume = {
idx: selected ? cells.indexOf(selected) : -1,
scrollY: window.scrollY,
};
}
function persistTabs() {
flushActiveTab();
try {
localStorage.setItem('ps.nb.tabs', JSON.stringify({
active: activeTabId,
tabs: tabs.map((t) => ({ id: t.id, name: t.name, db: t.db,
doc: t.doc, resume: t.resume })),
}));
} catch (e) { /* quota / disabled */ }
}
function makeTab(name, db, doc) {
return { id: 't' + (nextTabId++), name: name || 'Untitled',
db: db || connDb || null, doc: doc || null,
kernel: null, resume: null };
}
function loadTabIntoView(tab) {
if (tab.doc) {
loadNotebook(tab.doc);
} else {
defaultNotebook();
}
kernel = tab.kernel || null;
if (kernel) setKernelChip('alive', `pid ${kernel.pid} · ${kernel.db}`);
else setKernelChip('none', 'no kernel');
if (tab.resume) {
if (Number.isInteger(tab.resume.idx) && tab.resume.idx >= 0
&& tab.resume.idx < cells.length) {
selectCell(cells[tab.resume.idx], { scroll: false });
}
const y = tab.resume.scrollY;
if (Number.isFinite(y) && y > 0) {
requestAnimationFrame(() => requestAnimationFrame(
() => window.scrollTo(0, y)));
}
} else {
if (cells.length) selectCell(cells[0], { scroll: false });
window.scrollTo(0, 0);
}
renderTabBar();
updateBindingBanner();
}
function activateTab(id) {
if (id === activeTabId) return;
flushActiveTab();
const tab = tabs.find((t) => t.id === id);
if (!tab) return;
activeTabId = id;
loadTabIntoView(tab);
persistTabs();
}
// A tab worth dropping silently: never ran anything (no kernel) and
// holds no content (the freshly-booted Untitled tab).
function tabIsPristine(tab) {
if (!tab || tab.kernel) return false;
const docCells = (tab.doc && tab.doc.cells) || [];
return docCells.every((c) =>
!String(Array.isArray(c.source) ? c.source.join('')
: c.source || '').trim()
&& !(c.outputs && c.outputs.length));
}
function newTab(name, db, doc) {
flushActiveTab();
const prev = currentTab();
const tab = makeTab(name, db, doc);
tabs.push(tab);
activeTabId = tab.id;
// Opening a document (example / .ipynb load) from a pristine
// Untitled tab replaces it rather than leaving an empty husk
// behind. The + button (doc == null) keeps the old tab: making a
// second blank tab is exactly its job.
if (doc && prev && tabIsPristine(prev)) {
tabs.splice(tabs.indexOf(prev), 1);
}
loadTabIntoView(tab);
persistTabs();
return tab;
}
function closeTab(id) {
const idx = tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
const tab = tabs[idx];
const nonTrivial = tab.doc
? (tab.doc.cells || []).some((c) => String(
Array.isArray(c.source) ? c.source.join('') : c.source || '').trim())
: (id === activeTabId && cells.some((c) => (c.source || '').trim()));
if (nonTrivial && !window.confirm(`Close tab “${tabDisplayName(tab)}”?`)) return;
if (tab.kernel) {
fetch(`/api/nb/session/${encodeURIComponent(tab.kernel.sessionId)}`,
{ method: 'DELETE' }).catch(() => {});
}
tabs.splice(idx, 1);
if (id === activeTabId) {
activeTabId = null;
kernel = null;
if (tabs.length) activateTab(tabs[Math.max(0, idx - 1)].id);
else newTab();
} else {
renderTabBar();
persistTabs();
}
}
// A tab's display name is the first level-1 Markdown heading in its
// notebook (the document names itself, like a paper title); the
// stored name (file stem / "Untitled") is only the fallback.
function headingTabName(tab) {
const list = tab.id === activeTabId
? cells.map((c) => ({ md: c.type === 'markdown', src: c.source || '' }))
: ((tab.doc && tab.doc.cells) || []).map((c) => ({
md: c.cell_type === 'markdown',
src: Array.isArray(c.source) ? c.source.join('') : (c.source || ''),
}));
for (const c of list) {
if (!c.md) continue;
let inFence = false;
for (const line of String(c.src).split('\n')) {
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = line.match(/^#\s+(.+?)\s*#*\s*$/);
if (m) return m[1];
}
}
return null;
}
function tabDisplayName(tab) {
return headingTabName(tab) || tab.name;
}
function renderTabBar() {
const bar = byId('nb-tabs');
if (!bar) return;
const esc = env.escapeHtml, escA = env.escapeAttr;
bar.innerHTML = tabs.map((t) => {
const active = t.id === activeTabId;
const foreign = t.db && connDb && t.db !== connDb;
const name = tabDisplayName(t);
return ``
+ `${esc(name)}`
+ (foreign ? `${esc(t.db)}` : '')
+ ``
+ ``;
}).join('')
+ ``;
}
function wireTabBar() {
const bar = byId('nb-tabs');
if (!bar) return;
bar.addEventListener('click', (e) => {
const close = e.target.closest('[data-tab-close]');
if (close) {
closeTab(close.dataset.tabClose);
return;
}
if (e.target.closest('#nb-tab-add')) {
newTab();
return;
}
const tabEl = e.target.closest('[data-tab]');
if (tabEl) activateTab(tabEl.dataset.tab);
});
}
/* Binding banner: the active tab's database vs the live connection.
Offers exactly the action that makes sense: "Switch to X" when the
bound database exists (is CONNECT-able), "Create X" when it does
not -- offering both would be nonsense. */
let dbListCache = null; // GET /api/databases payload
async function accessibleDatabases() {
if (dbListCache) return dbListCache;
try {
const resp = await fetch('/api/databases');
if (resp.ok) dbListCache = await resp.json();
} catch (e) { /* fall through */ }
return dbListCache || [];
}
async function updateBindingBanner() {
const banner = byId('nb-binding-banner');
if (!banner) return;
const tab = currentTab();
const foreign = tab && tab.db && connDb && tab.db !== connDb;
banner.hidden = !foreign;
if (!foreign) { banner.innerHTML = ''; return; }
const exists = (await accessibleDatabases()).includes(tab.db);
// The tab may have changed while the list was fetched.
if (currentTab() !== tab) return;
const esc = env.escapeHtml;
banner.innerHTML =
` `
+ `This notebook is bound to ${esc(tab.db)}`
+ `${exists ? '' : ' (which does not exist)'}; `
+ `you are connected to ${esc(connDb)}.`
+ (exists
? ` `
: ` `)
+ ``;
const sw = byId('nb-bind-switch');
if (sw) sw.addEventListener('click', () => switchConnectionTo(tab.db));
const cr = byId('nb-bind-create');
if (cr) cr.addEventListener('click', async () => {
const resp = await fetch('/api/databases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: tab.db }),
});
const payload = await resp.json().catch(() => ({}));
dbListCache = null;
if (resp.status === 409) {
// Created elsewhere since the list was cached: just switch.
switchConnectionTo(tab.db);
return;
}
if (!resp.ok) {
window.alert(payload.error || `HTTP ${resp.status}`);
return;
}
if (payload.warning) window.alert(payload.warning);
switchConnectionTo(tab.db);
});
byId('nb-bind-keep').addEventListener('click', () => {
tab.db = connDb;
persistTabs();
renderTabBar();
updateBindingBanner();
});
}
async function switchConnectionTo(dbname) {
// Persist first: the connection switch reloads the page (and kills
// every kernel server-side); on boot the active tab matches the new
// database, so the reconcile below leaves it in place.
persistTabs();
try {
const resp = await fetch('/api/conn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ database: dbname }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload.error || `HTTP ${resp.status}`);
return;
}
} catch (e) {
window.alert(`Network error: ${e.message}`);
return;
}
window.location.reload();
}
/* ──────── kernel client ──────── */
function setKernelChip(state, label) {
const chip = byId('nb-kernel-chip');
const lbl = byId('nb-kernel-label');
if (!chip || !lbl) return;
chip.className = 'nb__kernel nb__kernel--' + state;
lbl.textContent = label;
}
async function ensureKernel() {
if (kernel) return kernel;
if (kernelStarting) return kernelStarting;
setKernelChip('starting', 'starting…');
kernelStarting = (async () => {
const resp = await fetch('/api/nb/session', { method: 'POST' });
const payload = await resp.json().catch(() => ({}));
if (!resp.ok) {
setKernelChip('dead', payload.error || `kernel failed (HTTP ${resp.status})`);
throw new Error(payload.error || `HTTP ${resp.status}`);
}
kernel = { sessionId: payload.session_id, pid: payload.pid, db: payload.db };
setKernelChip('alive', `pid ${kernel.pid} · ${kernel.db}`);
return kernel;
})();
try {
return await kernelStarting;
} finally {
kernelStarting = null;
}
}
function kernelGone(reason) {
kernel = null;
setKernelChip('dead', reason || 'kernel died – restart it');
}
async function shutdownKernel() {
if (!kernel) return;
const id = kernel.sessionId;
kernel = null;
setKernelChip('none', 'no kernel');
try {
await fetch(`/api/nb/session/${encodeURIComponent(id)}`, { method: 'DELETE' });
} catch (e) { /* server gone: nothing to clean */ }
}
async function restartKernel() {
await shutdownKernel();
// Restart resets the Jupyter-style staleness markers: every cell's
// counter goes back to "not run on this kernel".
execCounter = 0;
for (const c of cells) { c.count = null; }
for (const c of cells) updateGutter(c);
try { await ensureKernel(); } catch (e) { /* chip already shows it */ }
}
/* ──────── markdown rendering (vendored marked + DOMPurify) ──────── */
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src;
s.onload = resolve;
s.onerror = () => reject(new Error('failed to load ' + src));
document.head.appendChild(s);
});
}
function ensureMdLibs() {
if (!mdLibs) {
mdLibs = Promise.all([
loadScript('/static/vendor/marked.min.js'),
loadScript('/static/vendor/purify.min.js'),
]);
}
return mdLibs;
}
async function renderMarkdownInto(el, source) {
try {
await ensureMdLibs();
const html = window.marked.parse(source, { gfm: true, breaks: false });
el.innerHTML = window.DOMPurify.sanitize(html);
// ```sql fences get the same tokenizer as the cell editors (marked
// tags them code.language-sql). highlightSql escapes its input, so
// feeding it the decoded textContent cannot reintroduce markup.
const hl = window.ProvsqlStudio.highlightSql;
if (hl) {
for (const code of el.querySelectorAll(
'pre code.language-sql, pre code.language-postgresql')) {
code.innerHTML = hl(code.textContent);
}
}
} catch (e) {
// Renderer unavailable (vendored file missing): degrade to
// escaped plain text rather than hiding the user's prose.
el.textContent = source;
}
}
/* ──────── cell DOM ──────── */
function cellEl(cell) {
return cellsEl().querySelector(`[data-cell-id="${cell.id}"]`);
}
function updateGutter(cell) {
const el = cellEl(cell);
if (!el) return;
const g = el.querySelector('.nb-cell__count');
if (!g) return;
if (running && running.cellId === cell.id) g.textContent = '[*]';
else if (cell.count != null) g.textContent = `[${cell.count}]`;
else g.textContent = '[ ]';
}
function buildOutputTargets(outEl) {
outEl.innerHTML = `
tuples
· – ms
`;
return {
head: outEl.querySelector('thead tr'),
body: outEl.querySelector('tbody'),
count: outEl.querySelector('.nb-out__count'),
noun: outEl.querySelector('.nb-out__noun'),
banners: outEl.querySelector('.nb-out__banners'),
truncated: outEl.querySelector('.nb-out__trunc'),
};
}
function renderOutputs(cell) {
if (cell.type !== 'sql') return;
const el = cellEl(cell);
if (!el) return;
const outEl = el.querySelector('.nb-out');
if (!outEl) return;
if (!cell.outputs) {
outEl.hidden = true;
outEl.innerHTML = '';
return;
}
outEl.hidden = false;
const targets = buildOutputTargets(outEl);
const R = window.ProvsqlStudio.makeBlockRenderer(env, targets);
const p = cell.outputs;
R.renderBlocks(p.blocks || [], !!p.wrapped, p.notices || []);
const timeEl = outEl.querySelector('.nb-out__time');
if (timeEl) timeEl.textContent = p.elapsed_ms != null ? p.elapsed_ms : '–';
// Snapshot for the .ipynb text/html bundle (external viewers); the
// JSON payload stays the source of truth Studio re-renders from.
cell.htmlSnapshot = outEl.innerHTML;
}
function autosize(ta) {
ta.style.height = 'auto';
ta.style.height = Math.max(ta.scrollHeight, 34) + 'px';
}
function buildCellDom(cell) {
const div = document.createElement('div');
div.className = `nb-cell nb-cell--${cell.type}`;
div.dataset.cellId = cell.id;
div.innerHTML = `
[ ]
${cell.type === 'sql'
? ``
: ''}
${cell.type === 'circuit'
? ``
: ''}
${cell.type === 'eval'
? ``
: ''}
${cell.type === 'circuit' ? `
Circuit for ${shortToken(cell.token)}
` : ''}
${cell.type === 'eval' ? `
` : ''}
${cell.type === 'sql' ? `
${cell.scheme ? `
${cell.scheme}
` : ''}
` : `
`}
${cell.type === 'sql'
? ``
: ''}
${cell.type === 'sql'
? ``
: ''}
`;
wireCellDom(cell, div);
return div;
}
function wireCellDom(cell, div) {
if (cell.type === 'sql') {
const ta = div.querySelector('.nb-cell__ta');
const code = div.querySelector('.nb-cell__hl code');
const hl = window.ProvsqlStudio.highlightSql || ((t) => '');
const refresh = () => {
code.innerHTML = hl(ta.value);
autosize(ta);
cell.source = ta.value;
scheduleAutosave();
};
ta.value = cell.source;
ta.addEventListener('focus', () => { lastFocused = cell; selectCell(cell, { scroll: false }); });
ta.addEventListener('input', refresh);
ta.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
runCell(cell);
} else if (e.shiftKey && e.key === 'Enter') {
e.preventDefault();
ta.blur(); // Jupyter: drop to command mode
runSelectedThenAdvance(cell);
} else if (e.altKey && e.key === 'Enter') {
e.preventDefault();
runAltEnter(cell);
} else if (e.key === 'Escape') {
e.preventDefault();
ta.blur();
selectCell(cell);
}
});
div.querySelector('.nb-cell__run').addEventListener('click', () => runCell(cell));
// Initial paint (after insertion, so scrollHeight is real).
requestAnimationFrame(refresh);
} else if (cell.type === 'markdown') {
const view = div.querySelector('.nb-cell__md');
const ta = div.querySelector('.nb-cell__mdta');
let everEdited = false;
const startEdit = () => {
everEdited = true;
ta.value = cell.source;
view.hidden = true;
ta.hidden = false;
autosize(ta);
ta.focus();
};
const endEdit = () => {
cell.source = ta.value;
ta.hidden = true;
view.hidden = false;
renderMarkdownInto(view, cell.source || '*(empty cell: double-click to edit)*');
scheduleAutosave();
};
ta.addEventListener('focus', () => { lastFocused = cell; selectCell(cell, { scroll: false }); });
view.addEventListener('dblclick', startEdit);
ta.addEventListener('blur', endEdit);
ta.addEventListener('input', () => autosize(ta));
ta.addEventListener('keydown', (e) => {
if (((e.ctrlKey || e.metaKey) || e.shiftKey) && e.key === 'Enter') {
e.preventDefault();
ta.blur(); // blur renders via endEdit
if (e.shiftKey) runSelectedThenAdvance(cell);
} else if (e.key === 'Escape') {
e.preventDefault();
ta.blur();
selectCell(cell);
}
});
renderMarkdownInto(view, cell.source || '*(empty cell: double-click to edit)*');
// Fresh empty markdown cells drop straight into edit mode (the
// "+ Markdown" flow); cells *converted* to markdown (the `m` key)
// stay in command mode, like Jupyter, so the keymap keeps working.
// The everEdited guard stops the deferred auto-edit from
// REOPENING the editor when something else (focusCell's synthetic
// dblclick) already opened it and the user finished the edit
// within the same frame.
if (!cell.source && !cell._noAutoEdit) {
requestAnimationFrame(() => { if (!everEdited) startEdit(); });
}
delete cell._noAutoEdit;
}
if (cell.type === 'circuit') {
div.querySelector('.nb-cell__run').addEventListener('click',
() => refreshCircuitCell(cell));
div.querySelector('.nb-circ__eval').addEventListener('click', () => {
insertEvalAfter(cell, cell.token);
});
div.querySelector('.nb-circ__jump').addEventListener('click', () => {
// Same carry mechanism as the where-mode jump button: Circuit
// mode preloads the token on arrival. The row's provenance
// gate (recorded when the snapshot came from a result-row
// click on an rv / non-provsql uuid) rides along so the eval
// strip's "Conditioned by" presets to the row, as it does for
// in-Circuit-mode clicks.
try {
sessionStorage.setItem('ps.preloadCircuit', cell.token);
if (cell.rowProv) {
sessionStorage.setItem('ps.preloadCircuitRowProv', cell.rowProv);
} else {
sessionStorage.removeItem('ps.preloadCircuitRowProv');
}
} catch (e) { /* plain navigation */ }
window.location.href = '/circuit';
});
// Saved notebooks carry the scene; paint it without a fetch.
if (cell.scene) {
requestAnimationFrame(() => {
const box = div.querySelector('.nb-circ__canvas');
if (box) paintSceneInto(box, cell.scene);
});
}
}
if (cell.type === 'eval') {
div.querySelector('.nb-cell__run').addEventListener('click',
() => runEvalCell(cell));
const sem = div.querySelector('.nb-eval__semiring');
const meth = div.querySelector('.nb-eval__method');
const args = div.querySelector('.nb-eval__args');
const map = div.querySelector('.nb-eval__mapping');
sem.addEventListener('change', () => {
cell.semiring = sem.value;
syncEvalControls(cell, div);
scheduleAutosave();
});
meth.addEventListener('change', () => {
cell.method = meth.value;
scheduleAutosave();
});
args.addEventListener('input', () => {
cell.args = args.value;
scheduleAutosave();
});
map.addEventListener('change', () => {
cell.mapping = map.value;
scheduleAutosave();
});
ensureMappings().then((maps) => {
for (const m of maps) {
const opt = document.createElement('option');
opt.value = m.qname;
opt.textContent = `${m.qname} (${m.value_type || '?'})`;
if (m.qname === cell.mapping) opt.selected = true;
map.appendChild(opt);
}
});
syncEvalControls(cell, div);
if (cell.result) {
requestAnimationFrame(() => renderEvalResult(cell));
}
}
div.querySelector('.nb-cell__actions').addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const idx = cells.indexOf(cell);
if (btn.dataset.act === 'up' && idx > 0) {
cells.splice(idx, 1); cells.splice(idx - 1, 0, cell);
cellsEl().insertBefore(div, div.previousElementSibling);
} else if (btn.dataset.act === 'down' && idx < cells.length - 1) {
cells.splice(idx, 1); cells.splice(idx + 1, 0, cell);
cellsEl().insertBefore(div.nextElementSibling, div);
} else if (btn.dataset.act === 'add-sql' || btn.dataset.act === 'add-md') {
const c = newCell(btn.dataset.act === 'add-sql' ? 'sql' : 'markdown');
cells.splice(idx + 1, 0, c);
div.after(buildCellDom(c));
focusCell(c);
} else if (btn.dataset.act === 'scheme') {
cycleScheme(cell, div);
return;
} else if (btn.dataset.act === 'to-circuit') {
// Carry this cell's SQL into Circuit mode through the standard
// mode-switch channel: executed cells auto-replay there, drafts
// just land in the query box.
try {
sessionStorage.setItem('ps.sql', cell.source || '');
if ((cell.source || '').trim() && cell.count != null) {
sessionStorage.setItem('ps.sql.ran', '1');
} else {
sessionStorage.removeItem('ps.sql.ran');
}
} catch (e2) { /* sessionStorage disabled: plain navigation */ }
window.location.href = '/circuit';
return;
} else if (btn.dataset.act === 'del') {
if ((cell.source || '').trim()
&& !window.confirm('Delete this cell?')) return;
cells.splice(idx, 1);
div.remove();
if (!cells.length) appendCell('sql');
}
scheduleAutosave();
});
}
// Per-cell scheme cycling (default -> semiring -> where -> boolean).
// `undefined` means "follow the toolbar's notebook-level default".
const SCHEME_CYCLE = [undefined, 'semiring', 'where', 'boolean'];
function cycleScheme(cell, div) {
const idx = SCHEME_CYCLE.indexOf(cell.scheme);
cell.scheme = SCHEME_CYCLE[(idx + 1) % SCHEME_CYCLE.length];
// Refresh the chip in place (no full rebuild: outputs stay live).
let chip = div.querySelector('.nb-cell__scheme');
if (cell.scheme) {
if (!chip) {
chip = document.createElement('span');
chip.className = 'nb-cell__scheme';
div.querySelector('.nb-cell__editor').before(chip);
}
chip.textContent = cell.scheme;
chip.title = `This cell runs under the ${cell.scheme} provenance `
+ 'scheme (overrides the toolbar default)';
} else if (chip) {
chip.remove();
}
const btn = div.querySelector('[data-act="scheme"]');
if (btn) {
btn.title = `Provenance scheme for this cell: `
+ `${cell.scheme || 'notebook default'} – click to cycle `
+ '(default → semiring → where → boolean)';
}
scheduleAutosave();
}
function focusCell(cell) {
const el = cellEl(cell);
if (!el) return;
const ta = el.querySelector('.nb-cell__ta, .nb-cell__mdta:not([hidden])');
if (ta) ta.focus();
else el.querySelector('.nb-cell__md')?.dispatchEvent(new Event('dblclick'));
}
function appendCell(type, source) {
const c = newCell(type, source);
cells.push(c);
cellsEl().appendChild(buildCellDom(c));
return c;
}
function focusedCell() {
// Clicking a toolbar button moves focus to the button itself, so
// activeElement rarely sits inside a cell at dispatch time; the
// focus-tracked `lastFocused` is the actual "current" cell.
const el = document.activeElement && document.activeElement.closest
? document.activeElement.closest('.nb-cell')
: null;
if (el) {
const c = cells.find((x) => x.id === el.dataset.cellId);
if (c) return c;
}
if (selected && cells.includes(selected) && selected.type === 'sql') {
return selected;
}
if (lastFocused && cells.includes(lastFocused)) return lastFocused;
return cells.find((c) => c.type === 'sql') || null;
}
/* ──────── circuit cells ──────── */
/* A circuit cell is a self-contained snapshot of the provenance DAG
for one token: the /api/circuit scene (positions computed
server-side) painted by the compact renderer below, which reuses
circuit.js's CSS vocabulary (.node-group/.node-shape/.node-label/
.edge/.edge-pos) so the snapshot reads exactly like the Circuit
mode canvas. The scene JSON is what persists in the .ipynb (plus
an image/svg+xml copy for external viewers); on load the cell
re-paints from the JSON without a database. Interactivity beyond
a depth selector and a refresh deliberately stays in Circuit mode
(one click away via the jump link). */
// Child-position labelling rules, mirrored from circuit.js: only
// gates whose argument order matters get the digits.
const ORDERED_GATES = new Set(['cmp', 'monus', 'agg', 'arith', 'mixture']);
const COMMUTATIVE_AGG = new Set(['sum', 'count', 'min', 'max', 'avg']);
const COMMUTATIVE_CMP = new Set(['=', '<>', '!=']);
const NON_COMMUTATIVE_ARITH = new Set([2, 3]); // MINUS, DIV
function shouldLabelChildren(parent) {
if (!ORDERED_GATES.has(parent.type)) return false;
if (parent.type === 'agg') {
return !COMMUTATIVE_AGG.has((parent.info1_name || '').toLowerCase());
}
if (parent.type === 'cmp') {
return !COMMUTATIVE_CMP.has(parent.info1_name || '');
}
if (parent.type === 'arith') {
const tag = parent.info1 == null ? null : Number(parent.info1);
return Number.isFinite(tag) && NON_COMMUTATIVE_ARITH.has(tag);
}
return true;
}
function edgePosLabel(parent, pos) {
if (parent.type === 'mixture') {
return ({ 1: 'p', 2: 'x', 3: 'y' })[pos] || String(pos);
}
return String(pos);
}
function svgEl(tag, attrs) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
return el;
}
function paintSceneInto(container, scene) {
container.innerHTML = '';
if (!scene || !scene.nodes || !scene.nodes.length) {
container.textContent = '(empty circuit)';
return;
}
const xs = scene.nodes.map((n) => n.x), ys = scene.nodes.map((n) => n.y);
const pad = 40;
const minX = Math.min(...xs) - pad, maxX = Math.max(...xs) + pad;
const minY = Math.min(...ys) - pad, maxY = Math.max(...ys) + pad;
// Presentation attributes double the stylesheet below: SVG attributes
// lose to any CSS rule, so the live look is app.css's unchanged -- but
// the htmlSnapshot copy of this SVG (the .ipynb image/svg+xml bundle)
// renders standalone in external viewers (nbviewer, GitHub), which see
// neither app.css nor colors_and_type.css. Hex values are the resolved
// --purple-500 / --purple-700 / --fg-muted.
const svg = svgEl('svg', {
class: 'nb-circ__svg',
// Explicit xmlns: innerHTML serialization omits it, and without it
// the snapshot is not a parseable standalone SVG document.
xmlns: 'http://www.w3.org/2000/svg',
viewBox: `${minX} ${minY} ${maxX - minX} ${maxY - minY}`,
preserveAspectRatio: 'xMidYMid meet',
'font-family': 'sans-serif',
});
// Cap the on-screen size while keeping the aspect ratio: small
// scenes render 1:1-ish, large ones shrink to fit the cell.
svg.style.maxHeight = '360px';
svg.style.width = '100%';
const nodesById = {};
for (const n of scene.nodes) nodesById[n.id] = n;
// Parallel-edge bow, as in circuit.js, so duplicate wires stay
// distinguishable.
const parallelTotal = {};
for (const e of scene.edges) {
const k = `${e.from}|${e.to}`;
parallelTotal[k] = (parallelTotal[k] || 0) + 1;
}
const parallelSeen = {};
for (const e of scene.edges) {
const from = nodesById[e.from], to = nodesById[e.to];
if (!from || !to) continue;
const k = `${e.from}|${e.to}`;
const total = parallelTotal[k];
parallelSeen[k] = (parallelSeen[k] || 0) + 1;
const bow = total > 1 ? ((parallelSeen[k] - 1) - (total - 1) / 2) * 18 : 0;
svg.appendChild(svgEl('path', {
class: 'edge',
fill: 'none',
stroke: '#5A3E8C',
'stroke-width': 1.6,
d: `M ${from.x} ${from.y + 22} `
+ `C ${from.x + bow} ${from.y + 50}, `
+ `${to.x + bow} ${to.y - 50}, `
+ `${to.x} ${to.y - 22}`,
}));
if (shouldLabelChildren(from) && e.child_pos != null) {
const dx = from.x - to.x, dy = from.y - to.y;
const len = Math.hypot(dx, dy) || 1;
const t = svgEl('text', {
class: 'edge-pos',
fill: '#8A7A9B',
'font-size': 8,
x: (from.x + to.x) / 2 + bow + (-dy / len) * 9,
y: (from.y + to.y) / 2 + (dx / len) * 9,
});
t.textContent = edgePosLabel(from, e.child_pos);
svg.appendChild(t);
}
}
for (const n of scene.nodes) {
const g = svgEl('g', {
class: `node-group node--${n.type}` + (n.frontier ? ' is-frontier' : ''),
transform: `translate(${n.x},${n.y})`,
});
g.appendChild(svgEl('circle', {
class: 'node-shape', r: 22,
fill: '#fff', stroke: '#6B4FA0', 'stroke-width': 2,
}));
const label = String(n.label == null ? '' : n.label);
const t = svgEl('text', {
class: 'node-label',
fill: '#6B4FA0',
'font-weight': 600,
'text-anchor': 'middle',
'dominant-baseline': 'central',
'font-size': label.length > 6 ? 9 : (label.length > 3 ? 11 : 14),
});
t.textContent = label;
const tip = svgEl('title');
tip.textContent = `${n.type}\n${n.id}`;
g.appendChild(tip);
g.appendChild(t);
svg.appendChild(g);
}
container.appendChild(svg);
}
async function refreshCircuitCell(cell) {
const el = cellEl(cell);
const box = el && el.querySelector('.nb-circ__canvas');
if (!box) return;
box.textContent = 'loading…';
try {
// No depth parameter: like Circuit mode's loadCircuit, the server
// clamps to its configured max_circuit_depth, so the snapshot and
// the canvas show the same cut of the DAG.
const resp = await fetch(
`/api/circuit/${encodeURIComponent(cell.token)}`);
const payload = await resp.json();
if (!resp.ok) throw new Error(payload.error || `HTTP ${resp.status}`);
cell.scene = payload;
paintSceneInto(box, cell.scene);
cell.htmlSnapshot = box.innerHTML;
} catch (e) {
box.textContent = `Could not fetch the circuit: ${e.message}`;
}
scheduleAutosave();
}
// The eval strip's semantics (semiring evaluation, probability of a
// Boolean event) apply to plain provenance tokens; an agg_token or
// random_variable root gets no Evaluate affordance -- Circuit mode's
// dedicated dispatch (distribution profiles, moments, agg inspection)
// is the investigation surface for those.
function evalApplies(cell) {
return cell.tokenKind !== 'agg_token'
&& cell.tokenKind !== 'random_variable';
}
function syncCircuitEvalButton(cell) {
const el = cellEl(cell);
const btn = el && el.querySelector('.nb-circ__eval');
if (btn) btn.hidden = !evalApplies(cell);
}
function shortToken(t) {
const s = String(t || '');
return s.length > 8 ? s.slice(0, 8) + '…' : s;
}
// Insert a circuit cell for `token` after `afterCell` -- or, when the
// next cell is already a circuit cell, retarget it (repeated clicks
// on result UUIDs swap the snapshot instead of piling cells up).
function showCircuitFor(token, afterCell, rowProv, tokenKind) {
const idx = cells.indexOf(afterCell);
let cell = idx >= 0 && cells[idx + 1] && cells[idx + 1].type === 'circuit'
? cells[idx + 1] : null;
if (cell) {
cell.token = token;
cell.rowProv = rowProv || '';
cell.tokenKind = tokenKind || '';
syncCircuitEvalButton(cell);
const el = cellEl(cell);
const lbl = el && el.querySelector('.nb-circ__token');
if (lbl) { lbl.textContent = shortToken(token); lbl.title = token; }
} else {
cell = newCell('circuit');
cell.token = token;
cell.rowProv = rowProv || '';
cell.tokenKind = tokenKind || '';
const pos = idx >= 0 ? idx + 1 : cells.length;
cells.splice(pos, 0, cell);
const anchorEl = idx >= 0 ? cellEl(afterCell) : null;
const dom = buildCellDom(cell);
if (anchorEl) anchorEl.after(dom);
else cellsEl().appendChild(dom);
}
refreshCircuitCell(cell);
}
/* ──────── evaluation cells ──────── */
/* An evaluation cell is one eval-strip invocation as a cell: token +
evaluation (compiled semiring or probability method) + optional
arguments / provenance mapping, with the result rendered inline
and the invocation recorded in metadata.provsql so a saved
notebook replays it. The narrative form of "and the probability
of this answer is...". Offered only for plain provenance tokens:
agg_token / random_variable roots get no Evaluate affordance (the
strip's semantics don't apply to them; Circuit mode's dedicated
dispatch -- distribution profiles, moments -- is the place to
investigate those). */
const EVAL_SEMIRINGS = [
['probability', 'Probability'],
['formula', 'Formula'],
['boolexpr', 'Boolean expression'],
['why', 'Why'],
['which', 'Which'],
['how', 'How'],
['counting', 'Counting'],
];
let mappingsCache = null; // /api/provenance_mappings payload
async function ensureMappings() {
if (mappingsCache) return mappingsCache;
try {
const resp = await fetch('/api/provenance_mappings');
if (resp.ok) mappingsCache = await resp.json();
} catch (e) { /* mapping picker just stays empty */ }
if (!mappingsCache) mappingsCache = [];
return mappingsCache;
}
function syncEvalControls(cell, el) {
const isProb = cell.semiring === 'probability';
el.querySelector('.nb-eval__method').hidden = !isProb;
el.querySelector('.nb-eval__mapping').hidden = isProb;
el.querySelector('.nb-eval__args').hidden = !isProb;
}
const EVAL_METHODS = [
'', 'exact', 'relative', 'additive', 'independent', 'possible-worlds',
'sieve', 'd-tree', 'tree-decomposition', 'compilation', 'wmc',
'monte-carlo', 'karp-luby', 'stopping-rule',
];
function renderEvalResult(cell) {
const el = cellEl(cell);
const box = el && el.querySelector('.nb-eval__out');
if (!box) return;
if (!cell.result) {
box.hidden = true;
box.innerHTML = '';
return;
}
box.hidden = false;
const esc = env.escapeHtml;
const r = cell.result;
if (r.error) {
box.innerHTML = ` `
+ `${esc(r.error)}${r.detail ? ' – ' + esc(r.detail) : ''}
`;
return;
}
const value = (r.result == null) ? '(null)'
: (typeof r.result === 'object' ? JSON.stringify(r.result, null, 1)
: String(r.result));
const multiline = value.includes('\n') || value.length > 100;
box.innerHTML =
(multiline
? `${esc(value)}`
: `${esc(value)}`)
+ (r.resolved_method
? ` via ${esc(r.resolved_method)}`
: '')
+ (cell.elapsed != null
? ` · ${esc(String(cell.elapsed))} ms`
: '');
cell.htmlSnapshot = box.innerHTML;
}
async function runEvalCell(cell) {
const el = cellEl(cell);
const box = el && el.querySelector('.nb-eval__out');
if (box) { box.hidden = false; box.textContent = 'evaluating…'; }
const body = {
token: cell.token,
semiring: cell.semiring || 'probability',
};
if (cell.semiring === 'probability') {
if (cell.method) body.method = cell.method;
if (cell.args) body.arguments = cell.args;
} else if (cell.mapping) {
body.mapping = cell.mapping;
}
const t0 = performance.now();
let payload;
try {
const resp = await fetch('/api/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
payload = await resp.json().catch(() => ({}));
if (!resp.ok && !payload.error) payload.error = `HTTP ${resp.status}`;
} catch (e) {
payload = { error: `Network error: ${e.message}` };
}
cell.elapsed = Math.round(performance.now() - t0);
cell.result = payload;
cell.count = ++execCounter;
updateGutter(cell);
renderEvalResult(cell);
scheduleAutosave();
}
// Insert an evaluation cell for `token` after `afterCell` (a circuit
// cell's "Evaluate" button).
function insertEvalAfter(afterCell, token) {
const idx = cells.indexOf(afterCell);
const cell = newCell('eval');
cell.token = token;
cell.semiring = 'probability';
cell.method = 'exact';
cell.args = '';
cell.mapping = '';
const pos = idx >= 0 ? idx + 1 : cells.length;
cells.splice(pos, 0, cell);
const anchorEl = idx >= 0 ? cellEl(afterCell) : null;
const dom = buildCellDom(cell);
if (anchorEl) anchorEl.after(dom);
else cellsEl().appendChild(dom);
selectCell(cell);
scheduleAutosave();
return cell;
}
/* ──────── sidebar: outline + compact relations ──────── */
/* The where-mode sidebar shows full table contents; next to a cell
list that is wasted space. The notebook sidebar is deliberately
short: an outline of the Markdown headings (click scrolls to the
cell) and a one-line-per-relation schema summary (click inserts
the relation name at the cursor; full contents stay one
Schema-panel click away). */
function outlineEntries() {
const entries = [];
for (const c of cells) {
if (c.type !== 'markdown') continue;
let inFence = false;
for (const line of String(c.source || '').split('\n')) {
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
if (m) entries.push({ cellId: c.id, level: m[1].length, text: m[2] });
}
}
return entries;
}
function renderSidebar() {
const root = document.getElementById('sidebar-body');
if (!root) return;
const esc = env.escapeHtml, escA = env.escapeAttr;
const entries = outlineEntries();
const outlineHtml = entries.length
? `` + entries.map((e) =>
`- ${esc(e.text)}
`
).join('') + `
`
: `Headings in Markdown cells appear here.
`;
let relsHtml = `loading…
`;
if (schemaCache) {
const rels = schemaCache;
relsHtml = rels.length
? `` + rels.map((r) => {
const qname = r.bare_resolves ? r.table : `${r.schema}.${r.table}`;
const cols = (r.columns || []).map((c) => c.name).join(', ');
const colsTitle = (r.columns || [])
.map((c) => `${c.name} ${c.type}`).join('\n');
const pill = r.has_provenance
? `prov`
: '';
return `- `
+ ``
+ `${pill}`
+ `
${esc(cols)}
`
+ ` `;
}).join('') + `
`
: `No relations.
`;
}
root.innerHTML = `
Outline
${outlineHtml}
Relations
${relsHtml}
`;
}
async function refreshSidebarSchema() {
try {
const resp = await fetch('/api/schema');
if (resp.ok) schemaCache = await resp.json();
} catch (e) { /* keep the stale list */ }
renderSidebar();
}
// The SQL cell a panel-driven insert targets: the cell whose editor
// has (or last had) focus, or null when no "querybox is selected" --
// in which case the inserters below append a fresh cell instead of
// clobbering an arbitrary one.
function currentSqlCell() {
const el = document.activeElement && document.activeElement.closest
? document.activeElement.closest('.nb-cell')
: null;
if (el) {
const c = cells.find((x) => x.id === el.dataset.cellId);
if (c && c.type === 'sql') return c;
}
if (lastFocused && cells.includes(lastFocused)
&& lastFocused.type === 'sql') return lastFocused;
return null;
}
function insertSql(text) {
const cell = currentSqlCell() || appendCell('sql');
const el = cellEl(cell);
const ta = el && el.querySelector('.nb-cell__ta');
if (!ta) return;
const s = ta.selectionStart != null ? ta.selectionStart : ta.value.length;
const e = ta.selectionEnd != null ? ta.selectionEnd : s;
ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
ta.setSelectionRange(s + text.length, s + text.length);
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
// Whole-statement form (the schema panel's SELECT * / add_provenance
// / create_provenance_mapping prefills): replace the current cell's
// content, or land in a fresh cell when none is selected.
function replaceSql(text) {
const cell = currentSqlCell() || appendCell('sql');
const el = cellEl(cell);
const ta = el && el.querySelector('.nb-cell__ta');
if (!ta) return;
ta.value = text;
ta.setSelectionRange(text.length, text.length);
ta.focus();
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
function wireSidebar() {
const root = document.getElementById('sidebar-body');
if (!root) return;
root.addEventListener('click', (e) => {
const h = e.target.closest('[data-outline-cell]');
if (h) {
const el = cellsEl().querySelector(
`[data-cell-id="${CSS.escape(h.dataset.outlineCell)}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
el.classList.add('nb-cell--flash');
setTimeout(() => el.classList.remove('nb-cell--flash'), 1200);
}
return;
}
const rel = e.target.closest('[data-rel]');
if (rel) insertSql(rel.dataset.rel);
});
}
/* ──────── selection + command mode (Jupyter keymap) ──────── */
/* Edit mode is "an editor has focus"; Esc drops to command mode with
the cell selected (highlighted border). Command-mode keys follow
Jupyter: a/b insert above/below, dd deletes, z undoes the delete,
m/y convert to Markdown/SQL, j/k or arrows move the selection,
Enter re-enters edit mode, Shift+Enter runs and advances,
Ctrl+Enter runs in place. Alt+Enter (both modes) runs and inserts
a fresh cell below. */
function selectCell(cell, opts) {
selected = cell;
for (const el of cellsEl().querySelectorAll('.nb-cell--selected')) {
el.classList.remove('nb-cell--selected');
}
const el = cell && cellEl(cell);
if (el) {
el.classList.add('nb-cell--selected');
if (!opts || opts.scroll !== false) {
el.scrollIntoView({ block: 'nearest' });
}
}
}
function editCell(cell) {
const el = cellEl(cell);
if (!el) return;
if (cell.type === 'sql') {
el.querySelector('.nb-cell__ta')?.focus();
} else if (cell.type === 'markdown') {
const ta = el.querySelector('.nb-cell__mdta');
if (ta && ta.hidden) el.querySelector('.nb-cell__md')?.dispatchEvent(new Event('dblclick'));
else ta?.focus();
}
}
function insertCellAt(idx, type) {
const c = newCell(type);
cells.splice(idx, 0, c);
const dom = buildCellDom(c);
const next = cells[idx + 1];
const nextEl = next && cellEl(next);
if (nextEl) cellsEl().insertBefore(dom, nextEl);
else cellsEl().appendChild(dom);
scheduleAutosave();
return c;
}
function deleteCell(cell) {
const idx = cells.indexOf(cell);
if (idx < 0) return;
lastDeleted = { cell, index: idx };
cells.splice(idx, 1);
cellEl(cell)?.remove();
if (!cells.length) appendCell('sql');
selectCell(cells[Math.min(idx, cells.length - 1)]);
scheduleAutosave();
}
function undoDelete() {
if (!lastDeleted) return;
const { cell, index } = lastDeleted;
lastDeleted = null;
const idx = Math.min(index, cells.length);
cells.splice(idx, 0, cell);
const next = cells[idx + 1];
const nextEl = next && cellEl(next);
const dom = buildCellDom(cell);
if (nextEl) cellsEl().insertBefore(dom, nextEl);
else cellsEl().appendChild(dom);
updateGutter(cell);
renderOutputs(cell);
selectCell(cell);
scheduleAutosave();
}
// m / y conversion: keep the source, drop type-specific baggage
// (outputs / counters when leaving sql, rendered view when leaving
// markdown). Circuit cells don't convert. SQL going to Markdown is
// wrapped in a ```sql fence so it renders as a code block; Markdown
// coming back to SQL drops the fence again when the whole cell is a
// single fenced block, so m + y round-trips cleanly.
function convertCell(cell, type) {
if (cell.type === type || cell.type === 'circuit'
|| cell.type === 'eval') return;
if (type === 'markdown' && (cell.source || '').trim()) {
cell.source = '```sql\n'
+ String(cell.source).replace(/\n+$/, '') + '\n```';
} else if (type === 'sql') {
const m = String(cell.source || '').trim()
.match(/^(```|~~~)[^\n]*\n([\s\S]*?)\n?\1$/);
if (m) cell.source = m[2];
}
const el = cellEl(cell);
cell.type = type;
cell.outputs = null;
cell.count = null;
cell.htmlSnapshot = null;
cell._noAutoEdit = true;
const dom = buildCellDom(cell);
if (el) el.replaceWith(dom);
updateGutter(cell);
selectCell(cell);
scheduleAutosave();
}
function selectSibling(delta) {
if (!cells.length) return;
const idx = selected ? cells.indexOf(selected) : -1;
const next = idx < 0 ? 0 : Math.max(0, Math.min(cells.length - 1, idx + delta));
selectCell(cells[next]);
}
function inEditMode() {
const ae = document.activeElement;
return !!(ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT'
|| ae.tagName === 'SELECT' || ae.isContentEditable));
}
function commandKeydown(e) {
if (inEditMode()) return; // edit-mode keys live on the editors
if (!selected || !cells.includes(selected)) {
// Jupyter's invariant: the selection is never empty -- there is
// always exactly one selected cell for command-mode keys to act
// on. Re-establish it (visibly) if some path dropped it.
if (!cells.length) return;
selectCell(cells[0], { scroll: false });
}
const idx = cells.indexOf(selected);
const plain = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
if (plain && e.key === 'a') {
selectCell(insertCellAt(idx, 'sql'));
} else if (plain && e.key === 'b') {
selectCell(insertCellAt(idx + 1, 'sql'));
} else if (plain && e.key === 'd') {
const now = Date.now();
if (now - lastDKey < 600) { lastDKey = 0; deleteCell(selected); }
else lastDKey = now;
return; // don't reset the dd timer below
} else if (plain && e.key === 'z') {
undoDelete();
} else if (plain && e.key === 'm') {
convertCell(selected, 'markdown');
} else if (plain && e.key === 'y') {
convertCell(selected, 'sql');
} else if (plain && (e.key === 'j' || e.key === 'ArrowDown')) {
e.preventDefault();
selectSibling(+1);
} else if (plain && (e.key === 'k' || e.key === 'ArrowUp')) {
e.preventDefault();
selectSibling(-1);
} else if (plain && e.key === 'Enter') {
e.preventDefault();
editCell(selected);
} else if (e.key === 'Enter' && e.shiftKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
runSelectedThenAdvance(selected);
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (selected.type === 'sql') runCell(selected);
else if (selected.type === 'circuit') refreshCircuitCell(selected);
else if (selected.type === 'eval') runEvalCell(selected);
} else if (e.key === 'Enter' && e.altKey) {
e.preventDefault();
runAltEnter(selected);
} else {
lastDKey = 0;
return;
}
lastDKey = 0;
}
// Shift+Enter, Jupyter semantics: run (or render), then select the
// next cell in command mode (never opening its editor). The one
// exception, also Jupyter's: at the last cell a fresh SQL cell is
// created below and opened in edit mode.
async function runSelectedThenAdvance(cell) {
if (cell.type === 'sql') await runCell(cell);
else if (cell.type === 'circuit' && cell.token) await refreshCircuitCell(cell);
else if (cell.type === 'eval' && cell.token) await runEvalCell(cell);
const idx = cells.indexOf(cell);
const atEnd = idx === cells.length - 1;
if (atEnd) appendCell('sql');
selectCell(cells[idx + 1]);
if (atEnd) editCell(cells[idx + 1]);
}
// Alt+Enter: run, then insert a fresh SQL cell below and edit it.
async function runAltEnter(cell) {
if (cell.type === 'sql') await runCell(cell);
const c = insertCellAt(cells.indexOf(cell) + 1, 'sql');
selectCell(c);
editCell(c);
}
/* ──────── execution ──────── */
function setRunningUi(on) {
byId('nb-run').disabled = on;
byId('nb-run-all').disabled = on;
byId('nb-restart').disabled = on;
byId('nb-interrupt').hidden = !on;
}
async function runCell(cell) {
if (cell.type !== 'sql' || running) return;
let k;
try {
k = await ensureKernel();
} catch (e) {
// Surface the failure in the cell too -- the kernel chip alone is
// easy to miss when the eye is on the cell that "did nothing".
cell.outputs = { blocks: [{ kind: 'error',
message: `Could not start a kernel: ${e.message}` }], notices: [] };
renderOutputs(cell);
return;
}
const requestId = (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
running = { cellId: cell.id, requestId };
updateGutter(cell);
setRunningUi(true);
const t0 = performance.now();
let payload = null;
try {
const resp = await fetch('/api/nb/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: k.sessionId,
sql: cell.source,
// Per-cell override first (e.g. cs7's boolean-provenance
// narrative); the toolbar selector is the notebook default.
prov_scheme: cell.scheme || byId('nb-scheme').value,
request_id: requestId,
}),
});
payload = await resp.json().catch(() => null);
if (!payload) payload = { blocks: [], notices: [] };
if (!resp.ok && payload.error) {
// Session-level failure (expired / busy / dead kernel): shape it
// like an error block so the cell renders it in place.
payload.blocks = [{ kind: 'error', message: payload.error }];
}
} catch (e) {
payload = { blocks: [{ kind: 'error', message: `Network error: ${e.message}` }],
notices: [] };
} finally {
running = null;
setRunningUi(false);
}
payload.elapsed_ms = Math.round(performance.now() - t0);
cell.outputs = payload;
cell.count = ++execCounter;
if (payload.kernel_dead) {
kernelGone('kernel died – restart it');
cell.count = null;
}
updateGutter(cell);
renderOutputs(cell);
scheduleAutosave();
// DDL in a cell (CREATE TABLE, add_provenance, ...) must reflect in
// the sidebar and the metadata panels, same as a where-mode exec.
refreshSidebarSchema();
if (window.ProvsqlStudio.metadata && window.ProvsqlStudio.metadata.invalidateAll) {
window.ProvsqlStudio.metadata.invalidateAll();
}
}
async function runAll() {
for (const cell of cells) {
if (cell.type === 'circuit') {
if (cell.token) await refreshCircuitCell(cell);
continue;
}
if (cell.type === 'eval') {
if (cell.token) await runEvalCell(cell);
continue;
}
if (cell.type !== 'sql' || !(cell.source || '').trim()) continue;
await runCell(cell);
// A dead kernel aborts the run: later cells would all fail the
// same way and bury the actual diagnostic.
if (!kernel) break;
}
}
async function interrupt() {
if (!running) return;
try {
await fetch(`/api/cancel/${encodeURIComponent(running.requestId)}`,
{ method: 'POST' });
} catch (e) { /* the in-flight exec returns either way */ }
}
/* ──────── nbformat (de)serialization ──────── */
function toLines(text) {
// nbformat convention: array of lines, each keeping its newline.
const parts = String(text || '').split('\n');
return parts.map((l, i) => (i < parts.length - 1 ? l + '\n' : l))
.filter((l, i, a) => !(i === a.length - 1 && l === ''));
}
function joinSource(source) {
return Array.isArray(source) ? source.join('') : String(source || '');
}
function toIpynb() {
return {
nbformat: 4,
nbformat_minor: 5,
metadata: {
kernelspec: { name: 'provsql-studio', display_name: 'ProvSQL (SQL)',
language: 'sql' },
language_info: { name: 'sql' },
provsql: {
scheme: byId('nb-scheme').value,
// The database binding: which environment this notebook was
// authored against (no credentials, just the name).
database: (currentTab() && currentTab().db) || connDb || undefined,
},
},
cells: cells.map((c) => {
if (c.type === 'markdown') {
return { cell_type: 'markdown', metadata: {}, source: toLines(c.source) };
}
if (c.type === 'eval') {
const outputs = [];
if (c.result) {
const data = { 'application/vnd.provsql.eval+json': c.result };
if (!c.result.error && c.result.result != null) {
data['text/plain'] = toLines(
typeof c.result.result === 'object'
? JSON.stringify(c.result.result)
: String(c.result.result));
}
outputs.push({ output_type: 'execute_result',
execution_count: c.count, metadata: {}, data });
}
return {
cell_type: 'code', execution_count: c.count,
metadata: { provsql: {
cell: 'eval', token: c.token,
semiring: c.semiring || 'probability',
method: c.method || undefined, arguments: c.args || undefined,
mapping: c.mapping || undefined,
} },
source: toLines(
`-- evaluate ${c.semiring || 'probability'}`
+ (c.method ? `/${c.method}` : '')
+ ` on ${c.token}`),
outputs,
};
}
if (c.type === 'circuit') {
// A circuit cell is a code cell whose payload lives in
// metadata.provsql; the SQL-comment source keeps external
// viewers (and a hypothetical SQL kernel) from choking.
const outputs = [];
if (c.scene) {
const data = { 'application/vnd.provsql.scene+json': c.scene };
if (c.htmlSnapshot) data['image/svg+xml'] = toLines(c.htmlSnapshot);
outputs.push({ output_type: 'execute_result',
execution_count: null, metadata: {}, data });
}
return {
cell_type: 'code', execution_count: null,
metadata: { provsql: { cell: 'circuit', token: c.token,
row_prov: c.rowProv || undefined,
token_kind: c.tokenKind || undefined } },
source: toLines(`-- circuit ${c.token}`),
outputs,
};
}
const outputs = [];
if (c.outputs) {
const data = {
'application/vnd.provsql.blocks+json': c.outputs,
};
if (c.htmlSnapshot) data['text/html'] = toLines(c.htmlSnapshot);
outputs.push({ output_type: 'execute_result',
execution_count: c.count, metadata: {}, data });
}
return { cell_type: 'code', execution_count: c.count,
metadata: { provsql: c.scheme ? { scheme: c.scheme } : {} },
source: toLines(c.source), outputs };
}),
};
}
function fromIpynb(doc) {
if (!doc || doc.nbformat !== 4 || !Array.isArray(doc.cells)) {
throw new Error('not an nbformat-4 notebook');
}
const loaded = [];
for (const c of doc.cells) {
if (c.cell_type === 'markdown') {
loaded.push(newCell('markdown', joinSource(c.source)));
} else if (c.cell_type === 'code'
&& c.metadata && c.metadata.provsql
&& c.metadata.provsql.cell === 'eval') {
const cell = newCell('eval');
const p = c.metadata.provsql;
cell.token = String(p.token || '');
cell.semiring = String(p.semiring || 'probability');
cell.method = String(p.method || '');
cell.args = String(p.arguments || '');
cell.mapping = String(p.mapping || '');
cell.count = (typeof c.execution_count === 'number') ? c.execution_count : null;
for (const o of (c.outputs || [])) {
const r = o && o.data && o.data['application/vnd.provsql.eval+json'];
if (r) { cell.result = r; break; }
}
loaded.push(cell);
} else if (c.cell_type === 'code'
&& c.metadata && c.metadata.provsql
&& c.metadata.provsql.cell === 'circuit') {
const cell = newCell('circuit');
cell.token = String(c.metadata.provsql.token || '');
cell.rowProv = String(c.metadata.provsql.row_prov || '');
cell.tokenKind = String(c.metadata.provsql.token_kind || '');
for (const o of (c.outputs || [])) {
// Re-paint only from the scene JSON (through our own
// painter); the svg snapshot is for external viewers.
const s = o && o.data && o.data['application/vnd.provsql.scene+json'];
if (s) { cell.scene = s; break; }
}
loaded.push(cell);
} else if (c.cell_type === 'code') {
const cell = newCell('sql', joinSource(c.source));
const pmeta = (c.metadata && c.metadata.provsql) || {};
if (['semiring', 'where', 'boolean'].includes(pmeta.scheme)) {
cell.scheme = pmeta.scheme;
}
cell.count = (typeof c.execution_count === 'number') ? c.execution_count : null;
for (const o of (c.outputs || [])) {
// Studio re-renders ONLY from its own JSON payload; the
// text/html bundle is for external viewers and is never
// injected back (a tampered file must not stored-XSS us).
const p = o && o.data && o.data['application/vnd.provsql.blocks+json'];
if (p) { cell.outputs = p; break; }
}
loaded.push(cell);
}
// raw cells: dropped (Studio has no use for them)
}
const scheme = doc.metadata && doc.metadata.provsql && doc.metadata.provsql.scheme;
return { loaded, scheme };
}
function renderAll() {
const root = cellsEl();
root.innerHTML = '';
for (const c of cells) root.appendChild(buildCellDom(c));
for (const c of cells) { updateGutter(c); renderOutputs(c); }
}
// Set the notebook-level scheme AND the cross-mode session channel
// (ps.opt.provScheme) that Circuit mode's radio selector restores
// from, so a Notebook->Circuit switch keeps the active notebook's
// scheme. (Per-notebook metadata still wins when a tab/doc carries
// one; the channel reflects whatever is currently active.)
function setScheme(value) {
if (!['semiring', 'where', 'boolean'].includes(value)) return;
byId('nb-scheme').value = value;
try { sessionStorage.setItem('ps.opt.provScheme', value); } catch (e) {}
}
function loadNotebook(doc) {
const { loaded, scheme } = fromIpynb(doc);
cells = loaded.length ? loaded : [newCell('sql')];
execCounter = cells.reduce((m, c) => Math.max(m, c.count || 0), 0);
if (scheme) setScheme(scheme);
renderAll();
}
/* ──────── persistence: download / file-load / autosave ──────── */
async function download() {
const tab = currentTab();
const suggested = (((tab && tabDisplayName(tab)) || 'notebook')
.replace(/[^\w.-]+/g, '_')) + '.ipynb';
const json = JSON.stringify(toIpynb(), null, 1);
// Real save dialog (destination + filename) where the File System
// Access API exists (Chromium, secure context -- the Playground's
// main target). Elsewhere (Firefox, plain-http remote hosts) there
// is no scriptable picker: fall back to a classic download under
// the suggested name and let the browser's own "ask where to save"
// setting decide whether a dialog appears -- no prompt boxes.
if (window.showSaveFilePicker) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: suggested,
types: [{ description: 'Jupyter notebook',
accept: { 'application/x-ipynb+json': ['.ipynb'] } }],
});
const w = await handle.createWritable();
await w.write(json);
await w.close();
return;
} catch (e) {
if (e && e.name === 'AbortError') return; // user cancelled
// any other failure: fall through to the download path
}
}
const blob = new Blob([json], { type: 'application/x-ipynb+json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = suggested;
a.click();
URL.revokeObjectURL(a.href);
}
let autosaveTimer = null;
function flushAutosave() {
clearTimeout(autosaveTimer);
persistTabs();
}
function scheduleAutosave() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => {
// Every model change funnels through here, so the debounced tick
// doubles as the sidebar/outline + tab-name refresh trigger.
renderSidebar();
renderTabBar();
persistTabs();
}, 500);
}
// Restore the persisted tab set. Returns true when something was
// restored; the single-notebook 'ps.nb.autosave' blob from before the
// tab era migrates into a lone tab.
function restoreTabs() {
let saved = null;
try {
saved = JSON.parse(localStorage.getItem('ps.nb.tabs') || 'null');
} catch (e) { saved = null; }
if (!saved || !Array.isArray(saved.tabs) || !saved.tabs.length) {
let legacy = null;
try {
legacy = JSON.parse(localStorage.getItem('ps.nb.autosave') || 'null');
} catch (e) { legacy = null; }
if (!legacy) return false;
try { localStorage.removeItem('ps.nb.autosave'); } catch (e) {}
const db = (legacy.metadata && legacy.metadata.provsql
&& legacy.metadata.provsql.database) || connDb;
tabs = [makeTab('Untitled', db, legacy)];
activeTabId = tabs[0].id;
return true;
}
tabs = saved.tabs.map((t) => ({
id: t.id, name: t.name || 'Untitled', db: t.db || null,
doc: t.doc || null, kernel: null, resume: t.resume || null,
}));
nextTabId = tabs.reduce(
(m, t) => Math.max(m, parseInt(String(t.id).slice(1), 10) || 0), 0) + 1;
activeTabId = tabs.some((t) => t.id === saved.active)
? saved.active : tabs[0].id;
return true;
}
/* ──────── bundled example notebooks ──────── */
/* The tutorial / case-study notebooks generated from the user guide
(studio/scripts/rst2nb.py), served by name from
/api/nb/examples/. Opening one is exactly a file load: a new
tab bound per the notebook's metadata (the binding banner then
offers to create/switch to its database). */
async function populateExamples() {
const sel = byId('nb-example');
if (!sel) return;
let examples = [];
try {
const resp = await fetch('/api/nb/examples');
if (resp.ok) examples = await resp.json();
} catch (e) { /* treated as no examples below */ }
if (!examples.length) {
// Older server (endpoint missing) or a build without the bundled
// notebooks: hide the menu rather than leaving a dead control.
sel.hidden = true;
return;
}
sel.hidden = false;
for (const ex of examples) {
const opt = document.createElement('option');
opt.value = ex.name;
opt.textContent = ex.title || ex.name;
sel.appendChild(opt);
}
}
async function openExample(name) {
try {
const resp = await fetch(`/api/nb/examples/${encodeURIComponent(name)}`);
const doc = await resp.json();
if (!resp.ok) throw new Error(doc.error || `HTTP ${resp.status}`);
fromIpynb(doc); // validate before opening a tab
const db = (doc.metadata && doc.metadata.provsql
&& doc.metadata.provsql.database) || connDb;
newTab(name, db, doc);
} catch (e) {
window.alert(`Could not open example ${name}: ${e.message}`);
}
}
/* ──────── boot ──────── */
function defaultNotebook() {
// A single empty SQL cell: no boilerplate Markdown, so fresh tabs
// stay "Untitled" until the user gives the notebook an H1 heading.
cells = [newCell('sql')];
renderAll();
}
async function init(studioEnv) {
env = studioEnv;
// Result-table UUID / agg_token / random_variable cells carry
// data-circuit-uuid (makeBlockRenderer marks them in notebook
// mode); clicking one materialises the circuit snapshot right
// below the SQL cell that produced it.
document.addEventListener('keydown', commandKeydown);
cellsEl().addEventListener('mousedown', (e) => {
const host = e.target.closest('.nb-cell');
const cell = host && cells.find((c) => c.id === host.dataset.cellId);
if (cell) selectCell(cell, { scroll: false });
});
cellsEl().addEventListener('click', (e) => {
const td = e.target.closest('[data-circuit-uuid]');
if (!td) return;
const host = td.closest('.nb-cell');
const cell = host && cells.find((c) => c.id === host.dataset.cellId);
if (cell) {
showCircuitFor(td.dataset.circuitUuid, cell,
td.dataset.rowProv || '',
td.dataset.tokenKind || '');
}
});
byId('nb-run').addEventListener('click', () => {
const c = focusedCell();
if (c) runCell(c);
});
byId('nb-run-all').addEventListener('click', runAll);
byId('nb-interrupt').addEventListener('click', interrupt);
byId('nb-restart').addEventListener('click', () => {
if (window.confirm('Restart the kernel? Temp tables and session '
+ 'settings will be lost.')) restartKernel();
});
byId('nb-add-sql').addEventListener('click', () => { focusCell(appendCell('sql')); scheduleAutosave(); });
byId('nb-add-md').addEventListener('click', () => { focusCell(appendCell('markdown')); scheduleAutosave(); });
byId('nb-scheme').addEventListener('change', () => {
setScheme(byId('nb-scheme').value);
scheduleAutosave();
});
byId('nb-save').addEventListener('click', download);
byId('nb-load').addEventListener('click', () => byId('nb-load-input').click());
byId('nb-example').addEventListener('change', () => {
const name = byId('nb-example').value;
byId('nb-example').value = '';
if (name) openExample(name);
});
byId('nb-load-input').addEventListener('change', async () => {
const input = byId('nb-load-input');
const file = input.files && input.files[0];
input.value = '';
if (!file) return;
try {
const doc = JSON.parse(await file.text());
// Validate before opening a tab for it.
fromIpynb(doc);
const name = file.name.replace(/\.ipynb$/i, '') || 'notebook';
const db = (doc.metadata && doc.metadata.provsql
&& doc.metadata.provsql.database) || connDb;
// A loaded notebook always lands in its own tab, bound to the
// database recorded in its metadata; the binding banner offers
// switch / create / rebind when that is not the live one.
newTab(name, db, doc);
} catch (e) {
window.alert(`Could not load ${file.name}: ${e.message}`);
}
});
// Best-effort kernel cleanup when the tab goes away; the server's
// idle GC is the real backstop.
window.addEventListener('pagehide', () => {
// The debounced autosave may not have fired yet (e.g. a mode
// switch right after an edit); write synchronously so nothing is
// lost on navigation. flushAutosave -> persistTabs also records
// the per-tab resume point (selection + scroll).
flushAutosave();
if (navigator.sendBeacon) {
for (const t of tabs) {
const k = t.id === activeTabId ? kernel : t.kernel;
if (k) navigator.sendBeacon(`/api/nb/session/${k.sessionId}/close`);
}
}
});
wireSidebar();
// Same body-level switch as circuit mode's fingerprint toggle:
// formatCell renders every uuid cell as a short/full span pair, and
// body.show-uuids flips which one is visible -- no re-render.
const uuidBtn = byId('nb-show-uuids');
uuidBtn.addEventListener('click', () => {
const on = !document.body.classList.contains('show-uuids');
document.body.classList.toggle('show-uuids', on);
uuidBtn.setAttribute('aria-pressed', String(on));
});
wireTabBar();
// Inherit the session's scheme (the Circuit-mode pick) for fresh
// notebooks; tabs whose doc records a scheme override it below.
try {
const saved = sessionStorage.getItem('ps.opt.provScheme');
if (saved && ['semiring', 'where', 'boolean'].includes(saved)) {
byId('nb-scheme').value = saved;
}
} catch (e) { /* sessionStorage disabled */ }
// The current database name anchors every binding decision; fetch
// it before restoring tabs (one round-trip, also warms /api/conn).
try {
const resp = await fetch('/api/conn');
if (resp.ok) connDb = (await resp.json()).database || null;
} catch (e) { /* banner logic degrades to no-op without connDb */ }
if (!restoreTabs()) {
tabs = [makeTab('Untitled', connDb, null)];
activeTabId = tabs[0].id;
}
// Booting on a database different from the active tab's binding
// (the user switched databases elsewhere): activate the tab bound
// to the new database, or open a fresh one for it -- the previous
// tab stays, with its binding shown on the tab.
const active = currentTab();
if (active && active.db && connDb && active.db !== connDb) {
const match = tabs.find((t) => t.db === connDb);
if (match) {
activeTabId = match.id;
} else {
const t = makeTab('Untitled', connDb, null);
tabs.push(t);
activeTabId = t.id;
}
}
loadTabIntoView(currentTab());
persistTabs();
refreshSidebarSchema();
populateExamples();
// Deep link: /notebook?nb= opens that bundled notebook in
// its own tab (an already-open tab of the same name is reused).
const wanted = new URLSearchParams(window.location.search).get('nb');
if (wanted) {
const existing = tabs.find((t) => t.name === wanted);
if (existing) activateTab(existing.id);
else openExample(wanted);
}
}
// Mode-switch carry (app.js carryQueryForSwitch): the current cell's
// SQL, with ran=true when that cell was executed on this kernel.
function currentSqlForCarry() {
const cell = currentSqlCell();
if (!cell) return { sql: '', ran: false };
return { sql: cell.source || '', ran: cell.count != null };
}
window.ProvsqlNotebook = { init, insertSql, replaceSql, currentSqlForCarry };
})();