feat(workouts): attach a route to a stored workout that has none

The workout batch kept `route` only for entries it inserted. An entry
matching a stored workout reported `duplicate` and its track was dropped,
so turning on `storeWorkoutRoutes` only helped workouts recorded from then
on.

A matching entry that carries a route now attaches it when the stored
workout has no track and the account keeps tracks, the same way the
heart-rate series enrichment works: the planner probes for a stored
track, the write seals it like an insert does, and the entry reports
`enriched` (once, also when it attaches series and track together). The
climb derived from the track fills an empty `elevationM`, as it would on
insert. A workout that already has a track keeps it, and with routes off
nothing is written.

Track rows now go through `createManyAndReturn` on the unique
`workoutId`, so a track that lands between the probe and the write wins
and this entry settles as a duplicate instead of failing or adding a
second row.
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 16:04:08 +01:00
1 parent da3d29424d
commit 7b4c67b372
11 files changed
+705 -81

No files matched your search

@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
createManyAndReturn: vi.fn(),
},
workoutRoute: {
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
workoutSamples: {
createManyAndReturn: vi.fn(),
@@ -201,13 +201,16 @@ beforeEach(() => {
}) as never,
);
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
// The series write reports which rows landed, so an enrichment can tell
// an attach from a row a concurrent writer got in first. The fake
// echoes every row back as landed.
// The series and track writes report which rows landed, so an
// enrichment can tell an attach from a row a concurrent writer got in
// first. The fakes echo every row back as landed.
const echoLanded = (async (args: { data: Array<{ workoutId: string }> }) =>
args.data.map((row) => ({ workoutId: row.workoutId }))) as never;
vi.mocked(prisma.workoutRoute.createManyAndReturn).mockImplementation(
echoLanded,
);
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
(async (args: { data: Array<{ workoutId: string }> }) =>
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
echoLanded,
);
// v1.4.43 — default to no per-user source-priority override so
// the write-time picker walks the canonical default ladder. Tests
@@ -550,7 +553,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
};
// Same per-entry status as a stored track: the client needs no branch.
expect(body.data.entries[0]?.status).toBe("inserted");
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
});
it("drops the track when the account row carries no opt-in at all", async () => {
@@ -564,7 +567,7 @@ describe("POST /api/workouts/batch — workout-route opt-in", () => {
const res = await POST(makeRequest({ workouts: [withRoute()] }));
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
});
});
@@ -599,8 +602,9 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
}),
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createManyAndReturn).mock
.calls[0]?.[0];
const rows = (
call as {
data: Array<{
@@ -654,7 +658,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
);
expect(res.status).toBe(200);
// The route table is untouched — an indoor workout has no GPS route.
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
expect(prisma.workoutRoute.createManyAndReturn).not.toHaveBeenCalled();
// The HR series lands in the dedicated child table by FK.
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
@@ -703,10 +707,10 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
}),
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
expect(prisma.workoutRoute.createManyAndReturn).toHaveBeenCalledTimes(1);
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const routeRows = (
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
vi.mocked(prisma.workoutRoute.createManyAndReturn).mock.calls[0]?.[0] as {
data: Array<{ workoutId: string }>;
}
).data;
@@ -27,7 +27,7 @@ vi.mock("@/lib/db", () => ({
const txClient = {
workout: { createManyAndReturn: vi.fn() },
workoutRoute: { createMany: vi.fn() },
workoutRoute: { createManyAndReturn: vi.fn() },
workoutSamples: { createMany: vi.fn() },
};
@@ -133,7 +133,7 @@ beforeEach(() => {
}));
},
);
txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 });
txClient.workoutRoute.createManyAndReturn.mockResolvedValue([]);
txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 });
});
+11 -8
View File
@@ -36,8 +36,8 @@
* - `Content-Length` ceiling at 5 MB before parsing — anything
* larger returns 413 so the iOS client falls back to one workout
* per call.
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
* the iOS sync cursor can checkpoint accurately.
* - Per-entry status (`inserted | duplicate | enriched | updated |
* skipped`) so the iOS sync cursor can checkpoint accurately.
*
* Idempotency contract:
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
@@ -45,12 +45,15 @@
* composite index. Re-posting the same batch surfaces duplicates
* rather than failing the call.
*
* Heart-rate series enrichment: a re-post that matches a stored
* workout and carries a `samples` array attaches that series when the
* workout has none. The workout row itself stays first-write-wins —
* nothing on it is written, not one column — and a workout that already
* holds a series is a no-op, so the client's history sweep is repeatable
* as often as it likes. See `src/lib/workouts/batch-ingest/plan.ts`.
* Enrichment: a re-post that matches a stored workout and carries a
* `samples` array attaches that series when the workout has none, and one
* that carries a `route` attaches that track when the workout has none and
* the account keeps tracks. The workout row itself stays first-write-wins:
* a series writes nothing on it, an attached track fills only an empty
* `elevationM` with its climb (as an insert would). A workout that already
* holds a series or a track keeps it, so the client's history sweep is
* repeatable as often as it likes. See
* `src/lib/workouts/batch-ingest/plan.ts`.
*
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
* Prisma's `createManyAndReturn`. The returned composite keys identify the