From a877dcddf5b4b32220faf3b1088c7e0c46d21f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 05:05:41 +0200 Subject: [PATCH] fix(measurements): let the newest stats: entry win inside one batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/measurements/batch/route.ts | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1bdce805b..96a4e60e6 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -480,10 +480,45 @@ async function postBatch(request: NextRequest): Promise { 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(); + 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