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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 09:26:24 +02:00
1 parent 3d55289ba3
commit 508d44633e
2 files changed
+34 -2

No files matched your search

+7 -2
View File
@@ -477,16 +477,21 @@ async function postBatch(request: NextRequest): Promise<Response> {
workoutId: string;
samples: NonNullable<Prepared["samples"]>;
}> = [];
/** Workouts already claimed by an earlier entry of THIS batch. */
const enrichClaimed = new Set<string>();
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,
@@ -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: [