Stacks: live SQL preview, side-by-side layout, cascade stale detection

- Two-column layout: config left, SQL panel right (equal halves)
- SQL panel shows formatted SQL (sql-formatter, 4-space indent)
- Live preview: SQL updates 400ms after any field/source/mapping change
- Run button executes edited SQL directly via new exec-sql endpoint
- generate_stack_view gains p_dry_run mode for preview without executing
- CASCADE drop detects dependent stacks, marks them stale in DB and status bar
- net_balance moved to last column in generated view
- Backfill 458 missing dcard rows and 123 missing chase rows from TPS migration bug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-04-19 10:36:27 -04:00
parent 7c63a2ac29
commit 95e63679ef
5 changed files with 177 additions and 41 deletions

View File

@ -98,6 +98,14 @@ module.exports = (pool) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// Preview the SQL that would be generated (dry run — does not create the view)
router.get('/:name/view-sql', async (req, res, next) => {
try {
const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}, true) AS result`);
res.json(result.rows[0].result);
} catch (err) { next(err); }
});
// Generate / refresh the dfv view // Generate / refresh the dfv view
router.post('/:name/view', async (req, res, next) => { router.post('/:name/view', async (req, res, next) => {
try { try {
@ -110,6 +118,33 @@ module.exports = (pool) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// Execute custom SQL for the view (user-edited SQL)
router.post('/:name/exec-sql', async (req, res, next) => {
try {
const { sql } = req.body;
if (!sql) return res.status(400).json({ success: false, error: 'sql is required' });
await pool.query(`DROP VIEW IF EXISTS dfv.${req.params.name} CASCADE`);
await pool.query(sql);
await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`);
// Detect stacks whose views were dropped by CASCADE
const staleResult = await pool.query(`
SELECT array_agg(name) AS names FROM dataflow.stacks
WHERE name != ${lit(req.params.name)}
AND view_generated_at IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM pg_views WHERE schemaname = 'dfv' AND viewname = name
)
`);
const cascadeStale = staleResult.rows[0].names || [];
if (cascadeStale.length) {
await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NULL WHERE name = ANY($1)`, [cascadeStale]);
}
res.json({ success: true, cascade_stale: cascadeStale });
} catch (err) {
res.json({ success: false, error: err.message });
}
});
// Calibrate balance offset given a known good balance at a specific date // Calibrate balance offset given a known good balance at a specific date
router.post('/:name/calibrate', async (req, res, next) => { router.post('/:name/calibrate', async (req, res, next) => {
try { try {

View File

@ -46,6 +46,7 @@ ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT;
-- Drop old signatures before recreating -- Drop old signatures before recreating
DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC); DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC);
DROP FUNCTION IF EXISTS upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC); DROP FUNCTION IF EXISTS upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC);
DROP FUNCTION IF EXISTS generate_stack_view(TEXT);
------------------------------------------------------ ------------------------------------------------------
-- Function: list_stacks -- Function: list_stacks
@ -239,7 +240,7 @@ $$ LANGUAGE plpgsql STABLE;
-- Each source CTE applies amount_sign and computes a per-source running balance. -- Each source CTE applies amount_sign and computes a per-source running balance.
-- Outer SELECT adds net_balance across all sources. -- Outer SELECT adds net_balance across all sources.
------------------------------------------------------ ------------------------------------------------------
CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT) CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT, p_dry_run BOOLEAN DEFAULT false)
RETURNS JSON AS $$ RETURNS JSON AS $$
DECLARE DECLARE
v_stack dataflow.stacks%ROWTYPE; v_stack dataflow.stacks%ROWTYPE;
@ -258,6 +259,7 @@ DECLARE
v_canon_cols TEXT; v_canon_cols TEXT;
v_src_bal_cols TEXT; v_src_bal_cols TEXT;
v_total_offset NUMERIC := 0; v_total_offset NUMERIC := 0;
v_cascade_stale TEXT[];
BEGIN BEGIN
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
IF NOT FOUND THEN IF NOT FOUND THEN
@ -327,9 +329,7 @@ BEGIN
RETURN json_build_object('success', false, 'error', 'Stack has no sources'); RETURN json_build_object('success', false, 'error', 'Stack has no sources');
END IF; END IF;
CREATE SCHEMA IF NOT EXISTS dfv;
v_view := 'dfv.' || quote_ident(p_stack_name); v_view := 'dfv.' || quote_ident(p_stack_name);
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
v_canon_cols := ( v_canon_cols := (
SELECT string_agg(quote_ident(f->>'name'), ', ') SELECT string_agg(quote_ident(f->>'name'), ', ')
@ -337,23 +337,21 @@ BEGIN
); );
IF v_has_bal THEN IF v_has_bal THEN
-- net_balance = sum of all signed amounts + total of all source offsets
-- This ensures net_balance = sum of per-source carried-forward balances on every row
v_sql := format( v_sql := format(
'CREATE VIEW %s AS ' 'CREATE VIEW %s AS '
'WITH %s, _stacked AS (SELECT * FROM %s) ' 'WITH %s, _stacked AS (SELECT * FROM %s) '
'SELECT _source, _id, %s, ' 'SELECT _source, _id, %s, '
'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, ' '%s, '
'%s ' 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance '
'FROM _stacked ORDER BY %I DESC, _id DESC', 'FROM _stacked ORDER BY %I DESC, _id DESC',
v_view, v_view,
array_to_string(v_ctes, ', '), array_to_string(v_ctes, ', '),
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '), array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
v_canon_cols, v_canon_cols,
v_src_bal_cols,
v_stack.amount_field, v_stack.amount_field,
v_stack.date_field, v_stack.date_field,
v_total_offset, v_total_offset,
v_src_bal_cols,
v_stack.date_field v_stack.date_field
); );
ELSE ELSE
@ -368,9 +366,31 @@ BEGIN
); );
END IF; END IF;
EXECUTE v_sql; IF NOT p_dry_run THEN
CREATE SCHEMA IF NOT EXISTS dfv;
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
EXECUTE v_sql;
RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql); -- Detect stacks whose views were dropped by CASCADE and mark them stale
SELECT array_agg(s.name) INTO v_cascade_stale
FROM dataflow.stacks s
WHERE s.name != p_stack_name
AND s.view_generated_at IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM pg_views v
WHERE v.schemaname = 'dfv' AND v.viewname = s.name
);
UPDATE dataflow.stacks SET view_generated_at = NULL
WHERE name = ANY(v_cascade_stale);
END IF;
RETURN json_build_object(
'success', true,
'view', v_view,
'sql', v_sql,
'cascade_stale', COALESCE(to_json(v_cascade_stale), '[]'::json)
);
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@ -410,6 +430,6 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql STABLE; $$ LANGUAGE plpgsql STABLE;
COMMENT ON FUNCTION generate_stack_view IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance'; COMMENT ON FUNCTION generate_stack_view(TEXT, BOOLEAN) IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance; p_dry_run=true returns SQL without executing';
COMMENT ON FUNCTION calibrate_balance IS 'Given a known good balance at a date, compute the offset to add to balance_offset'; COMMENT ON FUNCTION calibrate_balance IS 'Given a known good balance at a date, compute the offset to add to balance_offset';
COMMENT ON FUNCTION get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view'; COMMENT ON FUNCTION get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view';

View File

@ -12,7 +12,8 @@
"dependencies": { "dependencies": {
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.13.2" "react-router-dom": "^7.13.2",
"sql-formatter": "^15.7.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View File

@ -121,7 +121,9 @@ export const api = {
deleteStack: (name) => request('DELETE', `/stacks/${name}`), deleteStack: (name) => request('DELETE', `/stacks/${name}`),
upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body), upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body),
removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`), removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`),
previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`),
generateStackView: (name) => request('POST', `/stacks/${name}/view`), generateStackView: (name) => request('POST', `/stacks/${name}/view`),
execStackSql: (name, sql) => request('POST', `/stacks/${name}/exec-sql`, { sql }),
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`), getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }), calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),

View File

@ -1,5 +1,14 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { api } from '../api' import { api } from '../api'
import { format as formatSql } from 'sql-formatter'
function prettySql(sql) {
try {
return formatSql(sql, { language: 'postgresql', tabWidth: 4, keywordCase: 'upper' })
} catch {
return sql
}
}
const FIELD_TYPES = ['text', 'numeric', 'date'] const FIELD_TYPES = ['text', 'numeric', 'date']
@ -79,7 +88,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
// Stack panel // Stack panel
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) { function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSqlGenerated }) {
const members = stack.sources || [] const members = stack.sources || []
const [label, setLabel] = useState(stack.label || '') const [label, setLabel] = useState(stack.label || '')
@ -126,6 +135,22 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
}) })
}, [members.map(m => m.source_name).join(',')]) }, [members.map(m => m.source_name).join(',')])
// Live SQL preview debounced whenever fields, sources, or mappings change
const previewTimer = useRef(null)
useEffect(() => {
clearTimeout(previewTimer.current)
previewTimer.current = setTimeout(() => {
api.previewStackSql(stack.name)
.then(r => { if (r.success) onSqlGenerated?.(r.sql) })
.catch(() => {})
}, 400)
return () => clearTimeout(previewTimer.current)
}, [
JSON.stringify(fields),
JSON.stringify(srcCfg),
members.map(m => m.source_name).join(','),
])
// Auto-detect canonical amount/date field from field types // Auto-detect canonical amount/date field from field types
const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field
const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field
@ -316,7 +341,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
try { try {
const r = await api.generateStackView(stack.name) const r = await api.generateStackView(stack.name)
setViewResult(r) setViewResult(r)
if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) } if (r.success) {
fetchBalance()
onViewGenerated?.(stack.name)
onSqlGenerated?.(r.sql || '')
;(r.cascade_stale || []).forEach(n => onStale?.(n))
}
} catch (e) { setError(e.message) } } catch (e) { setError(e.message) }
} }
@ -547,16 +577,11 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
</div> </div>
)} )}
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>} {balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
{viewResult && ( {viewResult && !viewResult.success && (
<div className="text-xs"> <p className="text-xs text-red-500">{viewResult.error}</p>
{viewResult.success )}
? <p className="text-green-600 mb-2">View created: <span className="font-mono">{viewResult.view}</span></p> {viewResult && viewResult.success && (
: <p className="text-red-500">{viewResult.error}</p> <p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p>
}
{viewResult.sql && (
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 leading-relaxed">{viewResult.sql}</pre>
)}
</div>
)} )}
</div> </div>
@ -581,6 +606,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [sqlDraft, setSqlDraft] = useState('')
const [sqlRunning, setSqlRunning] = useState(false)
const [sqlResult, setSqlResult] = useState(null)
async function load() { async function load() {
const s = await api.getStacks() const s = await api.getStacks()
@ -591,6 +619,8 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
const s = await api.getStack(name) const s = await api.getStack(name)
setStackDetail(s) setStackDetail(s)
setSelected(name) setSelected(name)
setSqlDraft('')
setSqlResult(null)
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -610,10 +640,24 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
async function deleteStack(name) { async function deleteStack(name) {
if (!confirm(`Delete stack "${name}"?`)) return if (!confirm(`Delete stack "${name}"?`)) return
await api.deleteStack(name) await api.deleteStack(name)
if (selected === name) { setSelected(null); setStackDetail(null) } if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) }
load() load()
} }
async function runSql() {
if (!sqlDraft.trim() || !selected) return
setSqlRunning(true); setSqlResult(null)
try {
const r = await api.execStackSql(selected, sqlDraft)
setSqlResult(r)
if (r.success) {
onStackViewGenerated?.(selected)
;(r.cascade_stale || []).forEach(n => onStackStale?.(n))
}
} catch (e) { setSqlResult({ success: false, error: e.message }) }
finally { setSqlRunning(false) }
}
return ( return (
<div className="p-6"> <div className="p-6">
{/* Stack list — horizontal row of cards */} {/* Stack list — horizontal row of cards */}
@ -643,22 +687,56 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
)} )}
</div> </div>
{/* Full-width config panel */}
{stackDetail ? ( {stackDetail ? (
<> <div className="flex gap-6 items-start">
<h2 className="text-base font-semibold text-gray-800 mb-4"> {/* Left: config panel */}
{stackDetail.label || stackDetail.name} <div className="flex-1 min-w-0">
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>} <h2 className="text-base font-semibold text-gray-800 mb-4">
</h2> {stackDetail.label || stackDetail.name}
<StackPanel {stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
key={stackDetail.name} </h2>
stack={stackDetail} <StackPanel
sources={sources} key={stackDetail.name}
onUpdated={() => { load(); loadDetail(stackDetail.name) }} stack={stackDetail}
onStale={onStackStale} sources={sources}
onViewGenerated={onStackViewGenerated} onUpdated={() => { load(); loadDetail(stackDetail.name) }}
/> onStale={onStackStale}
</> onViewGenerated={onStackViewGenerated}
onSqlGenerated={sql => { setSqlDraft(prettySql(sql)); setSqlResult(null) }}
/>
</div>
{/* Right: SQL panel */}
<div className="flex-1 min-w-0">
<div className="bg-white border border-gray-200 rounded p-4 sticky top-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">Generated SQL</h3>
<button
onClick={runSql}
disabled={!sqlDraft.trim() || sqlRunning}
className="text-sm bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 disabled:opacity-40">
{sqlRunning ? 'Running…' : 'Run'}
</button>
</div>
{sqlDraft ? (
<textarea
className="w-full font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed"
style={{ minHeight: '60vh' }}
value={sqlDraft}
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
spellCheck={false}
/>
) : (
<p className="text-xs text-gray-400">Generate a view to see the SQL here.</p>
)}
{sqlResult && (
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}>
{sqlResult.success ? 'View updated successfully.' : sqlResult.error}
</p>
)}
</div>
</div>
</div>
) : ( ) : (
<p className="text-sm text-gray-400">Select a stack or create one.</p> <p className="text-sm text-gray-400">Select a stack or create one.</p>
)} )}