perf(batch): replace per-entry existence probes with batched reads

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 00:37:24 +02:00
1 parent 2bc706a445
commit 2c6699f682
4 files changed
+394 -153

No files matched your search

@@ -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<string, unknown>;
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<Record<string, unknown>> };
};
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<string, unknown>;
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<string, unknown>;
expect(probeWhere).toHaveProperty("userId_date_moodLoggedAt");
const findManyArg = vi.mocked(prisma.moodEntry.findMany).mock.calls[0]?.[0] as {
where: { OR: Array<Record<string, unknown>> };
};
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<string, unknown>;
};
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);
+53 -9
View File
@@ -189,6 +189,51 @@ async function postBulk(request: NextRequest): Promise<Response> {
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<string, ExistingMoodRow>();
const existingByDateKey = new Map<string, ExistingMoodRow>();
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<Response> {
},
};
// 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`
@@ -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);
});
});
+151 -65
View File
@@ -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<Response> {
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<Response> {
// 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 });
}
}