fix(medications): stock claims are atomic and only a dose becoming taken consumes

Two hardening passes on the consumption stamp:

- consumeForIntake claims the stamp with a single conditional UPDATE
  (NULL-stamp predicate + row lock) instead of read-then-write, so a
  double tap racing a sync replay decrements exactly once; the loser
  sees count 0. restoreForIntake clears the stamp the same way, so
  concurrent restores cannot refund twice.
- Every seam consumes only on the pending-to-taken transition. A sync
  replay that re-posts an already-taken history row, or an edit that
  corrects the timestamp of an old dose, never touches today's stock.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 18:48:30 +02:00
1 parent e21dfd040e
commit 7eeab2ae98
9 files changed
+345 -90

No files matched your search

@@ -90,6 +90,55 @@ function makeClient(state: FakeState) {
(e) => e.id === args.where.id && e.userId === args.where.userId,
) ?? null,
),
// The hooks' atomic claim — evaluate the conditional WHERE the way
// Postgres would (the gate fields plus the stamp predicate:
// `Prisma.AnyNull` matches a NULL stamp, a JSON value matches by
// structural equality) and apply the data to every matching row.
updateMany: vi.fn(
async (args: {
where: {
id?: string;
userId?: string;
medicationId?: string;
deletedAt?: null;
takenAt?: { not: null };
skipped?: boolean;
inventoryConsumption?: { equals: unknown };
};
data: Record<string, unknown>;
}) => {
const w = args.where;
const matches = state.events.filter((e) => {
if (w.id !== undefined && e.id !== w.id) return false;
if (w.userId !== undefined && e.userId !== w.userId) return false;
if (w.medicationId !== undefined && e.medicationId !== w.medicationId)
return false;
if ("deletedAt" in w && e.deletedAt !== null) return false;
if (w.takenAt !== undefined && e.takenAt === null) return false;
if (w.skipped !== undefined && e.skipped !== w.skipped) return false;
if (w.inventoryConsumption !== undefined) {
const eq = w.inventoryConsumption.equals;
if (eq === Prisma.AnyNull) {
if (e.inventoryConsumption !== null) return false;
} else if (
JSON.stringify(e.inventoryConsumption) !== JSON.stringify(eq)
) {
return false;
}
}
return true;
});
for (const event of matches) {
if ("inventoryConsumption" in args.data) {
event.inventoryConsumption =
args.data.inventoryConsumption === Prisma.DbNull
? null
: args.data.inventoryConsumption;
}
}
return { count: matches.length };
},
),
update: vi.fn(
async (args: {
where: { id: string };
@@ -653,3 +702,66 @@ describe("restoreForIntake", () => {
]);
});
});
describe("claim atomicity (concurrent duplicate calls)", () => {
it("two interleaved consumes decrement exactly once — the claim, not a read, is the gate", async () => {
const state: FakeState = {
unitsPerDose: 1,
events: [takenEvent()],
items: [
item({
id: "open",
state: "IN_USE",
unitsRemaining: 4,
firstUseAt: new Date(NOW.getTime() - MS_PER_DAY),
expiresAt: new Date(NOW.getTime() + 29 * MS_PER_DAY),
}),
],
};
const client = makeClient(state);
// Both calls pass any plain read of the (still NULL) stamp; only the
// conditional claim UPDATE — atomic per statement, like Postgres
// under the row lock — lets exactly one through.
await Promise.all([
consumeForIntake(consumeArgs(client)),
consumeForIntake(consumeArgs(client)),
]);
expect(state.items[0].unitsRemaining).toBe(3);
expect(state.events[0].inventoryConsumption).toEqual([
{ itemId: "open", units: 1 },
]);
});
it("two interleaved restores refund exactly once", async () => {
const state: FakeState = {
unitsPerDose: 1,
events: [
takenEvent({
inventoryConsumption: [{ itemId: "open", units: 1 }],
}),
],
items: [
item({
id: "open",
state: "IN_USE",
unitsRemaining: 3,
firstUseAt: new Date(NOW.getTime() - MS_PER_DAY),
expiresAt: new Date(NOW.getTime() + 29 * MS_PER_DAY),
}),
],
};
const client = makeClient(state);
const args = {
client: client as never,
userId: "user-1",
eventId: "evt-1",
};
await Promise.all([restoreForIntake(args), restoreForIntake(args)]);
expect(state.items[0].unitsRemaining).toBe(4);
expect(state.events[0].inventoryConsumption).toBeNull();
});
});
+48 -34
View File
@@ -135,26 +135,30 @@ export async function consumeForIntake(input: {
const { client, userId, medicationId, eventId, intakeAt } = input;
try {
await runAtomically(client, async (tx) => {
const event = await tx.medicationIntakeEvent.findFirst({
where: { id: eventId, userId },
select: {
id: true,
medicationId: true,
takenAt: true,
skipped: true,
deletedAt: true,
inventoryConsumption: true,
// Atomic claim — gate check and stamp reservation in ONE
// conditional UPDATE. A plain read-then-write gate is not safe
// under READ COMMITTED: two concurrent consume calls for the same
// event (a double tap racing an iOS sync replay) both read the
// NULL stamp and both decrement. The claim takes the event's row
// lock and re-evaluates the predicate after a blocking peer
// commits, so exactly one caller wins; the loser sees count 0 and
// returns. The placeholder `[]` is overwritten with the real
// stamp below inside the same transaction — and rolls back with
// it if the consumption fails, so a failed consume never leaves a
// row stamped.
const claimed = await tx.medicationIntakeEvent.updateMany({
where: {
id: eventId,
userId,
medicationId,
deletedAt: null,
takenAt: { not: null },
skipped: false,
inventoryConsumption: { equals: Prisma.AnyNull },
},
data: { inventoryConsumption: [] },
});
if (
!event ||
event.deletedAt !== null ||
event.takenAt === null ||
event.skipped ||
event.inventoryConsumption !== null
) {
return;
}
if (claimed.count === 0) return;
const medication = await tx.medication.findFirst({
where: { id: medicationId, userId },
@@ -249,7 +253,7 @@ export async function consumeForIntake(input: {
// with no tracked stock must not retro-consume containers
// registered later when a sync replay revisits the row.
await tx.medicationIntakeEvent.update({
where: { id: event.id },
where: { id: eventId },
data: { inventoryConsumption: stamp as unknown as Prisma.InputJsonValue },
});
@@ -302,17 +306,32 @@ export async function restoreForIntake(input: {
inventoryConsumption: true,
},
});
if (!event) return;
const stamp = parseStamp(event.inventoryConsumption);
if (!event || event.inventoryConsumption === null) return;
const stampValue = event.inventoryConsumption;
// Atomic claim — clear the stamp ONLY while it still holds the
// exact value just read (jsonb structural equality). The claim
// takes the event's row lock, so two concurrent restores cannot
// both refund: the loser blocks, re-evaluates against the
// now-NULL stamp, sees count 0 and returns. The refund below then
// applies the captured stamp inside the same transaction; a
// mid-refund failure rolls the clear back with it.
const claimed = await tx.medicationIntakeEvent.updateMany({
where: {
id: eventId,
userId,
inventoryConsumption: {
equals: stampValue as Prisma.InputJsonValue,
},
},
data: { inventoryConsumption: Prisma.DbNull },
});
if (claimed.count === 0) return;
const stamp = parseStamp(stampValue);
if (stamp === null || stamp.length === 0) {
// Clear an empty/malformed stamp so a later re-take can
// consume afresh; a NULL stamp is already clear.
if (event.inventoryConsumption !== null) {
await tx.medicationIntakeEvent.update({
where: { id: event.id },
data: { inventoryConsumption: Prisma.DbNull },
});
}
// Empty / malformed stamp — the claim already cleared it so a
// later re-take can consume afresh; nothing to refund.
return;
}
@@ -351,11 +370,6 @@ export async function restoreForIntake(input: {
itemIds.push(item.id);
}
await tx.medicationIntakeEvent.update({
where: { id: event.id },
data: { inventoryConsumption: Prisma.DbNull },
});
annotate({
action: { name: "medication.inventory.restored" },
meta: {