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