perf(inventory): bulk-update expired in-use items via updateMany

The daily cron sweep loaded every stale IN_USE row and issued one
`prisma.update` per row, recomputing the state in JS only to learn
what the selector already proved (state=IN_USE AND expiresAt < now ⇒
the row must transition to EXPIRED, because IN_USE implies
dosesRemaining > 0 and the expiresAt filter shows the deadline lapsed).
Collapse the round-trip onto a single `updateMany` so the cron's cost
is O(1) round-trips per user instead of O(stale rows).

Fix-M / code-H3.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:26:01 +02:00
1 parent 1b5906a504
commit 1d5dcfb8b1
2 files changed
+49 -47

No files matched your search

@@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
@@ -122,36 +123,48 @@ describe("consumeOneDose", () => {
});
describe("expireStaleInUseItems", () => {
it("transitions IN_USE rows whose 30-day window has lapsed to EXPIRED", async () => {
const firstUseAgo = new Date(NOW.getTime() - 31 * MS_PER_DAY);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{
id: "inv-stale",
state: "IN_USE",
dosesTotal: 4,
dosesRemaining: 2,
firstUseAt: firstUseAgo,
printedExpiry: null,
},
] as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({} as never);
it("transitions IN_USE rows whose deadline has lapsed via a single updateMany", async () => {
// The selector `state = IN_USE AND expiresAt < now` already proves
// the row must transition to EXPIRED (IN_USE implies dosesRemaining
// > 0, and the expiresAt filter proves the deadline lapsed). Using
// `updateMany` collapses N row round-trips into one.
vi.mocked(prisma.medicationInventoryItem.updateMany).mockResolvedValue({
count: 3,
} as never);
const count = await expireStaleInUseItems({ nowMs: NOW.getTime() });
expect(count).toBe(1);
expect(prisma.medicationInventoryItem.update).toHaveBeenCalledWith({
where: { id: "inv-stale" },
expect(count).toBe(3);
expect(prisma.medicationInventoryItem.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.medicationInventoryItem.updateMany).toHaveBeenCalledWith({
where: {
state: "IN_USE",
expiresAt: { lt: new Date(NOW.getTime()) },
},
data: { state: "EXPIRED" },
});
expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled();
expect(prisma.medicationInventoryItem.findMany).not.toHaveBeenCalled();
});
it("scopes to a single user when userId is given", async () => {
vi.mocked(prisma.medicationInventoryItem.updateMany).mockResolvedValue({
count: 0,
} as never);
await expireStaleInUseItems({ userId: "user-1", nowMs: NOW.getTime() });
expect(prisma.medicationInventoryItem.updateMany).toHaveBeenCalledWith({
where: expect.objectContaining({ userId: "user-1" }),
data: { state: "EXPIRED" },
});
});
it("scopes to a single user when userId is given", async () => {
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([] as never);
it("returns 0 when no rows match", async () => {
vi.mocked(prisma.medicationInventoryItem.updateMany).mockResolvedValue({
count: 0,
} as never);
await expireStaleInUseItems({ userId: "user-1", nowMs: NOW.getTime() });
expect(prisma.medicationInventoryItem.findMany).toHaveBeenCalledWith({
where: expect.objectContaining({ userId: "user-1" }),
select: expect.anything(),
});
const count = await expireStaleInUseItems({ nowMs: NOW.getTime() });
expect(count).toBe(0);
});
});
+13 -24
View File
@@ -9,7 +9,6 @@
import { prisma } from "@/lib/db";
import {
computeExpiresAt,
computeInventoryState,
decrementDose,
type DecrementOutcome,
} from "./state-machine";
@@ -108,35 +107,25 @@ export async function expireStaleInUseItems(input: {
const { userId, nowMs } = input;
const now = new Date(nowMs);
// Candidates: IN_USE rows whose expiresAt has lapsed. EXPIRED rows
// are already terminal; USED_UP rows are off-window by definition.
const candidates = await prisma.medicationInventoryItem.findMany({
// Candidates: IN_USE rows whose `expiresAt` has lapsed. The
// selector + state-machine semantics line up here — every row that
// matches `state = IN_USE AND expiresAt < now` is also a row that
// `computeInventoryState` would classify EXPIRED (clause 2 or 3),
// because IN_USE implies `dosesRemaining > 0` (decrementDose flips
// an empty pen to USED_UP) and the `expiresAt` filter already
// proves the printed-expiry-or-window deadline has lapsed. EXPIRED
// rows are already terminal; USED_UP rows are off-window by
// definition. Use a single `updateMany` instead of one update per
// row — the daily cron sweep ran N round-trips for nothing.
const result = await prisma.medicationInventoryItem.updateMany({
where: {
state: "IN_USE",
expiresAt: { lt: now },
...(userId ? { userId } : {}),
},
select: {
id: true,
state: true,
dosesTotal: true,
dosesRemaining: true,
firstUseAt: true,
printedExpiry: true,
},
data: { state: "EXPIRED" },
});
let transitions = 0;
for (const row of candidates) {
const nextState = computeInventoryState(row, nowMs);
if (nextState === row.state) continue;
await prisma.medicationInventoryItem.update({
where: { id: row.id },
data: { state: nextState },
});
transitions += 1;
}
return transitions;
return result.count;
}
/**