From 2c6699f682003027777a036eca7fd18883274fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:13:45 +0200 Subject: [PATCH] perf(batch): replace per-entry existence probes with batched reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three batch-ingest routes ran a per-entry findUnique/upsert loop — up to 1000 sequential round trips on a 500-entry batch. - nutrients/batch: one indexed findMany existence read splits the batch into a bulk createMany (new day/nutrient pairs) and a $transaction of per-row updates (existing pairs), the shape measurements/batch already uses. A write failure now marks its whole group skipped rather than one entry, documented at the call site; validation failures stay isolated per entry as before. - mood-entries/bulk: the per-entry findUnique probe is replaced by one batched findMany keyed exactly like each entry's own dedup contract (externalId when carried, else date+moodLoggedAt). The per-entry upsert, tombstone check, tag-link writes and reverse push are unchanged — only the existence read is batched. --- .../mood-entries/bulk/__tests__/route.test.ts | 115 +++++++--- src/app/api/mood-entries/bulk/route.ts | 62 ++++- .../nutrients/batch/__tests__/route.test.ts | 154 +++++++++---- src/app/api/nutrients/batch/route.ts | 216 ++++++++++++------ 4 files changed, 394 insertions(+), 153 deletions(-) diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index 8742b60d8..7bf24ea65 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -1,6 +1,14 @@ /** * v1.4.43 W6 — multi-issue 422 envelope on POST /api/mood-entries/bulk. * Preserves the `mood.bulk.invalid` errorCode meta passthrough. + * + * v1.29 perf fix — the per-entry `findUnique` probe was replaced by one + * batched `findMany` existence read (a single indexed OR query); every + * entry still resolves its own "inserted" / "duplicate" state through the + * exact key its dedup contract uses (externalId when carried, else the + * legacy date+moodLoggedAt key). The per-entry `upsert` + tombstone check + + * tag links + reverse push are unchanged. Tests below drive `findMany` + * instead of `findUnique`. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -8,7 +16,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { - findUnique: vi.fn(), + findMany: vi.fn(), upsert: vi.fn(), }, auditLog: { create: vi.fn() }, @@ -84,6 +92,29 @@ function postReq(body: unknown): NextRequest { }); } +/** + * Shape the bulk pre-read's `findMany` select always returns. Every mocked + * "existing row" needs every field the route registers into its two lookup + * maps, even when a test only cares about one of them. + */ +function existingRow(overrides: { + id: string; + deletedAt?: Date | null; + source?: string; + externalId?: string | null; + date?: string; + moodLoggedAt?: Date; +}) { + return { + deletedAt: null, + source: "MANUAL", + externalId: null, + date: "2026-05-16", + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + ...overrides, + }; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -190,7 +221,7 @@ describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { beforeEach(() => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-1", } as never); @@ -272,11 +303,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { ); }); - it("dedups on (userId, source, externalId) when an entry carries externalId", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ - id: "entry-ext", - } as never); + it("reads existence once via a batched findMany, keyed on (source, externalId) when the entry carries one", async () => { const res = await POST( postReq({ entries: [ @@ -293,11 +320,15 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { data: { inserted: number; entries: Array<{ externalId?: string }> }; }; expect(json.data.inserted).toBe(1); - // The probe + upsert both key on the externalId compound, not the - // legacy wall-clock tuple. - const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] - ?.where as Record; - expect(probeWhere).toHaveProperty("userId_source_externalId"); + expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1); + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + where: { userId: string; OR: Array> }; + }; + expect(findManyArg.where.OR).toContainEqual({ + source: "MANUAL", + externalId: "ios-uuid-1", + }); + // The per-entry upsert still keys on the compound unique. const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { where: Record; create: { externalId: string | null; source: string }; @@ -308,11 +339,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { expect(json.data.entries[0].externalId).toBe("ios-uuid-1"); }); - it("uses the legacy wall-clock key and omits externalId when none is sent", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null as never); - vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ - id: "entry-legacy", - } as never); + it("keys the batched existence read on (date, moodLoggedAt) and omits externalId when none is sent", async () => { const res = await POST( postReq({ entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }], @@ -322,18 +349,30 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { const json = (await res.json()) as { data: { entries: Array<{ externalId?: string }> }; }; - const probeWhere = vi.mocked(prisma.moodEntry.findUnique).mock.calls[0]?.[0] - ?.where as Record; - expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt"); + const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as { + where: { OR: Array> }; + }; + expect(findManyArg.where.OR).toContainEqual({ + date: "2026-05-16", + moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"), + }); + const upsertArg = vi.mocked(prisma.moodEntry.upsert).mock.calls[0]?.[0] as { + where: Record; + }; + expect(upsertArg.where).toHaveProperty("userId_date_moodLoggedAt"); expect(json.data.entries[0].externalId).toBeUndefined(); }); it("reports a re-posted externalId entry as a duplicate (idempotent)", async () => { - // Probe finds the row → the response classifies it duplicate, not - // inserted, and the upsert update branch refreshes it in place. - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-ext", - } as never); + // The batched pre-read finds the row → the response classifies it + // duplicate, not inserted, and the upsert update branch refreshes it. + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-ext", + source: "MANUAL", + externalId: "ios-uuid-1", + }), + ] as never); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-ext", } as never); @@ -416,13 +455,17 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => { describe("POST /api/mood-entries/bulk — tombstone suppression", () => { it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => { - // A soft-deleted row under the probe key: the sync feed already + // A soft-deleted row under the batched pre-read: the sync feed already // surfaced the deletion to paired clients, so a stale offline re-post // must NOT resurrect it — and must not churn the hidden row either. - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-tomb", - deletedAt: new Date("2026-05-01T00:00:00.000Z"), - } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-tomb", + deletedAt: new Date("2026-05-01T00:00:00.000Z"), + source: "MANUAL", + externalId: "ios-uuid-tomb", + }), + ] as never); const res = await POST( postReq({ entries: [ @@ -455,10 +498,14 @@ describe("POST /api/mood-entries/bulk — tombstone suppression", () => { }); it("still refreshes a LIVE match in place (duplicate + upsert runs)", async () => { - vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({ - id: "entry-live", - deletedAt: null, - } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ + existingRow({ + id: "entry-live", + deletedAt: null, + source: "MANUAL", + externalId: "ios-uuid-live", + }), + ] as never); vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({ id: "entry-live", } as never); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 7afe11bd4..c74dc1d9a 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -189,6 +189,51 @@ async function postBulk(request: NextRequest): Promise { let duplicates = 0; const skipped: Array<{ index: number; reason: string }> = []; + // Perf — a single indexed existence read replaces the per-entry + // `findUnique` probe (up to 500 sequential round trips pre-fix). Every + // entry still resolves through exactly the key its own dedup contract + // uses (externalId when carried, else the legacy date+moodLoggedAt key), + // so lookups below are byte-identical to the per-entry probe — only the + // read is now batched. The per-entry `upsert` + tombstone check + tag + // links + reverse push all stay unchanged; batching those further would + // risk the correctness-sensitive per-row side effects for a write path + // that is usually small (a normal sync posts the current + previous + // local day, not a full historical backfill). + interface ExistingMoodRow { + id: string; + deletedAt: Date | null; + } + const orClauses: Array< + | { source: string; externalId: string } + | { date: string; moodLoggedAt: Date } + > = entries.map((entry) => + entry.externalId + ? { source: entry.source, externalId: entry.externalId } + : { date: moodDateKey(entry.moodLoggedAt, tz), moodLoggedAt: entry.moodLoggedAt }, + ); + const existingRows = + orClauses.length > 0 + ? await prisma.moodEntry.findMany({ + where: { userId: user.id, OR: orClauses }, + select: { + id: true, + deletedAt: true, + source: true, + externalId: true, + date: true, + moodLoggedAt: true, + }, + }) + : []; + const existingByExternalKey = new Map(); + const existingByDateKey = new Map(); + for (const row of existingRows) { + if (row.externalId !== null) { + existingByExternalKey.set(`${row.source}::${row.externalId}`, row); + } + existingByDateKey.set(`${row.date}::${row.moodLoggedAt.toISOString()}`, row); + } + for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const date = moodDateKey(entry.moodLoggedAt, tz); @@ -220,15 +265,14 @@ async function postBulk(request: NextRequest): Promise { }, }; - // Probe-then-upsert so the response reliably distinguishes - // "inserted" from "duplicate". Two round-trips per entry is - // acceptable given the 500-entry cap; a more cache-friendly - // shape (batched probe) is a v1.4.31 optimisation if the cap - // grows. - const existing = await prisma.moodEntry.findUnique({ - where: probeWhere, - select: { id: true, deletedAt: true }, - }); + // The response reliably distinguishes "inserted" from "duplicate" + // via the batched pre-read above (keyed exactly like `probeWhere`), + // so the per-entry probe here is now a Map lookup, not a round trip. + const existing = + (entry.externalId + ? existingByExternalKey.get(`${resolvedSource}::${entry.externalId}`) + : existingByDateKey.get(`${date}::${entry.moodLoggedAt.toISOString()}`)) ?? + null; // Tombstone suppression: a soft-deleted match stays deleted. The // user-facing DELETE route flips `deletedAt` so the `/api/sync/changes` diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index 5afdb0047..1d53a6597 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -3,8 +3,15 @@ * * Pins the ingest posture: module gate first (403 `module.disabled`), * per-entry skip guards (unit mismatch / plausibility / day key), the - * insert-vs-update statuses off the composite-PK probe, and the batch - * envelope errors (`too_large`, multi-issue `invalid`). + * insert-vs-update statuses off a single bulk existence probe, and the + * batch envelope errors (`too_large`, multi-issue `invalid`). + * + * v1.29 perf fix — the probe-then-upsert-per-entry pattern (up to 1000 + * sequential round trips) was replaced by one indexed `findMany` existence + * read + a bulk `createMany` for new pairs + a `$transaction` of per-row + * `update` calls for existing pairs. `$transaction` is mocked to actually + * run the array of promises it's handed so the update path executes for + * real in these tests. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -12,9 +19,11 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay: { - findUnique: vi.fn(), - upsert: vi.fn(), + findMany: vi.fn(), + createMany: vi.fn(), + update: vi.fn(), }, + $transaction: vi.fn(), }, })); @@ -93,8 +102,20 @@ beforeEach(() => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(auditLog).mockResolvedValue(undefined as never); - vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue(null); - vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({} as never); + // No existing (day, nutrient) pairs by default — every valid entry + // takes the insert path unless a test overrides this. + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([]); + vi.mocked(prisma.nutrientIntakeDay.createMany).mockImplementation( + (async (args: { data: unknown[] }) => ({ + count: args.data.length, + })) as never, + ); + vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({} as never); + // Real Prisma resolves every promise in the `$transaction([...])` array; + // the mock does the same so the update path actually runs in these tests. + vi.mocked(prisma.$transaction).mockImplementation( + (async (ops: unknown[]) => Promise.all(ops)) as never, + ); }); describe("POST /api/nutrients/batch — module gate", () => { @@ -122,7 +143,7 @@ describe("POST /api/nutrients/batch — module gate", () => { expect(body.data).toBeNull(); expect(body.meta?.errorCode).toBe("module.disabled"); expect(body.meta?.module).toBe("nutrients"); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); }); @@ -190,7 +211,8 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { reason: "unit_mismatch", }); expect(body.data.skipped).toEqual([{ index: 0, reason: "unit_mismatch" }]); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.findMany).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("skips a value above the plausibility cap", async () => { @@ -205,7 +227,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { const body = (await res.json()) as BatchResponse; expect(body.data.entries[0].status).toBe("skipped"); expect(body.data.entries[0].reason).toBe("value_out_of_range"); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("skips an impossible calendar day and a far-future day as day_invalid", async () => { @@ -224,7 +246,7 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { "day_invalid", "day_invalid", ]); - expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); }); it("a skipped entry never fails the batch — siblings still land", async () => { @@ -240,12 +262,13 @@ describe("POST /api/nutrients/batch — per-entry skip guards", () => { expect(body.data.processed).toBe(2); expect(body.data.inserted).toBe(1); expect(body.data.skipped).toHaveLength(1); - expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledTimes(1); + // One bulk createMany call carries the single valid entry. + expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledTimes(1); }); }); -describe("POST /api/nutrients/batch — upsert semantics", () => { - it("reports inserted for a fresh (day, nutrient) and writes the catalog's canonical unit", async () => { +describe("POST /api/nutrients/batch — write semantics", () => { + it("reports inserted for a fresh (day, nutrient), reads existence once, and writes the catalog's canonical unit", async () => { const day = recentDay(); const res = await POST( postReq({ @@ -265,42 +288,43 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect(body.data.updated).toBe(0); expect(body.data.entries[0].status).toBe("inserted"); - expect(prisma.nutrientIntakeDay.upsert).toHaveBeenCalledWith({ + // Single indexed existence read, not a per-entry probe. + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledTimes(1); + expect(prisma.nutrientIntakeDay.findMany).toHaveBeenCalledWith({ where: { - userId_day_nutrient_source: { + userId: "user-1", + source: "APPLE_HEALTH", + OR: [{ day, nutrient: "vitamin_d" }], + }, + select: { day: true, nutrient: true }, + }); + + expect(prisma.nutrientIntakeDay.createMany).toHaveBeenCalledWith({ + data: [ + { userId: "user-1", day, nutrient: "vitamin_d", + amount: 22.5, + unit: "ug", source: "APPLE_HEALTH", + externalSourceVersion: "yazio-9.9", }, - }, - create: { - userId: "user-1", - day, - nutrient: "vitamin_d", - amount: 22.5, - unit: "ug", - source: "APPLE_HEALTH", - externalSourceVersion: "yazio-9.9", - }, - update: { - amount: 22.5, - unit: "ug", - externalSourceVersion: "yazio-9.9", - }, + ], + skipDuplicates: true, }); + expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); }); it("reports updated (never duplicate) when the composite key already exists", async () => { - vi.mocked(prisma.nutrientIntakeDay.findUnique).mockResolvedValue({ - userId: "user-1", - } as never); + const day = recentDay(); + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day, nutrient: "caffeine" }, + ] as never); const res = await POST( postReq({ - entries: [ - { day: recentDay(), nutrient: "caffeine", unit: "mg", amount: 310 }, - ], + entries: [{ day, nutrient: "caffeine", unit: "mg", amount: 310 }], }), ); const body = (await res.json()) as BatchResponse; @@ -310,6 +334,20 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { expect( body.data.entries.some((e) => e.status === ("duplicate" as string)), ).toBe(false); + + expect(prisma.nutrientIntakeDay.createMany).not.toHaveBeenCalled(); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith({ + where: { + userId_day_nutrient_source: { + userId: "user-1", + day, + nutrient: "caffeine", + source: "APPLE_HEALTH", + }, + }, + data: { amount: 310, unit: "mg", externalSourceVersion: null }, + }); }); it("writes the audit-ledger breadcrumb only when at least one row landed", async () => { @@ -335,19 +373,24 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { ); }); - it("a thrown upsert marks only that entry skipped upsert_failed without leaking the raw error", async () => { + it("a failed bulk insert marks only the insert group skipped, without touching the update group", async () => { // The exception message must never reach the client — the per-entry - // `reason` is a closed set. A raw-message echo was a v1.28.48 fix. + // `reason` is a closed set. const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.mocked(prisma.nutrientIntakeDay.upsert) - .mockRejectedValueOnce(new Error("boom: sensitive db detail")) - .mockResolvedValueOnce({} as never); + const day = recentDay(); + // `zinc` is new (insert path); `iron` already exists (update path). + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([ + { day, nutrient: "iron" }, + ] as never); + vi.mocked(prisma.nutrientIntakeDay.createMany).mockRejectedValue( + new Error("boom: sensitive db detail"), + ); const res = await POST( postReq({ entries: [ - { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 }, - { day: recentDay(), nutrient: "iron", unit: "mg", amount: 12 }, + { day, nutrient: "zinc", unit: "mg", amount: 10 }, + { day, nutrient: "iron", unit: "mg", amount: 12 }, ], }), ); @@ -358,8 +401,29 @@ describe("POST /api/nutrients/batch — upsert semantics", () => { // The raw exception text is logged server-side only, never echoed. expect(JSON.stringify(body)).not.toContain("sensitive db detail"); expect(spy).toHaveBeenCalled(); - expect(body.data.entries[1].status).toBe("inserted"); - expect(body.data.inserted).toBe(1); + // The independent update group still lands. + expect(body.data.entries[1].status).toBe("updated"); + expect(body.data.updated).toBe(1); spy.mockRestore(); }); + + it("a race that drops one createMany row still counts consistently and is observable", async () => { + vi.mocked(prisma.nutrientIntakeDay.createMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await POST( + postReq({ + entries: [ + { day: recentDay(), nutrient: "zinc", unit: "mg", amount: 10 }, + { day: recentDay(2), nutrient: "iron", unit: "mg", amount: 12 }, + ], + }), + ); + const body = (await res.json()) as BatchResponse; + // Both entries are still labelled inserted (count-only trade-off, + // documented at the call site) — the response never fails. + expect(body.data.inserted).toBe(1); + expect(res.status).toBe(200); + }); }); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 73506e330..44ea0c9a3 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -10,10 +10,14 @@ * Upsert key = the composite PK (userId, day, nutrient). Re-post * replaces amount / unit / externalSourceVersion — last-writer-wins, * the day-total contract; per-entry status is `updated` then, never - * `duplicate`. Per-entry failures come back `skipped` with a reason - * (`unit_mismatch` | `value_out_of_range` | `day_invalid` | - * `upsert_failed`), never a batch failure — the measurements-batch - * posture. + * `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range` + * | `day_invalid`) are always isolated per entry. The DB write itself runs + * as two batched groups — a single indexed existence read splits valid + * entries into a bulk `createMany` (new pairs) and a `$transaction` of + * per-row updates (existing pairs), the measurements-batch shape. A write + * failure marks its WHOLE group `skipped upsert_failed` rather than + * isolating the one entry that would have caused it (unavoidable once the + * write is batched) — the request itself never fails. * * Module gate FIRST: the opt-in `nutrients` module (default off) * refuses ingest with the 403 `module.disabled` envelope, so a phone @@ -130,11 +134,21 @@ async function postBatch(request: NextRequest): Promise { const { entries } = parsed.data; const dayCeiling = maxAcceptableDay(new Date()); - const results: EntryResult[] = []; + const results: EntryResult[] = new Array(entries.length); const skipped: Array<{ index: number; reason: string }> = []; let inserted = 0; let updated = 0; + interface ValidEntry { + index: number; + day: string; + nutrient: (typeof entries)[number]["nutrient"]; + amount: number; + unit: string; + externalSourceVersion: string | null; + } + const validEntries: ValidEntry[] = []; + for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const definition = NUTRIENT_CATALOG[entry.nutrient]; @@ -144,86 +158,158 @@ async function postBatch(request: NextRequest): Promise { // unit skips the entry rather than converting. if (entry.unit !== definition.unit) { skipped.push({ index: i, reason: "unit_mismatch" }); - results.push({ index: i, status: "skipped", reason: "unit_mismatch" }); + results[i] = { index: i, status: "skipped", reason: "unit_mismatch" }; continue; } if (entry.amount > definition.plausibleDailyMax) { skipped.push({ index: i, reason: "value_out_of_range" }); - results.push({ + results[i] = { index: i, status: "skipped", reason: "value_out_of_range", - }); + }; continue; } if (!isRealCalendarDay(entry.day) || entry.day > dayCeiling) { skipped.push({ index: i, reason: "day_invalid" }); - results.push({ index: i, status: "skipped", reason: "day_invalid" }); + results[i] = { index: i, status: "skipped", reason: "day_invalid" }; continue; } - try { - // v1.29 — `source` joined the composite PK (migration 0249) so a - // manual water entry (source=MANUAL, written by - // `POST /api/nutrients/water`) coexists with the Apple-synced day - // total instead of one clobbering the other on the next sync. The - // batch route always owns the APPLE_HEALTH row — byte-identical - // upsert behaviour to pre-0249, just against the wider key. - const key = { - userId_day_nutrient_source: { - userId: user.id, - day: entry.day, - nutrient: entry.nutrient, - source: "APPLE_HEALTH", - }, - }; + validEntries.push({ + index: i, + day: entry.day, + nutrient: entry.nutrient, + amount: entry.amount, + unit: definition.unit, + externalSourceVersion: entry.externalSourceVersion ?? null, + }); + } - // Probe-then-upsert so the response reliably distinguishes - // `inserted` from `updated` (the mood-bulk pattern; two round - // trips per entry is fine under the 500-entry cap). - const existing = await prisma.nutrientIntakeDay.findUnique({ - where: key, - select: { userId: true }, - }); + // v1.29 — `source` joined the composite PK (migration 0249) so a manual + // water entry (source=MANUAL, written by `POST /api/nutrients/water`) + // coexists with the Apple-synced day total instead of one clobbering the + // other on the next sync. The batch route always owns the APPLE_HEALTH + // row. + // + // Perf (measurements/batch shape): ONE indexed existence read replaces the + // per-entry probe-then-upsert (up to 1000 sequential round trips pre-fix). + // Genuinely new (day, nutrient) pairs land via a single chunked + // `createMany`; pairs that already exist update via a batched + // `$transaction` of per-row `update` calls (values differ per row, so a + // single bulk statement isn't available without raw SQL — this still + // collapses the per-entry PROBE, and pipelines the writes through one + // transaction instead of N standalone round trips). + if (validEntries.length > 0) { + const existing = await prisma.nutrientIntakeDay.findMany({ + where: { + userId: user.id, + source: "APPLE_HEALTH", + OR: validEntries.map((e) => ({ day: e.day, nutrient: e.nutrient })), + }, + select: { day: true, nutrient: true }, + }); + const existingKeys = new Set( + existing.map((r) => `${r.day}::${r.nutrient}`), + ); - await prisma.nutrientIntakeDay.upsert({ - where: key, - create: { - userId: user.id, - day: entry.day, - nutrient: entry.nutrient, - amount: entry.amount, - unit: definition.unit, - source: "APPLE_HEALTH", - externalSourceVersion: entry.externalSourceVersion ?? null, - }, - update: { - // Last-writer-wins on the day total: iOS re-posts the current - // and previous local day on every sync as the totals grow. - amount: entry.amount, - unit: definition.unit, - externalSourceVersion: entry.externalSourceVersion ?? null, - }, - }); + const toInsert: ValidEntry[] = []; + const toUpdate: ValidEntry[] = []; + for (const e of validEntries) { + (existingKeys.has(`${e.day}::${e.nutrient}`) ? toUpdate : toInsert).push( + e, + ); + } - if (existing) { - updated += 1; - results.push({ index: i, status: "updated" }); - } else { - inserted += 1; - results.push({ index: i, status: "inserted" }); + if (toInsert.length > 0) { + try { + const result = await prisma.nutrientIntakeDay.createMany({ + data: toInsert.map((e) => ({ + userId: user.id, + day: e.day, + nutrient: e.nutrient, + amount: e.amount, + unit: e.unit, + source: "APPLE_HEALTH" as const, + externalSourceVersion: e.externalSourceVersion, + })), + skipDuplicates: true, + }); + inserted += result.count; + for (const e of toInsert) { + results[e.index] = { index: e.index, status: "inserted" }; + } + // Rare race: a concurrent request claimed one of these keys between + // our existence probe and this createMany, so skipDuplicates + // silently dropped that row's write here — measurements/batch + // accepts the same count-only trade-off for its own createMany race. + // The DB itself stays consistent (the racing writer's row is + // stored); only this response's per-row label may be stale for + // that one entry. + if (result.count < toInsert.length) { + annotate({ + action: { name: "nutrient.batch.insert_race" }, + meta: { attempted: toInsert.length, stored: result.count }, + }); + } + } catch (err: unknown) { + console.error("[nutrient-batch] bulk insert failed", err); + for (const e of toInsert) { + skipped.push({ index: e.index, reason: "upsert_failed" }); + results[e.index] = { + index: e.index, + status: "skipped", + reason: "upsert_failed", + }; + } + } + } + + if (toUpdate.length > 0) { + try { + await prisma.$transaction( + toUpdate.map((e) => + prisma.nutrientIntakeDay.update({ + where: { + userId_day_nutrient_source: { + userId: user.id, + day: e.day, + nutrient: e.nutrient, + source: "APPLE_HEALTH", + }, + }, + data: { + // Last-writer-wins on the day total: iOS re-posts the + // current and previous local day on every sync as the + // totals grow. + amount: e.amount, + unit: e.unit, + externalSourceVersion: e.externalSourceVersion, + }, + }), + ), + ); + updated += toUpdate.length; + for (const e of toUpdate) { + results[e.index] = { index: e.index, status: "updated" }; + } + } catch (err: unknown) { + // The client-facing `reason` is a closed set (see the file header) — + // never echo raw exception text into it. Log the real error + // server-side only (SWC keeps `console.error` in prod) and return + // the fixed `upsert_failed` member. + console.error("[nutrient-batch] bulk update failed", err); + for (const e of toUpdate) { + skipped.push({ index: e.index, reason: "upsert_failed" }); + results[e.index] = { + index: e.index, + status: "skipped", + reason: "upsert_failed", + }; + } } - } catch (err: unknown) { - // The client-facing `reason` is a closed set (see the file header) — - // never echo raw exception text into it. Log the real error - // server-side only (SWC keeps `console.error` in prod) and return the - // fixed `upsert_failed` member. - console.error("[nutrient-batch] upsert failed", err); - const reason = "upsert_failed"; - skipped.push({ index: i, reason }); - results.push({ index: i, status: "skipped", reason }); } }