Stop a segment edit from wiping its annotations

PUT /versions/:id/baseline/:logid deletes the log row and inserts a fresh one
from the stored template, so every field the form does not send comes back
null. That took the label and the bucket with it, and the tag besides -- the
segment form has no tag input at all, so a tag could not survive an edit made
for any other reason.

The route now hands back what it was not given, reading the row it is about
to replace. `??` rather than `||`: an empty string is the form clearing a
field deliberately, undefined is the form not carrying it.

The load templates gained a tag token to receive it. Stored templates are
per-source and were generated before any of this existed, which is the other
half of why labels vanished -- source 14's had no label column to write to.
Regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-17 23:06:17 -04:00
parent 3162759f93
commit 2306315a17
2 changed files with 20 additions and 10 deletions

View File

@ -8,7 +8,7 @@
// Tokens baked in at generation time: column names, source schema.table
// Tokens substituted at request time: {{fc_table}}, {{where_clause}}, {{exclude_clause}},
// {{version_id}}, {{logid}}, {{pf_user}}, {{note}},
// {{label}}, {{bucket}},
// {{label}}, {{bucket}}, {{tag}},
// {{params}}, {{slice}}, {{date_from}}, {{date_to}},
// {{value_incr}}, {{units_incr}}, {{set_clause}}, {{scale_factor}}
@ -303,9 +303,9 @@ GROUP BY
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket, tag)
VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''), NULLIF('{{tag}}', ''))
RETURNING id
)
,ins AS (
@ -324,9 +324,9 @@ SELECT count(*) AS rows_affected FROM ins`.trim();
return `
WITH
ilog AS (
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket)
INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note, label, bucket, tag)
VALUES ({{version_id}}, '{{pf_user}}', 'reference', NULL, '{{params}}'::jsonb, '{{note}}',
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''))
NULLIF('{{label}}', ''), NULLIF('{{bucket}}', ''), NULLIF('{{tag}}', ''))
RETURNING id
)
,ins AS (

View File

@ -409,7 +409,7 @@ module.exports = function(pool) {
// load baseline rows from source table — additive, no delete
router.post('/versions/:id/baseline', async (req, res) => {
const { where_clause, date_offset, note, filters, raw_where, label, bucket } = req.body;
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
if (!await assertInterval(dateOffset, res)) return;
@ -429,6 +429,7 @@ module.exports = function(pool) {
note: esc(note || ''),
label: esc(label || ''),
bucket: esc(bucket || ''),
tag: esc(tag || ''),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
@ -448,7 +449,7 @@ module.exports = function(pool) {
router.put('/versions/:id/baseline/:logid', async (req, res) => {
const versionId = parseInt(req.params.id);
const logid = parseInt(req.params.logid);
const { where_clause, date_offset, note, filters, raw_where, label, bucket } = req.body;
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
if (!await assertInterval(dateOffset, res)) return;
@ -487,13 +488,21 @@ module.exports = function(pool) {
date_offset: dateOffset,
...(raw_where ? { raw_where } : (filters ? { filters } : {}))
});
// This route deletes the log row and inserts a fresh one, so every
// annotation on it has to be handed back or it is lost. `??`, not `||`:
// an empty string is the form clearing a field on purpose, undefined is
// the form not carrying it at all -- the segment form has no tag input,
// so tag is always the latter and must survive an edit made for any
// other reason.
const keep = (sent, prior) => esc(sent ?? prior ?? '');
const sql = applyTokens(ctx.sql, {
fc_table: ctx.table,
version_id: ctx.version.id,
pf_user: esc(pf_user || ''),
note: esc(note || ''),
label: esc(label || ''),
bucket: esc(bucket || ''),
label: keep(label, oldLog.label),
bucket: keep(bucket, oldLog.bucket),
tag: keep(tag, oldLog.tag),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)
@ -558,7 +567,7 @@ module.exports = function(pool) {
// load reference rows from source table (additive — does not clear prior reference rows)
router.post('/versions/:id/reference', async (req, res) => {
const { where_clause, date_offset, note, filters, raw_where, label, bucket } = req.body;
const { where_clause, date_offset, note, filters, raw_where, label, bucket, tag } = req.body;
const pf_user = sessionUser(req);
const dateOffset = date_offset || '0 days';
const filterClause = (raw_where || where_clause || '').trim() || 'TRUE';
@ -577,6 +586,7 @@ module.exports = function(pool) {
note: esc(note || ''),
label: esc(label || ''),
bucket: esc(bucket || ''),
tag: esc(tag || ''),
params: esc(paramsJson),
filter_clause: filterClause,
date_offset: esc(dateOffset)