From 54c93c28dd736fcd0d6cc584f721b1c76546e119 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 12 Jun 2026 23:44:06 -0400 Subject: [PATCH] Make units column optional throughout source registration and SQL generation - SQL generator no longer requires a units col; recode/clone/scale omit units expressions when none is configured in col_meta - Source registration validation drops units from required roles (value + date are the only hard requirements) - DELETE /api/sources/:id returns 409 when existing versions reference the source - Setup.jsx surfaces the 409 error via flash instead of silently failing Co-Authored-By: Claude Sonnet 4.6 --- lib/sql_generator.js | 8 +++----- routes/sources.js | 5 ++++- ui/src/views/Setup.jsx | 7 ++++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/sql_generator.js b/lib/sql_generator.js index 7794cbc..fdba4d1 100644 --- a/lib/sql_generator.js +++ b/lib/sql_generator.js @@ -21,7 +21,6 @@ function generateSQL(source, colMeta) { const dateCol = colMeta.find(c => c.role === 'date')?.cname; if (!valueCol) throw new Error('No value column defined in col_meta'); - if (!unitsCol) throw new Error('No units column defined in col_meta'); if (!dateCol) throw new Error('No date column defined in col_meta'); if (dims.length === 0) throw new Error('No dimension columns defined in col_meta'); @@ -171,14 +170,14 @@ ilog AS ( ) ,neg AS ( INSERT INTO {{fc_table}} (${insertCols}) - SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'}, + SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}${effectiveUnits ? `, -${q(effectiveUnits)}` : ''}, 'recode', (SELECT id FROM ilog), '{{pf_user}}', now() FROM src RETURNING * ) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) - SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'}, + SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}${effectiveUnits ? `, ${q(effectiveUnits)}` : ''}, 'recode', (SELECT id FROM ilog), '{{pf_user}}', now() FROM src RETURNING * @@ -199,8 +198,7 @@ ilog AS ( SELECT {{set_clause}}, ${q(dateCol)}, - ${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}, - ${effectiveUnits ? `round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : '0'}, + ${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}${effectiveUnits ? `,\n round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : ''}, 'clone', (SELECT id FROM ilog), '{{pf_user}}', now() FROM {{fc_table}} WHERE {{where_clause}} diff --git a/routes/sources.js b/routes/sources.js index d1c1157..6376264 100644 --- a/routes/sources.js +++ b/routes/sources.js @@ -143,7 +143,7 @@ module.exports = function(pool) { // validate required roles const colMeta = colResult.rows; const roles = new Set(colMeta.map(c => c.role)); - const missing = ['value', 'units', 'date'].filter(r => !roles.has(r)); + const missing = ['value', 'date'].filter(r => !roles.has(r)); if (missing.length > 0) { return res.status(400).json({ error: `col_meta is missing required roles: ${missing.join(', ')}` @@ -292,6 +292,9 @@ module.exports = function(pool) { res.json({ message: 'Source deregistered', source: result.rows[0] }); } catch (err) { console.error(err); + if (err.code === '23001' || err.code === '23503') { + return res.status(409).json({ error: 'Source has existing versions — delete them first.' }); + } res.status(500).json({ error: err.message }); } }); diff --git a/ui/src/views/Setup.jsx b/ui/src/views/Setup.jsx index a9999b4..52480ae 100644 --- a/ui/src/views/Setup.jsx +++ b/ui/src/views/Setup.jsx @@ -140,7 +140,12 @@ export default function Setup({ refreshSources }) { async function deleteSource(id, e) { e.stopPropagation() if (!confirm('Deregister this source? Existing forecast tables are not affected.')) return - await fetch(`/api/sources/${id}`, { method: 'DELETE' }) + const res = await fetch(`/api/sources/${id}`, { method: 'DELETE' }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + flash(data.error || 'Delete failed', 'err') + return + } if (selectedSource?.id === id) { setSelectedSource(null); setCols([]); setEditedCols([]) } loadSources() }