mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(medications): end every medication and intake read on the row id
Postgres returns rows that tie on an ORDER BY in no particular order, and not the same one twice. The medication reads ordered by columns two rows share all the time: every read that hands schedules on included them with no order at all, medications were listed by createdAt alone (one import writes many in a millisecond), intakes by scheduledFor (a twice-daily medication puts every morning dose on one instant), and "the latest intake" lookups by takenAt. A client read the same account in a different order from one request to the next, and a latest-intake lookup picked either of two rows. Every findMany / findFirst on a medication table, nested includes included, now ends its order on the id in the direction of its last key, and schedules are read by window, then id (MEDICATION_SCHEDULE_ORDER, the order the backup already wrote). medication-read-order-guard.test.ts holds the tree to it. medication-read-order.test.ts writes tying rows in the opposite order to the one each read promises, through the medication list and today's intakes, and fails without the tie-breakers. The parity suite's own state reader had the same gap: it sorts rows by content with the ids blanked, and rows that tie then kept whatever order the unordered read returned, so their <id:N> labels swapped under a loaded run. Its reads are ordered by id, which for a cuid is the order the rows were written in. The JSON import's schema left the route, so its entry in the inline schema strictness register goes; the schema module says why its objects are not strict.
This commit is contained in:
1 parent
7936ab9a57
commit
111047491e
57 files changed
+476
-142
No files matched your search
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Medication rows that tie on their sort key come back in one order.
|
||||
*
|
||||
* Postgres answers rows that tie on an `ORDER BY` in no particular order;
|
||||
* with nothing to break the tie a small table hands them back as they lie in
|
||||
* the heap, which is the order they were written in. Each case below writes
|
||||
* its tying rows in the opposite order to the one the read promises, so a read
|
||||
* that lost its tie-breaker answers the heap order and fails, and does so on
|
||||
* the first run rather than on an unlucky one:
|
||||
*
|
||||
* - two medications created in the same millisecond (an import writes many)
|
||||
* are listed newest first, then by id;
|
||||
* - two schedules of one medication in the same window come back by id;
|
||||
* - two of today's intakes on the same instant (two medications due at
|
||||
* 08:00) come back by id.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { GET as listMedications } from "@/app/api/medications/route";
|
||||
import { GET as listIntakes } from "@/app/api/medications/intake/route";
|
||||
|
||||
import { callRoute } from "../kit/call-route";
|
||||
import { makeSession, makeUser } from "../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const CREATED = new Date("2026-10-01T09:00:00.000Z");
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
describe("medication reads with tying sort keys", () => {
|
||||
it("lists two medications created together newest first, then by id", async () => {
|
||||
const user = await makeUser({ timezone: "Europe/Berlin" });
|
||||
// Written a, then b; the list promises createdAt desc, id desc: b, a.
|
||||
for (const id of ["ord-med-a", "ord-med-b"]) {
|
||||
await prisma.medication.create({
|
||||
data: {
|
||||
id,
|
||||
userId: user.id,
|
||||
name: id,
|
||||
dose: "5 mg",
|
||||
createdAt: CREATED,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Written b, then a, in one window; the list promises window, id: a, b.
|
||||
for (const id of ["ord-sched-b", "ord-sched-a"]) {
|
||||
await prisma.medicationSchedule.create({
|
||||
data: {
|
||||
id,
|
||||
medicationId: "ord-med-a",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const res = await callRoute<
|
||||
Array<{ id: string; schedules: Array<{ id: string }> }>
|
||||
>(listMedications, {
|
||||
method: "GET",
|
||||
route: "/api/medications",
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.map((m) => m.id)).toEqual(["ord-med-b", "ord-med-a"]);
|
||||
const a = res.data.find((m) => m.id === "ord-med-a")!;
|
||||
expect(a.schedules.map((s) => s.id)).toEqual([
|
||||
"ord-sched-a",
|
||||
"ord-sched-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists today's intakes on one instant by id", async () => {
|
||||
const user = await makeUser({ timezone: "UTC" });
|
||||
const now = new Date();
|
||||
const due = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 1),
|
||||
);
|
||||
for (const [medicationId, intakeId] of [
|
||||
["ord-today-med-1", "ord-intake-b"],
|
||||
["ord-today-med-2", "ord-intake-a"],
|
||||
] as const) {
|
||||
await prisma.medication.create({
|
||||
data: {
|
||||
id: medicationId,
|
||||
userId: user.id,
|
||||
name: medicationId,
|
||||
dose: "1",
|
||||
},
|
||||
});
|
||||
// Written b, then a; the read promises scheduledFor asc, id asc: a, b.
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
id: intakeId,
|
||||
userId: user.id,
|
||||
medicationId,
|
||||
scheduledFor: due,
|
||||
takenAt: due,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const res = await callRoute<Array<{ id: string }>>(listIntakes, {
|
||||
method: "GET",
|
||||
route: "/api/medications/intake",
|
||||
query: { scope: "today" },
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data.map((e) => e.id)).toEqual(["ord-intake-a", "ord-intake-b"]);
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,16 @@ function orderKey(value: unknown): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Rows in content order, ids and stamps left out of the comparison. */
|
||||
/**
|
||||
* Rows in content order, ids and stamps left out of the comparison.
|
||||
*
|
||||
* Two rows can share their whole content once the ids are out (a schedule at
|
||||
* 08:00 on each of two medications), and the sort keeps them in the order the
|
||||
* read returned them. So every read below is ordered by id, which for a cuid
|
||||
* is the order the rows were written in: an unordered read handed the ties
|
||||
* back in whatever order the heap held them, and their `<id:N>` labels
|
||||
* swapped under a loaded run.
|
||||
*/
|
||||
export function sortRows<T>(rows: readonly T[]): unknown[] {
|
||||
return rows
|
||||
.map(plain)
|
||||
@@ -112,6 +121,8 @@ export function sortRows<T>(rows: readonly T[]): unknown[] {
|
||||
* guard writes them fire-and-forget, so whether one has landed when the test
|
||||
* reads is a race, not something the route decides.
|
||||
*/
|
||||
const BY_ID = { id: "asc" } as const;
|
||||
|
||||
export async function medicationState(prisma: Client, userIds: string[]) {
|
||||
const byUser = { userId: { in: userIds } };
|
||||
const byMedication = { medication: byUser };
|
||||
@@ -130,19 +141,35 @@ export async function medicationState(prisma: Client, userIds: string[]) {
|
||||
audit,
|
||||
idempotency,
|
||||
] = await Promise.all([
|
||||
prisma.medication.findMany({ where: byUser }),
|
||||
prisma.medicationSchedule.findMany({ where: byMedication }),
|
||||
prisma.medicationScheduleRevision.findMany({ where: byMedication }),
|
||||
prisma.medicationPauseEra.findMany({ where: byMedication }),
|
||||
prisma.medicationCourse.findMany({ where: byMedication }),
|
||||
prisma.medicationIntakeEvent.findMany({ where: byUser }),
|
||||
prisma.medicationDoseChange.findMany({ where: byMedication }),
|
||||
prisma.medicationInventoryItem.findMany({ where: byUser }),
|
||||
prisma.medicationInventoryEvent.findMany({ where: byMedication }),
|
||||
prisma.medicationCategoryAssignment.findMany({ where: byMedication }),
|
||||
prisma.medicationIngredient.findMany({ where: byMedication }),
|
||||
prisma.medication.findMany({ where: byUser, orderBy: BY_ID }),
|
||||
prisma.medicationSchedule.findMany({ where: byMedication, orderBy: BY_ID }),
|
||||
prisma.medicationScheduleRevision.findMany({
|
||||
where: byMedication,
|
||||
orderBy: BY_ID,
|
||||
}),
|
||||
prisma.medicationPauseEra.findMany({ where: byMedication, orderBy: BY_ID }),
|
||||
prisma.medicationCourse.findMany({ where: byMedication, orderBy: BY_ID }),
|
||||
prisma.medicationIntakeEvent.findMany({ where: byUser, orderBy: BY_ID }),
|
||||
prisma.medicationDoseChange.findMany({
|
||||
where: byMedication,
|
||||
orderBy: BY_ID,
|
||||
}),
|
||||
prisma.medicationInventoryItem.findMany({ where: byUser, orderBy: BY_ID }),
|
||||
prisma.medicationInventoryEvent.findMany({
|
||||
where: byMedication,
|
||||
orderBy: BY_ID,
|
||||
}),
|
||||
prisma.medicationCategoryAssignment.findMany({
|
||||
where: byMedication,
|
||||
orderBy: { medicationId: "asc" },
|
||||
}),
|
||||
prisma.medicationIngredient.findMany({
|
||||
where: byMedication,
|
||||
orderBy: BY_ID,
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: { ...byUser, action: { notIn: [...GUARD_TRAIL_ACTIONS] } },
|
||||
orderBy: BY_ID,
|
||||
select: {
|
||||
action: true,
|
||||
userId: true,
|
||||
@@ -153,6 +180,7 @@ export async function medicationState(prisma: Client, userIds: string[]) {
|
||||
}),
|
||||
prisma.idempotencyKey.findMany({
|
||||
where: byUser,
|
||||
orderBy: BY_ID,
|
||||
select: {
|
||||
userId: true,
|
||||
key: true,
|
||||
|
||||
Reference in new issue
Block a user