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:
parent
7c63a2ac29
commit
95e63679ef
@ -98,6 +98,14 @@ module.exports = (pool) => {
|
||||
} 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
|
||||
router.post('/:name/view', async (req, res, next) => {
|
||||
try {
|
||||
@ -110,6 +118,33 @@ module.exports = (pool) => {
|
||||
} 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
|
||||
router.post('/:name/calibrate', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@ -46,6 +46,7 @@ ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT;
|
||||
-- Drop old signatures before recreating
|
||||
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 generate_stack_view(TEXT);
|
||||
|
||||
------------------------------------------------------
|
||||
-- Function: list_stacks
|
||||
@ -239,7 +240,7 @@ $$ LANGUAGE plpgsql STABLE;
|
||||
-- Each source CTE applies amount_sign and computes a per-source running balance.
|
||||
-- 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 $$
|
||||
DECLARE
|
||||
v_stack dataflow.stacks%ROWTYPE;
|
||||
@ -258,6 +259,7 @@ DECLARE
|
||||
v_canon_cols TEXT;
|
||||
v_src_bal_cols TEXT;
|
||||
v_total_offset NUMERIC := 0;
|
||||
v_cascade_stale TEXT[];
|
||||
BEGIN
|
||||
SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name;
|
||||
IF NOT FOUND THEN
|
||||
@ -327,9 +329,7 @@ BEGIN
|
||||
RETURN json_build_object('success', false, 'error', 'Stack has no sources');
|
||||
END IF;
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS dfv;
|
||||
v_view := 'dfv.' || quote_ident(p_stack_name);
|
||||
EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view);
|
||||
|
||||
v_canon_cols := (
|
||||
SELECT string_agg(quote_ident(f->>'name'), ', ')
|
||||
@ -337,23 +337,21 @@ BEGIN
|
||||
);
|
||||
|
||||
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(
|
||||
'CREATE VIEW %s AS '
|
||||
'WITH %s, _stacked AS (SELECT * FROM %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',
|
||||
v_view,
|
||||
array_to_string(v_ctes, ', '),
|
||||
array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '),
|
||||
v_canon_cols,
|
||||
v_src_bal_cols,
|
||||
v_stack.amount_field,
|
||||
v_stack.date_field,
|
||||
v_total_offset,
|
||||
v_src_bal_cols,
|
||||
v_stack.date_field
|
||||
);
|
||||
ELSE
|
||||
@ -368,9 +366,31 @@ BEGIN
|
||||
);
|
||||
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;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
@ -410,6 +430,6 @@ BEGIN
|
||||
END;
|
||||
$$ 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 get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view';
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
"dependencies": {
|
||||
"react": "^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": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@ -121,7 +121,9 @@ export const api = {
|
||||
deleteStack: (name) => request('DELETE', `/stacks/${name}`),
|
||||
upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body),
|
||||
removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`),
|
||||
previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`),
|
||||
generateStackView: (name) => request('POST', `/stacks/${name}/view`),
|
||||
execStackSql: (name, sql) => request('POST', `/stacks/${name}/exec-sql`, { sql }),
|
||||
getStackBalance: (name) => request('GET', `/stacks/${name}/balance`),
|
||||
calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }),
|
||||
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
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']
|
||||
|
||||
@ -79,7 +88,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) {
|
||||
|
||||
// ── Stack panel ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
||||
function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSqlGenerated }) {
|
||||
const members = stack.sources || []
|
||||
|
||||
const [label, setLabel] = useState(stack.label || '')
|
||||
@ -126,6 +135,22 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
||||
})
|
||||
}, [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
|
||||
const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_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 {
|
||||
const r = await api.generateStackView(stack.name)
|
||||
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) }
|
||||
}
|
||||
|
||||
@ -547,16 +577,11 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) {
|
||||
</div>
|
||||
)}
|
||||
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>}
|
||||
{viewResult && (
|
||||
<div className="text-xs">
|
||||
{viewResult.success
|
||||
? <p className="text-green-600 mb-2">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||
: <p className="text-red-500">{viewResult.error}</p>
|
||||
}
|
||||
{viewResult.sql && (
|
||||
<pre className="bg-gray-50 p-2 rounded overflow-auto text-gray-500 leading-relaxed">{viewResult.sql}</pre>
|
||||
)}
|
||||
</div>
|
||||
{viewResult && !viewResult.success && (
|
||||
<p className="text-xs text-red-500">{viewResult.error}</p>
|
||||
)}
|
||||
{viewResult && viewResult.success && (
|
||||
<p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -581,6 +606,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [sqlDraft, setSqlDraft] = useState('')
|
||||
const [sqlRunning, setSqlRunning] = useState(false)
|
||||
const [sqlResult, setSqlResult] = useState(null)
|
||||
|
||||
async function load() {
|
||||
const s = await api.getStacks()
|
||||
@ -591,6 +619,8 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
||||
const s = await api.getStack(name)
|
||||
setStackDetail(s)
|
||||
setSelected(name)
|
||||
setSqlDraft('')
|
||||
setSqlResult(null)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
@ -610,10 +640,24 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
||||
async function deleteStack(name) {
|
||||
if (!confirm(`Delete stack "${name}"?`)) return
|
||||
await api.deleteStack(name)
|
||||
if (selected === name) { setSelected(null); setStackDetail(null) }
|
||||
if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) }
|
||||
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 (
|
||||
<div className="p-6">
|
||||
{/* Stack list — horizontal row of cards */}
|
||||
@ -643,22 +687,56 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full-width config panel */}
|
||||
{stackDetail ? (
|
||||
<>
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">
|
||||
{stackDetail.label || stackDetail.name}
|
||||
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
|
||||
</h2>
|
||||
<StackPanel
|
||||
key={stackDetail.name}
|
||||
stack={stackDetail}
|
||||
sources={sources}
|
||||
onUpdated={() => { load(); loadDetail(stackDetail.name) }}
|
||||
onStale={onStackStale}
|
||||
onViewGenerated={onStackViewGenerated}
|
||||
/>
|
||||
</>
|
||||
<div className="flex gap-6 items-start">
|
||||
{/* Left: config panel */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">
|
||||
{stackDetail.label || stackDetail.name}
|
||||
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>}
|
||||
</h2>
|
||||
<StackPanel
|
||||
key={stackDetail.name}
|
||||
stack={stackDetail}
|
||||
sources={sources}
|
||||
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>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user