The change log joined the whole forecast table on every open to total rows it had just written -- 2.5M rows to report a few thousand, and the Baseline page's row and value columns paid the same cost again. The totals go onto pf.log at write time instead. This is not a cache that can drift: a log entry's forecast rows never change once written, because only the operation owning the logid inserts them and the only thing that removes them is undo, which deletes the log row too. measure_cols records which columns the figures are denominated in, since the value and units roles can be reassigned in col_meta and the numbers would otherwise quietly come to mean something else. Stamping is best-effort and runs after the commit: a failure to record what happened must not roll back the thing that happened. The loads return their new log id to make it possible, the adjustments take theirs from the rows they return, and apply_mode 'each' writes one entry per slice, so it is a set rather than a single id. ?recount=1 does it the old way and writes back what it finds. Stored totals cannot drift on their own, but nothing stops someone deleting forecast rows by hand, and a stored figure has no way to notice -- so there is a way back, which doubles as the backfill for entries written before the columns existed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
9.8 KiB
JavaScript
208 lines
9.8 KiB
JavaScript
const express = require('express');
|
|
const { grainOf } = require('../lib/sql_generator');
|
|
const { fcTable } = require('../lib/utils');
|
|
|
|
module.exports = function(pool) {
|
|
const router = express.Router();
|
|
|
|
// list log entries for a version, newest first, with row counts and value/units totals
|
|
router.get('/versions/:id/log', async (req, res) => {
|
|
const versionId = parseInt(req.params.id);
|
|
try {
|
|
const verResult = await pool.query(
|
|
`SELECT v.*, 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`,
|
|
[versionId]
|
|
);
|
|
if (!verResult.rows.length) return res.status(404).json({ error: 'Version not found' });
|
|
const { tname, source_id } = verResult.rows[0];
|
|
const table = fcTable(tname, versionId);
|
|
|
|
const colMeta = await pool.query(
|
|
`SELECT cname, role FROM pf.col_meta WHERE source_id = $1 AND role IN ('value', 'units')`,
|
|
[source_id]
|
|
);
|
|
const valueCol = colMeta.rows.find(c => c.role === 'value')?.cname;
|
|
const unitsCol = colMeta.rows.find(c => c.role === 'units')?.cname;
|
|
|
|
const aggCols = [
|
|
`count(f.pf_id)::int AS row_count`,
|
|
valueCol ? `sum(f."${valueCol}")::float8 AS value_total` : `NULL::float8 AS value_total`,
|
|
unitsCol ? `sum(f."${unitsCol}")::float8 AS units_total` : `NULL::float8 AS units_total`
|
|
].join(', ');
|
|
|
|
// The totals are stamped onto the entry when it is written, so the
|
|
// normal read is a scan of a few dozen log rows rather than a join
|
|
// against millions of forecast rows.
|
|
//
|
|
// ?recount=1 does it the old way. Stored totals are fixed at write
|
|
// time and cannot drift on their own, but nothing stops someone
|
|
// deleting forecast rows by hand, and a stored figure has no way to
|
|
// notice. This is the way back -- and the backfill for entries
|
|
// written before the columns existed.
|
|
const recount = req.query.recount === '1' || req.query.recount === 'true';
|
|
const stamped = !recount && (await pool.query(
|
|
`SELECT count(*)::int AS n FROM pf.log
|
|
WHERE version_id = $1 AND row_count IS NULL`, [versionId]
|
|
)).rows[0].n === 0;
|
|
|
|
// ?kind=adjustments drops the baseline and reference entries. That is not
|
|
// only about what gets listed: the aggregate below joins the whole
|
|
// forecast table, and on a real version the load entries own almost
|
|
// every row of it -- 2.5M against a few thousand for the adjustments.
|
|
// Filtering in the WHERE keeps them out of the join rather than
|
|
// totalling them and discarding the answer.
|
|
const adjustmentsOnly = req.query.kind === 'adjustments';
|
|
const opFilter = adjustmentsOnly
|
|
? `AND l.operation NOT IN ('baseline', 'reference')`
|
|
: '';
|
|
|
|
const result = stamped
|
|
? await pool.query(`
|
|
SELECT l.*,
|
|
$2::text AS value_col,
|
|
$3::text AS units_col
|
|
FROM pf.log l
|
|
WHERE l.version_id = $1
|
|
${opFilter}
|
|
ORDER BY l.id DESC
|
|
`, [versionId, valueCol || null, unitsCol || null])
|
|
: await pool.query(`
|
|
SELECT l.*, ${aggCols},
|
|
$2::text AS value_col,
|
|
$3::text AS units_col
|
|
FROM pf.log l
|
|
LEFT JOIN ${table} f ON f.pf_logid = l.id
|
|
WHERE l.version_id = $1
|
|
${opFilter}
|
|
GROUP BY l.id
|
|
ORDER BY l.id DESC
|
|
`, [versionId, valueCol || null, unitsCol || null]);
|
|
|
|
// A recount is also a repair: write back what it found, so the next
|
|
// read is cheap again and the stored figure matches the rows.
|
|
if (recount) {
|
|
for (const r of result.rows) {
|
|
await pool.query(
|
|
`UPDATE pf.log SET row_count = $2, value_total = $3, units_total = $4,
|
|
measure_cols = $5::jsonb
|
|
WHERE id = $1`,
|
|
[r.id, r.row_count, r.value_total, r.units_total,
|
|
JSON.stringify({ value: valueCol || null, units: unitsCol || null })]
|
|
);
|
|
}
|
|
}
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(err.status || 500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// undo a log entry — delete all fc rows with this logid, then delete the log entry
|
|
router.delete('/log/:logid', async (req, res) => {
|
|
const logId = parseInt(req.params.logid);
|
|
try {
|
|
const logResult = await pool.query(`
|
|
SELECT l.*, v.status, s.tname, v.id AS version_id, v.source_id
|
|
FROM pf.log l
|
|
JOIN pf.version v ON v.id = l.version_id
|
|
JOIN pf.source s ON s.id = v.source_id
|
|
WHERE l.id = $1
|
|
`, [logId]);
|
|
if (!logResult.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
|
const log = logResult.rows[0];
|
|
if (log.status === 'closed') return res.status(403).json({ error: 'Version is closed' });
|
|
const table = fcTable(log.tname, log.version_id);
|
|
|
|
// In grain mode the client's table is indexed on pf_gkey, so undo has to
|
|
// report the grain keys to remove rather than raw pf_ids. The keys are
|
|
// distinct while rows_deleted still counts the raw rows removed.
|
|
const colMeta = await pool.query(
|
|
`SELECT cname, role, in_grain, opos FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
|
|
[log.source_id]
|
|
);
|
|
const grain = grainOf(colMeta.rows);
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const deleted = grain
|
|
? await client.query(`
|
|
WITH
|
|
del AS (
|
|
DELETE FROM ${table}
|
|
WHERE pf_logid = $1
|
|
RETURNING ${grain.groupCols().join(', ')}
|
|
)
|
|
SELECT
|
|
count(*)::int AS rows_deleted
|
|
,array_agg(DISTINCT ${grain.key()}) AS pf_gkeys
|
|
FROM del
|
|
`, [logId])
|
|
: await client.query(
|
|
`DELETE FROM ${table} WHERE pf_logid = $1 RETURNING pf_id`, [logId]
|
|
);
|
|
await client.query('DELETE FROM pf.log WHERE id = $1', [logId]);
|
|
await client.query('COMMIT');
|
|
res.json(grain
|
|
? {
|
|
rows_deleted: deleted.rows[0].rows_deleted,
|
|
pf_gkeys: deleted.rows[0].pf_gkeys || []
|
|
}
|
|
: {
|
|
rows_deleted: deleted.rowCount,
|
|
pf_ids: deleted.rows.map(r => r.pf_id)
|
|
});
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(err.status || 500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// update the note and/or tag on a log entry. Both are annotations — they never
|
|
// affect the forecast rows — so they stay editable after the fact, including on
|
|
// a closed version, where relabelling history is still legitimate.
|
|
router.patch('/log/:logid', async (req, res) => {
|
|
const logId = parseInt(req.params.logid);
|
|
const { note, tag, bucket, label } = req.body;
|
|
if (note === undefined && tag === undefined
|
|
&& bucket === undefined && label === undefined) {
|
|
return res.status(400).json({
|
|
error: 'Nothing to update — send note, tag, bucket and/or label'
|
|
});
|
|
}
|
|
try {
|
|
// COALESCE on the flag, not the value: an explicit null or '' must be
|
|
// able to clear a field, which COALESCE on the value alone would ignore
|
|
const result = await pool.query(
|
|
`UPDATE pf.log SET
|
|
note = CASE WHEN $2::bool THEN $3::text ELSE note END,
|
|
tag = CASE WHEN $4::bool THEN $5::text ELSE tag END,
|
|
bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END,
|
|
label = CASE WHEN $8::bool THEN $9::text ELSE label END
|
|
WHERE id = $1 RETURNING *`,
|
|
[
|
|
logId,
|
|
note !== undefined, note === undefined ? null : (String(note).trim() || null),
|
|
tag !== undefined, tag === undefined ? null : (String(tag).trim() || null),
|
|
bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null),
|
|
label !== undefined, label === undefined ? null : (String(label).trim() || null),
|
|
]
|
|
);
|
|
if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' });
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(err.status || 500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
return router;
|
|
};
|