From 8aa10d9bc208c4fda239cd070de1c87a2fc62023 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Thu, 23 Apr 2026 16:21:30 +0100 Subject: [PATCH] add event_date column to event_knowledge and event_predictions tables; update related logic in admin panel and augorWorker --- CLAUDE.md | 4 + admin.html | 289 ++++++++++++++++++++++++++++++++++---------- intelligence/db.js | 121 ++++++++++++++++++- src/routes/admin.js | 40 +++--- 4 files changed, 365 insertions(+), 89 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2488f24..09b7cd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +# Production Database + +Never run direct database operations (inserts, updates, deletes, schema changes) against the local SQLite files (e.g. intelligence.sqlite, archive.sqlite). These are local copies and do not affect the production database. All data changes must go through code that runs in production — seed functions, migration scripts, workers, etc. + # Database Policy When making any changes to the database schema or data, a strictly no data loss policy must be followed. This means: diff --git a/admin.html b/admin.html index f8dcd73..3189f77 100644 --- a/admin.html +++ b/admin.html @@ -478,6 +478,15 @@ height: 600px; } + #intel-graph-svg-wrap.graph-expanded { + position: fixed; + inset: 0; + z-index: 500; + border-radius: 0; + border: none; + height: 100dvh; + } + #intel-graph-svg { width: 100%; height: 100%; @@ -689,6 +698,7 @@
+
@@ -697,9 +707,15 @@
- Tracked company - Untracked entity - Scroll to zoom · drag nodes · click to see facts + Tracked company + Untracked entity + + + + Scroll to zoom · drag nodes · click for facts
@@ -1215,6 +1231,32 @@ document.getElementById('i-view').onchange = () => { intelOffset = 0; loadIntell // ── intelligence graph ───────────────────────────────────────────────────── +// tickers that dont make a readable label inside a node +const TICKER_DISPLAY = { + '005930.KS': 'SMSNG', + '000660.KS': 'HYNIX', + 'OPENAI': 'OAI', + 'ANTHROPIC': 'ANTH', +}; + +// split a name into up to two lines at a natural word boundary +function splitLabel(name, maxLine) { + if (name.length <= maxLine) return [name]; + const mid = Math.floor(name.length / 2); + // find a space near the middle to break on + let best = -1; + let bestDist = Infinity; + for (let i = 0; i < name.length; i++) { + if (name[i] === ' ') { + const dist = Math.abs(i - mid); + if (dist < bestDist) { bestDist = dist; best = i; } + } + } + if (best === -1) return [name]; // no spaces, just show as-is + return [name.slice(0, best), name.slice(best + 1)]; +} + + function showGraphView(visible) { document.getElementById('intel-graph-wrap').style.display = visible ? 'block' : 'none'; document.getElementById('intel-table-wrap').style.display = visible ? 'none' : ''; @@ -1222,6 +1264,9 @@ function showGraphView(visible) { } +let graphAllNodes = []; +let graphAllLinks = []; + async function loadIntelGraph() { showGraphView(true); @@ -1237,22 +1282,20 @@ async function loadIntelGraph() { document.getElementById('graph-empty').style.display = 'none'; - // build keyed nodes const nodeMap = new Map(); for (const n of data.nodes) { const key = n.tracked ? `c_${n.id}` : `u_${n.name}`; nodeMap.set(key, { ...n, key }); } - const nodes = Array.from(nodeMap.values()); + graphAllNodes = Array.from(nodeMap.values()); - const links = []; + graphAllLinks = []; for (const e of data.edges) { const src = `c_${e.from_company_id}`; const tgt = e.to_company_id ? `c_${e.to_company_id}` : `u_${e.to_entity}`; - if (nodeMap.has(src) && nodeMap.has(tgt)) { - links.push({ + graphAllLinks.push({ source: src, target: tgt, type: e.relationship_type, @@ -1262,62 +1305,132 @@ async function loadIntelGraph() { } } - renderIntelGraph(nodes, links); + renderIntelGraph(); +} + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + const wrap = document.getElementById('intel-graph-svg-wrap'); + if (wrap.classList.contains('graph-expanded')) toggleGraphExpand(); + } +}); + +document.getElementById('graph-show-untracked').addEventListener('change', () => { + if (graphAllNodes.length) renderIntelGraph(); +}); + + +function toggleGraphExpand() { + const wrap = document.getElementById('intel-graph-svg-wrap'); + const btn = document.getElementById('graph-expand-btn'); + const expanded = wrap.classList.toggle('graph-expanded'); + btn.textContent = expanded ? '⤡ Collapse' : '⤢ Expand'; + // re-render so the simulation uses the new dimensions + if (graphAllNodes.length) renderIntelGraph(); } -function renderIntelGraph(nodes, links) { +function renderIntelGraph() { + const showUntracked = document.getElementById('graph-show-untracked').checked; + + const nodes = showUntracked + ? graphAllNodes + : graphAllNodes.filter(n => n.tracked); + + const visibleKeys = new Set(nodes.map(n => n.key)); + const links = graphAllLinks.filter(l => visibleKeys.has( + typeof l.source === 'object' ? l.source.key : l.source + ) && visibleKeys.has( + typeof l.target === 'object' ? l.target.key : l.target + )); + + // deep-copy so d3 doesnt mutate our stored arrays on re-render + const nodesCopy = nodes.map(n => ({ ...n })); + const linksCopy = links.map(l => ({ + ...l, + source: typeof l.source === 'object' ? l.source.key : l.source, + target: typeof l.target === 'object' ? l.target.key : l.target, + })); + const svgEl = document.getElementById('intel-graph-svg'); svgEl.innerHTML = ''; - const width = svgEl.clientWidth || 800; + const width = svgEl.clientWidth || 900; const height = svgEl.clientHeight || 600; const svg = d3.select(svgEl); - const g = svg.append('g'); - // arrow marker - svg.append('defs').append('marker') + // svg defs — arrow marker + glow filter + const defs = svg.append('defs'); + + defs.append('marker') .attr('id', 'ig-arrow') .attr('viewBox', '0 -5 10 10') - .attr('refX', 22) + .attr('refX', 28) .attr('refY', 0) .attr('markerWidth', 5) .attr('markerHeight', 5) .attr('orient', 'auto') .append('path') .attr('d', 'M0,-5L10,0L0,5') - .attr('fill', '#334155'); + .attr('fill', '#475569'); - const maxCount = Math.max(...links.map(l => l.count), 1); + const glow = defs.append('filter') + .attr('id', 'ig-glow') + .attr('x', '-50%').attr('y', '-50%') + .attr('width', '200%').attr('height', '200%'); + glow.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'blur'); + const merge = glow.append('feMerge'); + merge.append('feMergeNode').attr('in', 'blur'); + merge.append('feMergeNode').attr('in', 'SourceGraphic'); - const zoomBehavior = d3.zoom().scaleExtent([0.1, 5]).on('zoom', ev => g.attr('transform', ev.transform)); + const maxCount = Math.max(...linksCopy.map(l => l.count), 1); + + const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', ev => g.attr('transform', ev.transform)); svg.call(zoomBehavior); - const simulation = d3.forceSimulation(nodes) - .force('link', d3.forceLink(links).id(d => d.key).distance(150)) - .force('charge', d3.forceManyBody().strength(-500)) + const simulation = d3.forceSimulation(nodesCopy) + .force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => { + // tracked-tracked links spread wider + const bothTracked = typeof d.source === 'object' ? d.source.tracked && d.target.tracked : false; + return bothTracked ? 220 : 160; + })) + .force('charge', d3.forceManyBody().strength(-900)) .force('center', d3.forceCenter(width / 2, height / 2)) - .force('collide', d3.forceCollide(40)); + .force('collide', d3.forceCollide(d => d.tracked ? 52 : 38)); - const linkLines = g.append('g') - .selectAll('line') - .data(links) + // links + const linkG = g.append('g'); + + const linkLines = linkG.selectAll('line') + .data(linksCopy) .join('line') .attr('stroke', '#334155') - .attr('stroke-width', d => 1 + (d.count / maxCount) * 3) - .attr('stroke-opacity', d => 0.3 + (d.count / maxCount) * 0.6) + .attr('stroke-width', d => { + // more dramatic: 1px at count=1, up to 7px at maxCount + return 1 + Math.pow(d.count / maxCount, 0.6) * 6; + }) + .attr('stroke-opacity', d => 0.25 + (d.count / maxCount) * 0.65) .attr('marker-end', 'url(#ig-arrow)'); - // relationship type on hover only via title - linkLines.append('title').text(d => `${d.type} (×${d.count})`); + // edge labels — size and opacity proportional to count + const linkLabels = linkG.selectAll('text') + .data(linksCopy) + .join('text') + .text(d => d.type) + .attr('text-anchor', 'middle') + .attr('font-size', d => 7 + Math.round((d.count / maxCount) * 4)) + .attr('fill', '#475569') + .attr('opacity', d => 0.4 + (d.count / maxCount) * 0.5) + .attr('pointer-events', 'none'); + // nodes const nodeGroups = g.append('g') .selectAll('g') - .data(nodes) + .data(nodesCopy) .join('g') .attr('cursor', d => d.tracked ? 'pointer' : 'default') .call( @@ -1336,29 +1449,66 @@ function renderIntelGraph(nodes, links) { if (d.tracked) openGraphSidebar(d); }); + // tracked node: glow circle + main circle + nodeGroups.filter(d => d.tracked).append('circle') + .attr('r', 24) + .attr('fill', 'rgba(59,130,246,0.08)') + .attr('stroke', 'rgba(59,130,246,0.25)') + .attr('stroke-width', 1) + .attr('filter', 'url(#ig-glow)') + .attr('pointer-events', 'none'); + nodeGroups.append('circle') - .attr('r', d => d.tracked ? 16 : 11) - .attr('fill', d => d.tracked ? '#1e3a5f' : '#0f172a') - .attr('stroke', d => d.tracked ? '#3b82f6' : '#475569') + .attr('r', d => d.tracked ? 20 : 12) + .attr('fill', d => d.tracked ? '#1a3152' : '#0b1524') + .attr('stroke', d => d.tracked ? '#3b82f6' : '#334155') .attr('stroke-width', d => d.tracked ? 2 : 1.5); - // ticker label inside tracked nodes; short name below untracked nodes + // short ticker label inside circle for tracked nodes nodeGroups.filter(d => d.tracked).append('text') - .text(d => d.ticker || d.name.slice(0, 5)) + .text(d => TICKER_DISPLAY[d.ticker] || (d.ticker && d.ticker.length <= 5 ? d.ticker : d.name.slice(0, 4).toUpperCase())) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('font-size', 9) - .attr('font-weight', '600') + .attr('font-size', 8) + .attr('font-weight', '700') .attr('fill', '#93c5fd') + .attr('letter-spacing', '0.04em') .attr('pointer-events', 'none'); - nodeGroups.filter(d => !d.tracked).append('text') - .text(d => d.name.length > 12 ? d.name.slice(0, 11) + '…' : d.name) - .attr('text-anchor', 'middle') - .attr('y', 20) - .attr('font-size', 9) - .attr('fill', '#475569') - .attr('pointer-events', 'none'); + // full company name below tracked nodes — wrapped + nodeGroups.filter(d => d.tracked).each(function(d) { + const lines = splitLabel(d.name, 14); + const el = d3.select(this).append('text') + .attr('text-anchor', 'middle') + .attr('font-size', 10) + .attr('font-weight', '500') + .attr('fill', '#cbd5e1') + .attr('pointer-events', 'none'); + + lines.forEach((line, i) => { + el.append('tspan') + .attr('x', 0) + .attr('dy', i === 0 ? 32 : 13) + .text(line); + }); + }); + + // full entity name below untracked nodes — wrapped + nodeGroups.filter(d => !d.tracked).each(function(d) { + const lines = splitLabel(d.name, 14); + const el = d3.select(this).append('text') + .attr('text-anchor', 'middle') + .attr('font-size', 9) + .attr('fill', '#475569') + .attr('pointer-events', 'none'); + + lines.forEach((line, i) => { + el.append('tspan') + .attr('x', 0) + .attr('dy', i === 0 ? 20 : 12) + .text(line); + }); + }); nodeGroups.append('title').text(d => d.name); @@ -1370,15 +1520,18 @@ function renderIntelGraph(nodes, links) { .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); + linkLabels + .attr('x', d => (d.source.x + d.target.x) / 2) + .attr('y', d => (d.source.y + d.target.y) / 2 - 3); + nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`); }); - // fit everything into view once the simulation has cooled enough simulation.on('end', () => { const bounds = g.node().getBBox(); if (!bounds.width || !bounds.height) return; - const pad = 40; + const pad = 60; const scaleX = width / (bounds.width + pad * 2); const scaleY = height / (bounds.height + pad * 2); const scale = Math.min(scaleX, scaleY, 1); @@ -1386,7 +1539,7 @@ function renderIntelGraph(nodes, links) { const tx = width / 2 - scale * (bounds.x + bounds.width / 2); const ty = height / 2 - scale * (bounds.y + bounds.height / 2); - svg.transition().duration(600) + svg.transition().duration(700) .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); }); } @@ -1490,24 +1643,32 @@ async function runSql() { elapsedEl.textContent = `${data.elapsed}ms`; - if (data.rows && data.rows.length > 0) { - const cols = Object.keys(data.rows[0]); - resultsEl.innerHTML = ` -
- - ${cols.map(c => ``).join('')} - ${data.rows.map(r => - `${cols.map(c => ``).join('')}` - ).join('')} -
${c}
${r[c] ?? 'NULL'}
-
-
${data.rows.length} row${data.rows.length !== 1 ? 's' : ''}
- `; - } else if (data.rows) { - resultsEl.innerHTML = '
No rows returned.
'; - } else { - resultsEl.innerHTML = `
${data.changes} row${data.changes !== 1 ? 's' : ''} affected.
`; + const blocks = []; + for (const r of (data.results || [])) { + if (r.error) { + blocks.push(`
${r.sql.slice(0, 120)}${r.error}
`); + } else if (r.rows && r.rows.length > 0) { + const cols = Object.keys(r.rows[0]); + blocks.push(` +
+
+ + ${cols.map(c => ``).join('')} + ${r.rows.map(row => + `${cols.map(c => ``).join('')}` + ).join('')} +
${c}
${row[c] ?? 'NULL'}
+
+
${r.rows.length} row${r.rows.length !== 1 ? 's' : ''}
+
+ `); + } else if (r.rows) { + blocks.push(`
No rows returned.
`); + } else { + blocks.push(`
${r.changes} row${r.changes !== 1 ? 's' : ''} affected.
`); + } } + resultsEl.innerHTML = blocks.join(''); } catch (e) { errEl.textContent = e.message; errEl.style.display = ''; diff --git a/intelligence/db.js b/intelligence/db.js index 0ebc589..5e8e440 100644 --- a/intelligence/db.js +++ b/intelligence/db.js @@ -113,11 +113,8 @@ function runColumnMigrations(db) { } function seedCompanies(db) { - const count = db.prepare("SELECT COUNT(*) as c FROM tracked_companies").get().c; - if (count > 0) return; - const insert = db.prepare( - "INSERT INTO tracked_companies (name, ticker, aliases) VALUES (?, ?, ?)" + "INSERT OR IGNORE INTO tracked_companies (name, ticker, aliases) VALUES (?, ?, ?)" ); const companies = [ @@ -168,6 +165,122 @@ function seedCompanies(db) { { name: "Western Digital", ticker: "WDC", aliases: ["WD", "Western Digital Corporation"] }, { name: "Seagate", ticker: "STX", aliases: ["Seagate Technology"] }, { name: "Pure Storage", ticker: "PSTG", aliases: [] }, + + // more AI labs + { name: "Mistral AI", ticker: "MISTRAL", aliases: ["Mistral"] }, + { name: "Cohere", ticker: "COHERE", aliases: [] }, + { name: "DeepSeek", ticker: "DEEPSEEK", aliases: ["DeepSeek AI"] }, + { name: "Stability AI", ticker: "STABILITY", aliases: [] }, + { name: "Inflection AI", ticker: "INFLECTION", aliases: [] }, + { name: "Scale AI", ticker: "SCALEAI", aliases: ["Scale"] }, + { name: "Hugging Face", ticker: "HF", aliases: [] }, + { name: "Cerebras Systems", ticker: "CBRS", aliases: ["Cerebras"] }, + { name: "Groq", ticker: "GROQ", aliases: [] }, + + // chinese tech + { name: "Alibaba", ticker: "BABA", aliases: ["Alibaba Group", "Taobao", "Alipay"] }, + { name: "Tencent", ticker: "0700.HK", aliases: ["Tencent Holdings", "WeChat"] }, + { name: "Baidu", ticker: "BIDU", aliases: ["Baidu Inc"] }, + { name: "Huawei", ticker: "HUAWEI", aliases: ["Huawei Technologies"] }, + { name: "ByteDance", ticker: "BYTEDANCE", aliases: ["TikTok", "Douyin"] }, + { name: "Xiaomi", ticker: "1810.HK", aliases: [] }, + { name: "SMIC", ticker: "688981.SS", aliases: ["Semiconductor Manufacturing International Corporation"] }, + { name: "DJI", ticker: "DJI", aliases: ["Da-Jiang Innovations"] }, + + // defense / aerospace + { name: "Lockheed Martin", ticker: "LMT", aliases: ["Lockheed"] }, + { name: "Raytheon Technologies", ticker: "RTX", aliases: ["RTX", "Raytheon"] }, + { name: "Northrop Grumman", ticker: "NOC", aliases: ["Northrop"] }, + { name: "Boeing", ticker: "BA", aliases: ["Boeing Company"] }, + { name: "General Dynamics", ticker: "GD", aliases: [] }, + { name: "BAE Systems", ticker: "BA.L", aliases: ["BAE"] }, + { name: "L3Harris Technologies", ticker: "LHX", aliases: ["L3Harris"] }, + { name: "Leidos", ticker: "LDOS", aliases: [] }, + { name: "SAIC", ticker: "SAIC", aliases: ["Science Applications International Corporation"] }, + { name: "Booz Allen Hamilton", ticker: "BAH", aliases: ["Booz Allen"] }, + { name: "Thales Group", ticker: "HO.PA", aliases: ["Thales"] }, + { name: "Airbus", ticker: "AIR.PA", aliases: ["Airbus SE"] }, + { name: "Leonardo", ticker: "LDO.MI", aliases: ["Leonardo SpA"] }, + { name: "Rheinmetall", ticker: "RHM.DE", aliases: [] }, + + // telecom + { name: "Ericsson", ticker: "ERIC", aliases: ["Telefonaktiebolaget LM Ericsson"] }, + { name: "Nokia", ticker: "NOK", aliases: ["Nokia Corporation"] }, + { name: "AT&T", ticker: "T", aliases: [] }, + { name: "Verizon", ticker: "VZ", aliases: ["Verizon Communications"] }, + { name: "T-Mobile", ticker: "TMUS", aliases: ["T-Mobile US"] }, + { name: "Deutsche Telekom", ticker: "DTE.DE", aliases: [] }, + { name: "SoftBank", ticker: "9984.T", aliases: ["SoftBank Group"] }, + + // finance / banking + { name: "JPMorgan Chase", ticker: "JPM", aliases: ["JPMorgan", "JP Morgan"] }, + { name: "Goldman Sachs", ticker: "GS", aliases: ["Goldman"] }, + { name: "BlackRock", ticker: "BLK", aliases: [] }, + { name: "Visa", ticker: "V", aliases: [] }, + { name: "Mastercard", ticker: "MA", aliases: [] }, + { name: "Morgan Stanley", ticker: "MS", aliases: [] }, + { name: "Citigroup", ticker: "C", aliases: ["Citi"] }, + { name: "Bank of America", ticker: "BAC", aliases: ["BofA"] }, + { name: "HSBC", ticker: "HSBA.L", aliases: ["HSBC Holdings"] }, + { name: "UBS", ticker: "UBS", aliases: [] }, + + // energy / resources + { name: "ExxonMobil", ticker: "XOM", aliases: ["Exxon"] }, + { name: "Chevron", ticker: "CVX", aliases: [] }, + { name: "Shell", ticker: "SHEL", aliases: ["Shell plc", "Royal Dutch Shell"] }, + { name: "BP", ticker: "BP", aliases: ["British Petroleum"] }, + { name: "TotalEnergies", ticker: "TTE.PA", aliases: ["Total"] }, + { name: "Saudi Aramco", ticker: "2222.SR", aliases: ["Aramco"] }, + { name: "NextEra Energy", ticker: "NEE", aliases: [] }, + + // pharma / biotech + { name: "Pfizer", ticker: "PFE", aliases: [] }, + { name: "Johnson & Johnson", ticker: "JNJ", aliases: ["J&J"] }, + { name: "Moderna", ticker: "MRNA", aliases: [] }, + { name: "AstraZeneca", ticker: "AZN", aliases: [] }, + { name: "Novartis", ticker: "NVS", aliases: [] }, + { name: "Roche", ticker: "ROG.SW", aliases: ["Roche Holding"] }, + { name: "Eli Lilly", ticker: "LLY", aliases: [] }, + { name: "CRISPR Therapeutics", ticker: "CRSP", aliases: [] }, + + // automotive + { name: "Toyota", ticker: "TM", aliases: ["Toyota Motor"] }, + { name: "Volkswagen", ticker: "VOW.DE", aliases: ["VW"] }, + { name: "Ford", ticker: "F", aliases: ["Ford Motor"] }, + { name: "General Motors", ticker: "GM", aliases: ["GM"] }, + { name: "BYD", ticker: "002594.SZ", aliases: ["BYD Company"] }, + { name: "Rivian", ticker: "RIVN", aliases: [] }, + + // media / entertainment + { name: "Netflix", ticker: "NFLX", aliases: [] }, + { name: "Disney", ticker: "DIS", aliases: ["The Walt Disney Company"] }, + { name: "Comcast", ticker: "CMCSA", aliases: ["NBCUniversal"] }, + { name: "Spotify", ticker: "SPOT", aliases: [] }, + { name: "Warner Bros Discovery", ticker: "WBD", aliases: ["Warner Bros", "HBO"] }, + { name: "News Corp", ticker: "NWSA", aliases: ["Rupert Murdoch"] }, + + // retail / e-commerce + { name: "Walmart", ticker: "WMT", aliases: [] }, + { name: "Shopify", ticker: "SHOP", aliases: [] }, + { name: "eBay", ticker: "EBAY", aliases: [] }, + + // cybersecurity + { name: "CrowdStrike", ticker: "CRWD", aliases: [] }, + { name: "Palo Alto Networks", ticker: "PANW", aliases: [] }, + { name: "SentinelOne", ticker: "S", aliases: [] }, + { name: "Fortinet", ticker: "FTNT", aliases: [] }, + { name: "Cloudflare", ticker: "NET", aliases: [] }, + { name: "Recorded Future", ticker: "RF", aliases: [] }, + + // space + { name: "SpaceX", ticker: "SPACEX", aliases: ["Space Exploration Technologies"] }, + { name: "Blue Origin", ticker: "BLUEORIGIN", aliases: [] }, + { name: "Planet Labs", ticker: "PL", aliases: [] }, + + // consulting / professional services + { name: "McKinsey", ticker: "MCKINSEY", aliases: ["McKinsey & Company"] }, + { name: "Accenture", ticker: "ACN", aliases: [] }, + { name: "Deloitte", ticker: "DELOITTE", aliases: [] }, ]; for (const c of companies) { diff --git a/src/routes/admin.js b/src/routes/admin.js index 3d70448..cbadf00 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -358,7 +358,7 @@ async function adminRoutes(fastify) { }); - // raw sql console + // raw sql console — supports multiple statements separated by ; fastify.post('/admin/api/sql', async (request, reply) => { if (!checkAuth(request, reply)) return; @@ -368,29 +368,27 @@ async function adminRoutes(fastify) { const target = database === 'intelligence' ? getIntelligenceDb() : db; if (!target) { reply.code(400); return { error: 'database not available' }; } - try { - const stmt = target.prepare(sql); - const start = Date.now(); + // split on semicolons, drop empty statements + const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0); - let rows, changes, lastInsertRowid; - if (stmt.reader) { - rows = stmt.all(); - } else { - const info = stmt.run(); - changes = info.changes; - lastInsertRowid = info.lastInsertRowid; + const results = []; + const start = Date.now(); + + for (const s of statements) { + try { + const stmt = target.prepare(s); + if (stmt.reader) { + results.push({ sql: s, rows: stmt.all() }); + } else { + const info = stmt.run(); + results.push({ sql: s, changes: info.changes, lastInsertRowid: info.lastInsertRowid }); + } + } catch (err) { + results.push({ sql: s, error: err.message }); } - - return { - rows: rows || null, - changes: changes ?? null, - lastInsertRowid: lastInsertRowid ?? null, - elapsed: Date.now() - start, - }; - } catch (err) { - reply.code(400); - return { error: err.message }; } + + return { results, elapsed: Date.now() - start }; }); // stats for dashboard header