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:
Marc-André Bombeck committed 2026-10-23 21:54:25 +02:00
1 parent 7936ab9a57
commit 111047491e
57 files changed
+476 -142

No files matched your search

@@ -190,7 +190,6 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
// not read would make a backup unrestorable on the version that has
// to restore it, which is the one job a backup has.
// ---------------------------------------------------------------
"import/route.ts": { count: 3, reason: "foreign archive" },
"medications/[id]/intake/import/route.ts": {
count: 1,
reason: "foreign archive",
@@ -11,6 +11,7 @@ import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
export const dynamic = "force-dynamic";
@@ -109,7 +110,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(scopedUserId ? { userId: scopedUserId } : {}),
},
include: {
schedules: true,
schedules: { orderBy: MEDICATION_SCHEDULE_ORDER },
user: { select: { id: true, username: true, timezone: true } },
},
});
+2 -2
View File
@@ -76,7 +76,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
},
},
orderBy: { createdAt: "desc" },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}),
resolveUserTimezone(user.id),
]);
@@ -114,7 +114,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const events = await prisma.medicationIntakeEvent.findMany({
where,
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
});
intakeCount = events.length;
sections.push("# Intake history");
+4 -3
View File
@@ -19,6 +19,7 @@ import {
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
/**
* Export user data.
@@ -122,8 +123,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "medications" || type === "all") {
const medications = await prisma.medication.findMany({
where: { userId },
include: { schedules: true },
orderBy: { createdAt: "desc" },
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
data.medications =
format === "csv"
@@ -135,7 +136,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
});
data.intakeEvents =
format === "csv"
+2 -2
View File
@@ -116,7 +116,7 @@ export const GET = apiHandler(async () => {
// per-dose windows reach the engine like every other surface.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
scheduleRevisions: { orderBy: [{ validFrom: "asc" }, { id: "asc" }] },
// v1.25 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
@@ -207,7 +207,7 @@ export const GET = apiHandler(async () => {
// intakes outside the 30-day rate window.
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
select: {
medicationId: true,
takenAt: true,
+6 -3
View File
@@ -48,10 +48,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const meds = await prisma.medication.findMany({
where: { userId: user.id, treatmentClass: "GLP1" },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
doseChanges: { orderBy: [{ effectiveFrom: "asc" }, { id: "asc" }] },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: 50,
},
// v1.16.10 divergence note: the stock READOUTS (glp1 details
@@ -63,7 +63,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// only grows through the legacy `POST /api/medications/[id]/glp1`
// inventory branch, so timelines on item-tracked medications show
// no new inventory entries.
inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 },
inventoryEvents: {
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: 20,
},
},
});
@@ -633,7 +633,7 @@ describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
expect(res.status).toBe(200);
expect(prisma.medicationSchedule.findFirst).toHaveBeenCalledWith({
where: { medicationId: "m1" },
orderBy: { windowStart: "asc" },
orderBy: [{ windowStart: "asc" }, { id: "asc" }],
select: { id: true },
});
expect(prisma.medicationSchedule.update).toHaveBeenCalledWith({
@@ -108,7 +108,7 @@ export const GET = apiHandler(
// chips agree with the % + the history on a "zugeordnet" dose.
attributionSource: true,
},
orderBy: { scheduledFor: "asc" },
orderBy: [{ scheduledFor: "asc" }, { id: "asc" }],
});
// The cadence-anchor is the medication's createdAt for stable
@@ -166,7 +166,7 @@ export const GET = apiHandler(
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id, deletedAt: null },
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
select: {
id: true,
scheduledFor: true,
@@ -50,7 +50,12 @@ export const GET = apiHandler(
const [items, medication] = await Promise.all([
prisma.medicationInventoryItem.findMany({
where: { userId: user.id, medicationId: id },
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
orderBy: [
{ state: "asc" },
{ expiresAt: "asc" },
{ createdAt: "asc" },
{ id: "asc" },
],
}),
prisma.medication.findUnique({
where: { id },
@@ -86,7 +86,7 @@ export const GET = apiHandler(
// Superseded rows are audit records behind a correction — the
// timeline shows the corrected state, like every era consumer.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validFrom: "desc" },
orderBy: [{ validFrom: "desc" }, { id: "desc" }],
select: {
id: true,
validFrom: true,
+4 -3
View File
@@ -21,6 +21,7 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
/**
* Batched card-compliance read: one round trip for every medication the
@@ -82,15 +83,15 @@ export const GET = apiHandler(async (request: Request) => {
// of rates, and an empty expected set must not read as 0% or 100%.
where: { userId: user.id, asNeeded: false, ...TRACKED_INTAKE_WHERE },
include: {
schedules: true,
schedules: { orderBy: MEDICATION_SCHEDULE_ORDER },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
scheduleRevisions: { orderBy: [{ validFrom: "asc" }, { id: "asc" }] },
// v1.25 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
},
orderBy: { createdAt: "desc" },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
// Bounded fan-out: each cold cell costs one bounded intake read + one
+1 -1
View File
@@ -83,7 +83,7 @@ export const GET = apiHandler(async (_request: NextRequest) => {
}),
prisma.medicationIntakeEvent.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
select: { updatedAt: true },
}),
prisma.medicationIntakeEvent.count({