From 508d44633e9973c180b7124fe7a9afa8a01b8def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 09:26:24 +0200 Subject: [PATCH] fix(workouts): one series, one enrichment, when a batch names a workout twice Two entries carrying the same external id and both a `samples` array reached the enrichment arm one after the other, and both read the same landed workout id back, so the response claimed two attaches where the database had written one. The second entry now takes the duplicate arm: a workout is claimed by the first entry of the batch that reaches it. --- src/app/api/workouts/batch/route.ts | 9 +++++-- .../integration/workout-batch-enrich.test.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 9b127601b..d1b5a758a 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -477,16 +477,21 @@ async function postBatch(request: NextRequest): Promise { workoutId: string; samples: NonNullable; }> = []; + /** Workouts already claimed by an earlier entry of THIS batch. */ + const enrichClaimed = new Set(); for (const p of survivors) { const keyTuple = p.dedupKey ? `${p.dedupKey.source}::${p.dedupKey.externalId}` : null; const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined; if (known) { - if (p.samples && !known.hasSeries) { + if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) { + enrichClaimed.add(known.id); // Enrichment. The status is assigned after the write, because // a concurrent writer may get the series in first — in which - // case this entry is the plain duplicate it always was. + // case this entry is the plain duplicate it always was. A + // second entry of this batch naming the same workout takes the + // duplicate arm above: one workout, one series, one `enriched`. toEnrich.push({ index: p.index, workoutId: known.id, diff --git a/tests/integration/workout-batch-enrich.test.ts b/tests/integration/workout-batch-enrich.test.ts index ff9d98125..b63bb4b0e 100644 --- a/tests/integration/workout-batch-enrich.test.ts +++ b/tests/integration/workout-batch-enrich.test.ts @@ -213,6 +213,33 @@ describe("POST /api/workouts/batch — HR series enrichment (real Postgres)", () ).toBe(1); }); + it("reports one enrichment when two entries in one batch aim at the same workout", async () => { + await post({ workouts: [baseWorkout("hk-uuid-enrich-007")] }); + + // Same external id twice, far enough apart in time that the + // write-time twin dedup keeps both entries. Only one series can land. + const res = await post({ + workouts: [ + baseWorkout("hk-uuid-enrich-007", { samples: SERIES }), + baseWorkout("hk-uuid-enrich-007", { + startedAt: "2026-05-14T09:00:00.000Z", + endedAt: "2026-05-14T09:45:00.000Z", + samples: [{ t: "2026-05-14T09:00:00.000Z", hr: 155 }], + }), + ], + }); + + expect(res.status).toBe(200); + expect( + res.data.entries.filter((e) => e.status === "enriched"), + ).toHaveLength(1); + expect( + await getPrismaClient().workoutSamples.count({ + where: { workout: { userId: TEST_USER_ID } }, + }), + ).toBe(1); + }); + it("leaves every workout column byte-identical across an enrichment", async () => { await post({ workouts: [