fix(measurements): let the newest stats: entry win inside one batch

Two entries carrying the same stats: key in one batch both miss the
existing-row probe, so the overwrite branch never runs, both land in the
bulk insert, and skipDuplicates keeps whichever the unique index saw
first. The older snapshot of a per-day cumulative total or of a filling
ten-minute heart-rate bucket therefore won and the newer, larger figure
was dropped silently — both entries reported inserted, the counters
still summed, and the client checkpointed past both.

stats: rows carry overwrite semantics by contract, so within one batch
the last entry for a key is the authoritative one. Resolve it up front
and report the superseded earlier entries as duplicate with a
superseded_in_batch reason, a status the client already checkpoints
past.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 05:05:41 +02:00
1 parent 3d1a1cec86
commit a877dcddf5
1 file changed
+35
+35
View File
@@ -480,10 +480,45 @@ async function postBatch(request: NextRequest): Promise<Response> {
return false;
}
// Intra-batch `stats:` supersession. Two entries carrying the same
// (type, source, externalId) `stats:` key inside ONE batch both miss
// `existingSet` — neither is in the DB yet — so the overwrite branch
// never runs, both land in `toInsert`, and
// `createMany({ skipDuplicates: true })` keeps whichever row the unique
// index saw first. The OLDER snapshot of a per-day cumulative total (or
// of a still-filling ten-minute heart-rate bucket) therefore won and the
// newer, larger figure was dropped silently: both entries reported
// `inserted`, the counters still summed, and the client checkpointed
// past both. `stats:` rows carry overwrite semantics by contract — a
// re-post replaces value / unit / measuredAt — so within one batch the
// LAST entry for a key is the authoritative one. Resolve it here and
// mark the superseded earlier entries `duplicate`, the same status the
// client already checkpoints past for a composite-key duplicate.
const lastStatsIndexByKey = new Map<string, number>();
for (const p of prepared) {
if (!isStatsExternalId(p.row.externalId as string)) continue;
lastStatsIndexByKey.set(
`${p.row.type}::${p.row.source}::${p.row.externalId}`,
p.index,
);
}
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
if (
isStatsExternalId(p.row.externalId as string) &&
lastStatsIndexByKey.get(key) !== p.index
) {
results[p.index] = {
index: p.index,
status: "duplicate",
reason: "superseded_in_batch",
};
duplicateCount += 1;
continue;
}
if (existingSet.has(key)) {
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects