mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user