pf_app/routes/versions.js
Paul Trowbridge 5376c25e04 Stop totalling log entries the change log does not show
Filtering the loads out in the dialog left the server still joining the
whole forecast table for them and discarding the answer. ?kind=adjustments
moves the filter into the WHERE, so they never enter the join: on version 29
the join goes from 2,556,821 rows to 25, and the query from 1666ms to 719ms.

Still 719ms, because nothing indexes pf_logid and it stays a sequential scan
of 2.5M rows. New forecast tables now get an index on it. That column is how
every entry-level operation finds its rows -- undo deletes by it, this
aggregate groups by it, and it is part of the grain key -- so the scan was
being paid on all of them.

Existing tables predate the index and still scan; fc_osm_skinny_29 would
want it added by hand.

The route keeps returning everything by default: Baseline.jsx lists the
segments from the same endpoint, and the ledger's tag lookup needs every
entry to label its lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:31:49 -04:00

347 lines
14 KiB
JavaScript

const express = require('express');
const { fcTable, mapType } = require('../lib/utils');
const { sessionUser } = require('../lib/auth');
module.exports = function(pool) {
const router = express.Router();
// list versions for a source
router.get('/sources/:id/versions', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.version WHERE source_id = $1 ORDER BY created_at DESC`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// create a new version
// inserts version row, then CREATE TABLE pf.fc_{tname}_{version_id} in one transaction
router.post('/sources/:id/versions', async (req, res) => {
const sourceId = parseInt(req.params.id);
const { name, description, exclude_iters } = req.body;
const created_by = sessionUser(req);
if (!name) return res.status(400).json({ error: 'name is required' });
const client = await pool.connect();
try {
// fetch source
const srcResult = await client.query(
`SELECT * FROM pf.source WHERE id = $1`, [sourceId]
);
if (srcResult.rows.length === 0) {
return res.status(404).json({ error: 'Source not found' });
}
const source = srcResult.rows[0];
// fetch col_meta joined to information_schema for data types
const colResult = await client.query(`
SELECT
m.cname,
m.role,
m.opos,
i.data_type,
i.numeric_precision,
i.numeric_scale
FROM pf.col_meta m
JOIN information_schema.columns i
ON i.table_schema = $2
AND i.table_name = $3
AND i.column_name = m.cname
WHERE m.source_id = $1
AND m.role NOT IN ('ignore')
ORDER BY m.opos
`, [sourceId, source.schema, source.tname]);
if (colResult.rows.length === 0) {
return res.status(400).json({
error: 'No usable columns in col_meta — configure roles before creating a version'
});
}
await client.query('BEGIN');
// insert version to get id
const verResult = await client.query(`
INSERT INTO pf.version (source_id, name, description, created_by, exclude_iters)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [
sourceId,
name,
description || null,
created_by || null,
exclude_iters ? JSON.stringify(exclude_iters) : '["reference"]'
]);
const version = verResult.rows[0];
// build CREATE TABLE DDL using col_meta + mapped data types
const table = fcTable(source.tname, version.id);
const systemCols = new Set(['pf_id', 'pf_iter', 'pf_logid', 'pf_user', 'pf_created_at']);
const colDefs = colResult.rows
.filter(c => !systemCols.has(c.cname))
.map(c => {
const pgType = mapType(c.data_type, c.numeric_precision, c.numeric_scale);
const quoted = `"${c.cname}"`;
return ` ${quoted.padEnd(26)}${pgType}`;
}).join(',\n');
const ddl = `
CREATE TABLE ${table} (
pf_id bigserial PRIMARY KEY,
${colDefs},
pf_iter text NOT NULL,
pf_logid bigint NOT NULL,
pf_user text,
pf_created_at timestamptz NOT NULL DEFAULT now()
)
`;
await client.query(ddl);
// pf_logid is how every entry-level operation finds its rows: undo
// deletes by it, the change log aggregates by it, and it is part of the
// grain key. Without an index each of those is a sequential scan of the
// whole forecast table -- 2.5M rows to total two adjustments.
await client.query(
`CREATE INDEX ${table.split('.').pop()}_logid_idx ON ${table} (pf_logid)`
);
await client.query('COMMIT');
res.status(201).json({ ...version, fc_table: table });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
if (err.code === '23505') {
return res.status(409).json({ error: 'A version with that name already exists for this source' });
}
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// where this version's writes actually land: the physical forecast table,
// its current row count, and the source table rows are read from.
// Surfaced in the status bar so the write target is never a mystery.
router.get('/versions/:id/table-info', async (req, res) => {
try {
const verResult = await pool.query(`
SELECT v.id, v.name, v.status, s.schema, s.tname
FROM pf.version v
JOIN pf.source s ON s.id = v.source_id
WHERE v.id = $1
`, [req.params.id]);
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
const v = verResult.rows[0];
const fc = fcTable(v.tname, v.id);
const [schema, table] = fc.split('.');
const existsResult = await pool.query(
`SELECT to_regclass($1) IS NOT NULL AS exists`, [fc]
);
const exists = existsResult.rows[0].exists;
let rows = null, byIter = [];
if (exists) {
const countResult = await pool.query(
`SELECT pf_iter, count(*)::int AS n FROM ${fc} GROUP BY pf_iter ORDER BY pf_iter`
);
byIter = countResult.rows;
rows = byIter.reduce((a, r) => a + r.n, 0);
}
res.json({
version_id: v.id,
version_name: v.name,
status: v.status,
source: `${v.schema}.${v.tname}`,
fc_table: fc,
fc_schema: schema,
fc_tname: table,
exists,
rows,
by_iter: byIter
});
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// Tags already used on this source, newest first — feeds the tag autocomplete.
// Scoped to the source rather than the version so an initiative name carries
// across versions, which is the point of naming it.
router.get('/sources/:id/tags', async (req, res) => {
try {
const result = await pool.query(`
SELECT l.tag,
count(*)::int AS uses,
max(l.stamp) AS last_used
FROM pf.log l
JOIN pf.version v ON v.id = l.version_id
WHERE v.source_id = $1 AND l.tag IS NOT NULL AND l.tag <> ''
GROUP BY l.tag
ORDER BY max(l.stamp) DESC
`, [req.params.id]);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// Bridge: how this version got from its baseline to where it stands, grouped by
// initiative. Amounts come from the version's own forecast table, so the figures
// reconcile with the pivot rather than being recomputed from the log's params.
router.get('/versions/:id/bridge', async (req, res) => {
try {
const verResult = await pool.query(`
SELECT v.id, v.exclude_iters, s.schema, s.tname, s.id AS source_id
FROM pf.version v JOIN pf.source s ON s.id = v.source_id
WHERE v.id = $1
`, [req.params.id]);
if (verResult.rows.length === 0) return res.status(404).json({ error: 'Version not found' });
const v = verResult.rows[0];
const fc = fcTable(v.tname, v.id);
const exists = await pool.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [fc]);
if (!exists.rows[0].ok) return res.json({ fc_table: fc, exists: false, rows: [] });
const colResult = await pool.query(
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1`, [v.source_id]);
const valueCol = colResult.rows.find(c => c.role === 'value')?.cname;
const unitsCol = colResult.rows.find(c => c.role === 'units')?.cname;
if (!valueCol) return res.status(400).json({ error: 'No value column configured' });
const excl = (v.exclude_iters || []).length
? `t.pf_iter NOT IN (${v.exclude_iters.map(i => `'${String(i).replace(/'/g, "''")}'`).join(', ')})`
: 'TRUE';
const result = await pool.query(`
SELECT CASE WHEN t.pf_iter = 'baseline' THEN '(baseline)'
ELSE coalesce(nullif(l.tag, ''), '(untagged)') END AS tag,
bool_or(t.pf_iter = 'baseline') AS is_baseline,
count(DISTINCT l.id)::int AS entries,
count(*)::int AS row_count,
round(sum(t."${valueCol}")::numeric, 2) AS value
${unitsCol ? `, round(sum(t."${unitsCol}")::numeric, 2) AS units` : ''}
FROM ${fc} t
LEFT JOIN pf.log l ON l.id = t.pf_logid
WHERE ${excl}
GROUP BY 1
ORDER BY bool_or(t.pf_iter = 'baseline') DESC, min(l.id)
`);
res.json({ fc_table: fc, exists: true, value_col: valueCol, units_col: unitsCol, rows: result.rows });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// update version name, description, or exclude_iters
router.put('/versions/:id', async (req, res) => {
const { name, description, exclude_iters } = req.body;
try {
const result = await pool.query(`
UPDATE pf.version SET
name = COALESCE($2, name),
description = COALESCE($3, description),
exclude_iters = COALESCE($4, exclude_iters)
WHERE id = $1
RETURNING *
`, [
req.params.id,
name || null,
description || null,
exclude_iters ? JSON.stringify(exclude_iters) : null
]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Version not found' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// close a version — blocks further edits
router.post('/versions/:id/close', async (req, res) => {
const pf_user = sessionUser(req);
try {
const result = await pool.query(`
UPDATE pf.version
SET status = 'closed', closed_at = now(), closed_by = $2
WHERE id = $1 AND status = 'open'
RETURNING *
`, [req.params.id, pf_user || null]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Version not found or already closed' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// reopen a closed version
router.post('/versions/:id/reopen', async (req, res) => {
try {
const result = await pool.query(`
UPDATE pf.version
SET status = 'open', closed_at = NULL, closed_by = NULL
WHERE id = $1 AND status = 'closed'
RETURNING *
`, [req.params.id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Version not found or already open' });
}
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// delete a version — drops forecast table then deletes version record
// log entries are removed by ON DELETE CASCADE on pf.log.version_id
router.delete('/versions/:id', async (req, res) => {
const versionId = parseInt(req.params.id);
const client = await pool.connect();
try {
const verResult = await client.query(`
SELECT v.*, s.tname
FROM pf.version v
JOIN pf.source s ON s.id = v.source_id
WHERE v.id = $1
`, [versionId]);
if (verResult.rows.length === 0) {
return res.status(404).json({ error: 'Version not found' });
}
const { tname } = verResult.rows[0];
const table = fcTable(tname, versionId);
await client.query('BEGIN');
await client.query(`DROP TABLE IF EXISTS ${table}`);
await client.query(`DELETE FROM pf.version WHERE id = $1`, [versionId]);
await client.query('COMMIT');
res.json({ message: 'Version deleted', fc_table: table });
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
return router;
};