Merge branch 'main' into feat/mood-level-b

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 10:57:59 +02:00
commit 61c220b7b8
8 files changed
+906 -109

No files matched your search

@@ -23,7 +23,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
workoutSamples: {
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
user: {
findUnique: vi.fn(),
@@ -109,6 +109,23 @@ function makeRequest(
});
}
/**
* `workout.findMany` serves two callers in this route: the pre-flight
* probe for rows that already exist (it passes a `where`), and — inside
* the fake above — the stand-in for `INSERT ... RETURNING` (`select`
* only). Keying on `select.id` alone stopped telling the two apart once
* the probe began selecting `id` so an enrichment can address the stored
* workout, so the RETURNING arm is the one WITHOUT a `where`.
*/
function mockInsertReturning(
rows: Array<{ id: string; source: string; externalId: string }>,
) {
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
where?: unknown;
select?: Record<string, unknown>;
}) => (args.where === undefined && args.select?.id ? rows : [])) as never);
}
function validWorkout(
externalId: string,
overrides: Record<string, unknown> = {},
@@ -161,7 +178,13 @@ beforeEach(() => {
);
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.workoutSamples.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.
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
(async (args: { data: Array<{ workoutId: string }> }) =>
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
);
// v1.4.43 W9 — default to no per-user source-priority override so
// the write-time picker walks the canonical default ladder. Tests
// that exercise a custom ladder override this in-line.
@@ -434,25 +457,18 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
createdAt: new Date("2026-05-14T10:00:01.000Z"),
},
] as never);
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: Record<string, unknown>;
}) => {
if (args.select?.id) {
return [
{
id: "apple-id",
source: "APPLE_HEALTH",
externalId: "shared-external-id",
},
{
id: "manual-id",
source: "MANUAL",
externalId: "shared-external-id",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "apple-id",
source: "APPLE_HEALTH",
externalId: "shared-external-id",
},
{
id: "manual-id",
source: "MANUAL",
externalId: "shared-external-id",
},
]);
const res = await POST(
makeRequest({
@@ -480,20 +496,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
// Post-insert lookup returns the newly-inserted workout id so the
// route can be attached by FK.
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true; source?: true; externalId?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-fresh-id",
source: "APPLE_HEALTH",
externalId: "uuid-with-route",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "wkt-fresh-id",
source: "APPLE_HEALTH",
externalId: "uuid-with-route",
},
]);
const res = await POST(
makeRequest({
@@ -523,20 +532,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-indoor-id",
source: "APPLE_HEALTH",
externalId: "uuid-indoor",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "wkt-indoor-id",
source: "APPLE_HEALTH",
externalId: "uuid-indoor",
},
]);
const res = await POST(
makeRequest({
@@ -556,8 +558,9 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
// The route table is untouched — an indoor workout has no GPS route.
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
// The HR series lands in the dedicated child table by FK.
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0];
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
.calls[0]?.[0];
const rows = (
call as {
data: Array<{
@@ -575,20 +578,13 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
it("attaches both a route and an HR series on one workout", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-both-id",
source: "APPLE_HEALTH",
externalId: "uuid-both",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "wkt-both-id",
source: "APPLE_HEALTH",
externalId: "uuid-both",
},
]);
const res = await POST(
makeRequest({
@@ -610,14 +606,15 @@ 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.workoutSamples.createMany).toHaveBeenCalledTimes(1);
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const routeRows = (
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
data: Array<{ workoutId: string }>;
}
).data;
const sampleRows = (
vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as {
vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
.calls[0]?.[0] as {
data: Array<{ workoutId: string }>;
}
).data;
@@ -632,7 +629,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
makeRequest({ workouts: [validWorkout("uuid-no-series")] }),
);
expect(res.status).toBe(200);
expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled();
expect(prisma.workoutSamples.createManyAndReturn).not.toHaveBeenCalled();
});
});
+128 -28
View File
@@ -30,8 +30,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 | skipped`) so the iOS
* sync cursor can checkpoint accurately.
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
* the iOS sync cursor can checkpoint accurately.
*
* Idempotency contract:
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
@@ -39,6 +39,13 @@
* 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 the enrichment block below.
*
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
* Prisma's `createManyAndReturn`. The returned composite keys identify the
* exact rows this request inserted, so per-entry statuses, child rows, and
@@ -66,10 +73,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout";
import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
} from "@/lib/validations/workout";
INVALID_SAMPLES_REASON,
parseWorkoutBatch,
} from "@/lib/workouts/batch-ingest-parse";
import {
classifyExternalId,
unstableExternalIdMeta,
@@ -106,16 +114,14 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
/**
* Per-entry outcome the iOS client uses to advance its sync cursor.
* `inserted` and `duplicate` both indicate the row landed (or was
* already present); the client can checkpoint past them identically.
* `skipped` covers entries the server cannot durably store — currently
* unused by this endpoint (the Zod schema rejects malformed entries
* with a 422 before we reach the per-entry pass), but reserved on the
* envelope so the response shape parallels the measurements batch and
* future server-side validation can surface fine-grained skips without
* breaking the iOS DTO.
* `inserted`, `duplicate` and `enriched` all indicate the row landed (or
* was already present); the client can checkpoint past them identically.
* `enriched` additionally says the entry's heart-rate series was attached
* to a workout that had none, which is what lets a history sweep count
* its own progress. `skipped` covers entries the server refused: an
* unusable external id, or a `samples` array that failed validation.
*/
type EntryStatus = "inserted" | "duplicate" | "skipped";
type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
@@ -251,15 +257,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
}
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return apiError(issue?.message ?? "Invalid batch", 400, {
// Strict on every field, per-entry on `samples` alone — see
// `parseWorkoutBatch` for why the series is the one field whose
// failure must not take the rest of a history sweep down with it.
const parsed = parseWorkoutBatch(rawBody);
if (!parsed.ok) {
return apiError(parsed.message, 400, {
errorCode: "workout.batch.invalid",
});
}
const { workouts } = parsed.data;
const refusedSampleIndices = new Set(parsed.refusedSampleIndices);
// Pre-flight duplicate detection so we can return per-entry status.
// The composite unique index is `(userId, source, externalId)` —
@@ -289,6 +298,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
const results: EntryResult[] = new Array(workouts.length);
const unstableShapes: UnstableExternalIdShape[] = [];
const prepared: Prepared[] = workouts.flatMap((w, index) => {
// An entry whose series failed validation is refused whole. Storing
// the workout without the curve the caller asked us to keep would be
// a silent loss, and the row may well already exist anyway.
if (refusedSampleIndices.has(index)) {
results[index] = {
index,
status: "skipped",
reason: INVALID_SAMPLES_REASON,
};
return [];
}
// The `(userId, source, externalId)` dedup key is only idempotent
// while the id is STABLE across client launches. An id that rotates
// per process (an object description carrying a memory address)
@@ -408,6 +429,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
let duplicateCount = droppedByWriteDedup.length;
let insertedCount = 0;
let enrichedCount = 0;
const insertedIdByIndex = new Map<number, string>();
if (survivors.length > 0) {
@@ -418,6 +440,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
.map((p) => p.dedupKey)
.filter((k): k is { source: string; externalId: string } => k !== null);
// The same probe answers both questions: does the row exist, and
// does it already carry a series. `id` comes along so an enrichment
// can address the stored workout without a second round trip.
const existing = dedupCandidates.length
? await prisma.workout.findMany({
where: {
@@ -427,30 +452,65 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: k.externalId,
})),
},
select: { source: true, externalId: true },
select: {
id: true,
source: true,
externalId: true,
samples: { select: { workoutId: true } },
},
})
: [];
const existingSet = new Set(
existing.map((row) => `${row.source}::${row.externalId}`),
);
const existingByKey = new Map<string, { id: string; hasSeries: boolean }>();
for (const row of existing) {
if (row.externalId === null) continue;
existingByKey.set(`${row.source}::${row.externalId}`, {
id: row.id,
hasSeries: row.samples !== null,
});
}
const toInsert: Prepared[] = [];
/** Stored workouts this batch hands a heart-rate series to. */
const toEnrich: Array<{
index: number;
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;
if (keyTuple !== null && existingSet.has(keyTuple)) {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined;
if (known) {
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. 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,
samples: p.samples,
});
} else {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p);
}
}
if (toInsert.length > 0) {
if (toInsert.length > 0 || toEnrich.length > 0) {
const CHUNK = 100;
/** Workout ids whose series row this request actually wrote. */
const seriesLanded = new Set<string>();
await prisma.$transaction(async (tx) => {
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
@@ -517,6 +577,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// Enrichment rides the SAME child write as a fresh workout's
// series — one mechanism, one place where a series is stored.
// The only difference is where the `workoutId` came from: the
// pre-flight probe instead of this request's INSERT ... RETURNING.
// Nothing here writes to the workout row.
for (const e of toEnrich) {
samplesToInsert.push({
workoutId: e.workoutId,
samples: e.samples.samples as Prisma.InputJsonValue,
sampleCount: e.samples.sampleCount,
});
}
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
await tx.workoutRoute.createMany({
data: routesToInsert.slice(i, i + CHUNK),
@@ -524,10 +597,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
await tx.workoutSamples.createMany({
// RETURNING names the rows that actually landed. `workoutId` is
// unique, so a workout that gained a series between the probe
// and this write is skipped here and reports as a duplicate —
// the re-post stays a no-op and never overwrites a stored curve.
const landed = await tx.workoutSamples.createManyAndReturn({
data: samplesToInsert.slice(i, i + CHUNK),
skipDuplicates: true,
select: { workoutId: true },
});
for (const row of landed) seriesLanded.add(row.workoutId);
}
});
@@ -540,6 +619,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
duplicateCount += 1;
}
}
// An enriched entry is still a duplicate of the workout row: the
// top-level counters count rows, and no row was inserted for it.
// The per-entry status carries what happened to the series.
for (const e of toEnrich) {
results[e.index] = {
index: e.index,
status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate",
};
if (seriesLanded.has(e.workoutId)) enrichedCount += 1;
duplicateCount += 1;
}
}
}
@@ -554,6 +645,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
enriched: enrichedCount,
skipped: skipped.length,
},
});
@@ -584,12 +676,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// Counts only. The series itself never reaches a wide event.
annotate({
action: { name: "workout.batch.ingest" },
meta: {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
enriched: enrichedCount,
skipped: skipped.length,
},
});
@@ -598,11 +692,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
// caches when at least one row landed. Workouts ride on the
// measurements bucket because achievements / analytics also touch
// workout-derived metrics.
if (insertedCount > 0) {
// An enrichment changes what the workout reads as — the detail seam
// gains its curve, the list gains its glyph — so it busts the cache
// too, even though no workout row was written.
if (insertedCount > 0 || enrichedCount > 0) {
invalidateUserMeasurements(user.id);
}
if (insertedCount > 0) {
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
// stop at the shared salience classifier before any queue work.
// An enrichment raises none: the workout arrived when it was stored.
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
() => {},
);