diff --git a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts index 6d784a435..586640b6b 100644 --- a/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts +++ b/src/lib/medications/__tests__/intake-slot-dedup-isolation.test.ts @@ -177,3 +177,69 @@ describe("dedupeUserIntakeSlots — per-slot error isolation", () => { expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(2); }); }); + +/** + * v1.20.2 F4 — the per-medication dedup read path is batched. The + * pre-v1.20.2 shape issued a `findFirst` (latest takenAt) + a `findMany` + * (all rows) PER medication → 2N round-trips. The batched path pulls + * every live row for the user's medication set in ONE `findMany`, groups + * in memory, and derives the per-med `lastIntakeAt` from the grouped set, + * so the read cost is one query regardless of the medication count. + */ +describe("dedupeUserIntakeSlots — batched read path (F4)", () => { + it("reads every medication's rows in a single findMany and never per-med findFirst", async () => { + const MED_2 = { ...MED, id: "med-2" }; + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + MED, + MED_2, + ] as never); + + // Rows for BOTH medications come back in one ordered result set; the + // grouping keys them apart by `medicationId`. med-2 carries a single + // row (no duplicate slot) so it is a no-op — proving the grouping + // routes each med's slice correctly. + const med2Row = { + id: "r-m2", + medicationId: "med-2", + scheduledFor: new Date("2026-06-15T05:00:00.000Z"), + takenAt: new Date("2026-06-15T05:02:00.000Z"), + skipped: false, + syncVersion: 0, + createdAt: new Date("2026-06-15T00:00:00Z"), + attributionSource: "AUTO", + inventoryConsumption: null, + }; + // `inventoryConsumption: null` on every row keeps the loser-refund + // path (which itself runs a `findFirst` inside `restoreForIntake`) + // out of the picture, so the findFirst assertion isolates the removed + // lastIntake probe. + const med1Rows = seedRows().map((r) => ({ + ...r, + inventoryConsumption: null, + })); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + ...med1Rows, + med2Row, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue( + {} as never, + ); + + const summary = await dedupeUserIntakeSlots("u1"); + + // ONE findMany for the whole medication set; the per-med findFirst is + // gone entirely. + expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled(); + // The single read is keyed by the medication-id set, not per med. + const whereArg = vi.mocked(prisma.medicationIntakeEvent.findMany).mock + .calls[0][0] as { + where: { medicationId: { in: string[] } }; + }; + expect(whereArg.where.medicationId.in.sort()).toEqual(["med-1", "med-2"]); + + // med-1's two slots still collapse from its grouped slice; med-2's + // lone row is left alone. + expect(summary.slotsCollapsed).toBe(2); + }); +}); diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 073c47db4..328d24c25 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -242,25 +242,24 @@ export async function dedupeUserIntakeSlots( // recompute after the collapse. Deduped so a busy day folds once. const daysToRecompute = new Set(); - for (const med of medications) { - if (med.schedules.length === 0) continue; + // v1.20.2 F4 — batch the per-medication reads. The pre-v1.20.2 shape + // issued a `findFirst` (latest takenAt) + a `findMany` (all live rows) + // PER medication → 2N round-trips (20 meds = 40). Pull every live row + // for the user's medication set in ONE ordered `findMany`, group in + // memory by `medicationId`, and derive the per-med `lastIntakeAt` (max + // non-null takenAt) from the same grouped set the `findFirst` would + // have scanned. The grouping preserves the `scheduledFor asc` order the + // snap + heal passes depend on (a stable Postgres sort keeps the + // per-medication slices in the same order a per-med query returned). + const medIds = medications + .filter((m) => m.schedules.length > 0) + .map((m) => m.id); - // Latest non-tombstoned takenAt — only rolling schedules consult it, - // but the snap resolver needs it threaded in. - const lastIntake = await prisma.medicationIntakeEvent.findFirst({ - where: { - userId, - medicationId: med.id, - deletedAt: null, - takenAt: { not: null }, - }, - orderBy: { takenAt: "desc" }, - select: { takenAt: true }, - }); - const lastIntakeAt = lastIntake?.takenAt ?? null; - - const rows = (await prisma.medicationIntakeEvent.findMany({ - where: { userId, medicationId: med.id, deletedAt: null }, + const rowsByMedication = new Map(); + const lastIntakeAtByMedication = new Map(); + if (medIds.length > 0) { + const allRows = (await prisma.medicationIntakeEvent.findMany({ + where: { userId, medicationId: { in: medIds }, deletedAt: null }, select: { id: true, medicationId: true, @@ -276,9 +275,37 @@ export async function dedupeUserIntakeSlots( // v1.16.10 — stamped losers refund their consumption on collapse. inventoryConsumption: true, }, - orderBy: { scheduledFor: "asc" }, + // `medicationId` first so the per-med slices are contiguous, then the + // `scheduledFor asc` the loop relies on. + orderBy: [{ medicationId: "asc" }, { scheduledFor: "asc" }], })) as DedupIntakeRow[]; + for (const row of allRows) { + const list = rowsByMedication.get(row.medicationId); + if (list) { + list.push(row); + } else { + rowsByMedication.set(row.medicationId, [row]); + } + // Latest non-tombstoned takenAt per medication — the same value the + // per-med `findFirst({ takenAt: { not: null }, orderBy: desc })` + // returned. Only rolling schedules consult it, but the snap resolver + // needs it threaded in. + if (row.takenAt !== null) { + const prev = lastIntakeAtByMedication.get(row.medicationId) ?? null; + if (prev === null || row.takenAt.getTime() > prev.getTime()) { + lastIntakeAtByMedication.set(row.medicationId, row.takenAt); + } + } + } + } + + for (const med of medications) { + if (med.schedules.length === 0) continue; + + const lastIntakeAt = lastIntakeAtByMedication.get(med.id) ?? null; + const rows = rowsByMedication.get(med.id) ?? []; + if (rows.length < 2) continue; // v1.16.9 — heal pass FIRST: converge ad-hoc actioned API/REMINDER