From 111047491e318cc6befd992ebfbbfcfd58b22bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 21:54:25 +0200 Subject: [PATCH] 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 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. --- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 8 +- .../baselines/version-stamp-comments.json | 8 +- .../medication-read-order-guard.test.ts | 139 ++++++++++++++++++ ...inline-schema-strictness-inventory.test.ts | 1 - .../notifications/reminder-check/route.ts | 3 +- src/app/api/export/medications/route.ts | 4 +- src/app/api/export/route.ts | 7 +- src/app/api/insights/cards/route.ts | 4 +- src/app/api/insights/glp1-timeline/route.ts | 9 +- .../medications/[id]/__tests__/route.test.ts | 2 +- src/app/api/medications/[id]/cadence/route.ts | 2 +- .../medications/[id]/dose-history/route.ts | 2 +- .../api/medications/[id]/inventory/route.ts | 7 +- .../[id]/schedule-revisions/route.ts | 2 +- src/app/api/medications/compliance/route.ts | 7 +- src/app/api/sync/state/route.ts | 2 +- src/lib/ai/coach/glp1-snapshot.ts | 11 +- src/lib/ai/coach/snapshot.ts | 4 +- .../analytics/schedule-anchored-compliance.ts | 2 +- src/lib/dashboard/meds-today.ts | 5 +- src/lib/day/records.ts | 4 +- src/lib/doctor-report/collect.ts | 8 +- src/lib/import/json/schema.ts | 5 + src/lib/insights/blood-pressure-status.ts | 4 +- .../medication-compliance.ts | 4 +- .../insights/correlation-channel-series.ts | 6 +- src/lib/insights/features.ts | 4 +- src/lib/insights/general-status.ts | 2 +- src/lib/insights/glp1-plateau.ts | 5 +- .../insights/medication-compliance-status.ts | 6 +- src/lib/jobs/intake-auto-take.ts | 4 +- .../reminder/medication-reminder-check.ts | 16 +- src/lib/mcp/resources.ts | 11 +- src/lib/mcp/tools/get-medication-schedule.ts | 5 +- src/lib/medications/compliance-payload.ts | 2 +- src/lib/medications/courses.ts | 6 +- src/lib/medications/create-medication.ts | 5 +- .../medications/efficacy/build-efficacy.ts | 2 +- .../efficacy/load-segmented-effects.ts | 2 +- src/lib/medications/intake-slot-dedup.ts | 22 +-- src/lib/medications/intake-today.ts | 2 +- src/lib/medications/inventory/consumption.ts | 4 + src/lib/medications/list-read.ts | 30 ++-- src/lib/medications/read-medication.ts | 4 +- src/lib/medications/read-order.ts | 22 +++ src/lib/medications/record-intake/write.ts | 2 +- .../scheduling/project-today-intakes.ts | 2 +- src/lib/medications/scheduling/slot-upsert.ts | 6 +- .../update-medication/preflight.ts | 2 +- .../medications/update-medication/write.ts | 2 +- src/lib/targets/build-response.ts | 6 +- src/lib/telegram-webhook-handlers.ts | 4 +- src/lib/timeline/lanes.ts | 2 +- src/lib/timeline/readiness.ts | 2 +- .../integration/medication-read-order.test.ts | 120 +++++++++++++++ .../routes/parity/medication-state.ts | 52 +++++-- 57 files changed, 476 insertions(+), 142 deletions(-) create mode 100644 src/__tests__/medication-read-order-guard.test.ts create mode 100644 src/lib/medications/read-order.ts create mode 100644 tests/integration/medication-read-order.test.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1f4e342a1..936e877ea 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 89, - "total": 99600, + "total": 99598, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -72,7 +72,7 @@ "lib/jobs/medication-intake-import.ts": 819, "lib/jobs/offhost-backup.ts": 1147, "lib/jobs/reaction-line.ts": 824, - "lib/jobs/reminder/medication-reminder-check.ts": 818, + "lib/jobs/reminder/medication-reminder-check.ts": 816, "lib/managed-profiles/handover.ts": 986, "lib/mcp/record-search.ts": 1467, "lib/mcp/rich-reads.ts": 1467, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 965c9159f..b568f3ee6 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 354, - "total": 114417, + "total": 114410, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -319,8 +319,8 @@ "lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 286, "lib/jobs/oura-backfill.ts :: runOuraBackfillForUser": 236, "lib/jobs/reaction-line.ts :: runReactionLine": 337, - "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 619, - "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 614, + "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 616, + "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 611, "lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324, "lib/labs/biomarker-merge.ts :: runMerge": 254, "lib/labs/ocr-extract.ts :: runOcrExtraction": 207, @@ -337,7 +337,7 @@ "lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323, "lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268, "lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321, - "lib/medications/list-read.ts :: buildMedicationsList": 310, + "lib/medications/list-read.ts :: buildMedicationsList": 309, "lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218, "lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272, "lib/notifications/dispatcher.ts :: dispatchNotification": 378, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index de6f472fb..33e262ea2 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2897, - "total": 9290, + "total": 9285, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2357,7 +2357,7 @@ "lib/jobs/reminder/google-health-sync.ts": 1, "lib/jobs/reminder/insights-handlers.ts": 3, "lib/jobs/reminder/medication-maintenance.ts": 3, - "lib/jobs/reminder/medication-reminder-check.ts": 13, + "lib/jobs/reminder/medication-reminder-check.ts": 12, "lib/jobs/reminder/mood-cycle-checks.ts": 3, "lib/jobs/reminder/ops-handlers.ts": 3, "lib/jobs/reminder/oura-sync.ts": 1, @@ -2506,7 +2506,7 @@ "lib/medications/injection-site-write.ts": 1, "lib/medications/injection-sites.ts": 6, "lib/medications/intake-actionable.ts": 1, - "lib/medications/intake-slot-dedup.ts": 18, + "lib/medications/intake-slot-dedup.ts": 17, "lib/medications/intake-tracking.ts": 1, "lib/medications/inventory/__tests__/consumption.test.ts": 1, "lib/medications/inventory/__tests__/summary.test.ts": 2, @@ -2515,7 +2515,7 @@ "lib/medications/inventory/state-machine.ts": 2, "lib/medications/inventory/summary.ts": 4, "lib/medications/lifecycle.ts": 3, - "lib/medications/list-read.ts": 26, + "lib/medications/list-read.ts": 23, "lib/medications/med-target-map.ts": 1, "lib/medications/medication-order.ts": 1, "lib/medications/outstanding-doses.ts": 2, diff --git a/src/__tests__/medication-read-order-guard.test.ts b/src/__tests__/medication-read-order-guard.test.ts new file mode 100644 index 000000000..32bd34331 --- /dev/null +++ b/src/__tests__/medication-read-order-guard.test.ts @@ -0,0 +1,139 @@ +/** + * Every medication and intake read orders its ties. + * + * 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: a schedule list with no order at all, medications by + * `createdAt` (one import writes many in a millisecond), intakes by + * `scheduledFor` (a twice-daily medication puts every morning dose on the same + * instant), "the latest intake" by `takenAt`. A client then read the same + * account in a different order from one request to the next, and a "latest" + * lookup picked either of two rows. The parity suite saw it as `` + * labels swapping under a loaded run. + * + * So every `findMany` / `findFirst` on a medication table, its nested + * includes too, states an order that ends on a unique key, and a medication's + * schedules are never included unordered (`MEDICATION_SCHEDULE_ORDER`). An + * order held in a variable is checked where it is built, and listed below. + */ +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + readSourceText, + requireFloor, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); + +const READ = + /\b(?:prisma|tx|db|client)\s*\.\s*(medication|medicationSchedule|medicationIntakeEvent|medicationScheduleRevision|medicationPauseEra|medicationCourse|medicationDoseChange|medicationInventoryItem|medicationInventoryEvent|medicationSideEffect|medicationIngredient|medicationCategoryAssignment)\s*\.\s*(findMany|findFirst|findFirstOrThrow)\s*\(/g; + +/** Unique keys an order may end on, beyond `id`. */ +const UNIQUE_LAST_KEYS: Record = { + medicationCategoryAssignment: "medicationId", +}; + +/** Orders built in a variable, by file, with where the tie-breaker lives. */ +const ORDER_VARIABLES: Record = { + "lib/medications/list-intake-events.ts": + "`orderBy` ends on `{ id: sortDir }` in both arms, built just above the read.", +}; + +function closing(text: string, at: number, open: string, close: string) { + let depth = 0; + for (let i = at; i < text.length; i++) { + if (text[i] === open) depth++; + else if (text[i] === close && --depth === 0) return i; + } + return -1; +} + +interface Read { + where: string; + model: string; + orders: string[]; + variableOrder: boolean; + unorderedSchedules: boolean; +} + +function reads(): Read[] { + const out: Read[] = []; + const files = walkSourceFiles(SRC, { floor: 2000 }).filter( + (rel) => !rel.startsWith("generated/") && !rel.includes("__tests__"), + ); + for (const rel of files) { + const text = stripComments(readSourceText(join(SRC, rel)), rel); + for (const match of text.matchAll(READ)) { + const open = match.index! + match[0].length - 1; + const end = closing(text, open, "(", ")"); + const call = text.slice(open, end + 1); + const orders: string[] = []; + for (const order of call.matchAll(/orderBy\s*:\s*([[{])/g)) { + const start = order.index! + order[0].length - 1; + const bracket = order[1]!; + const stop = closing(call, start, bracket, bracket === "[" ? "]" : "}"); + orders.push(call.slice(start, stop + 1).replace(/\s+/g, " ")); + } + out.push({ + where: `${rel}:${text.slice(0, match.index).split("\n").length}`, + model: match[1]!, + orders, + variableOrder: /\borderBy\s*(?:,|\})/.test(call), + unorderedSchedules: /\bschedules\s*:\s*true\b/.test(call), + }); + } + } + return out; +} + +const ALL = reads(); + +/** Whether an order's last key is unique for the read's model. */ +function endsUnique(order: string, model: string): boolean { + const keys = [...order.matchAll(/(\w+)\s*:\s*(?:"(?:asc|desc)"|\{|\w)/g)] + .map((m) => m[1]!) + .filter((key) => key !== "sort" && key !== "nulls"); + const last = keys.at(-1); + return last === "id" || last === UNIQUE_LAST_KEYS[model]; +} + +describe("medication reads order their ties", () => { + it("finds the reads it is about", () => { + requireFloor("medication reads", ALL, 150); + requireFloor( + "ordered medication reads", + ALL.filter((read) => read.orders.length > 0), + 60, + ); + }); + + it("ends every stated order on a unique key", () => { + const loose = ALL.flatMap((read) => + read.orders + .filter((order) => !endsUnique(order, read.model)) + .map((order) => `${read.where} ${read.model}: ${order}`), + ); + expect(loose).toEqual([]); + }); + + it("never includes a medication's schedules unordered", () => { + expect( + ALL.filter((read) => read.unorderedSchedules).map((read) => read.where), + ).toEqual([]); + }); + + it("names every order held in a variable, and nothing else", () => { + const files = [ + ...new Set( + ALL.filter((read) => read.variableOrder).map( + (read) => read.where.split(":")[0]!, + ), + ), + ].sort(); + expect(files).toEqual(Object.keys(ORDER_VARIABLES).sort()); + }); +}); diff --git a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts index 80a5dc67f..8fc5f168f 100644 --- a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts +++ b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts @@ -190,7 +190,6 @@ const DELIBERATELY_OPEN: Record = { // 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", diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index f161051d4..736677301 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -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 } }, }, }); diff --git a/src/app/api/export/medications/route.ts b/src/app/api/export/medications/route.ts index 8ec77cd18..05c0dd6ca 100644 --- a/src/app/api/export/medications/route.ts +++ b/src/app/api/export/medications/route.ts @@ -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"); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 4020fc835..f3c6c89ec 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -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" diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 7d67ae7c4..04779c3ba 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -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, diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index 580e8c104..3419a917a 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -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, + }, }, }); diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index a1791b827..39b43f603 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -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({ diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 652623365..a54907ec5 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -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 diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index acb1e411b..61b323b4d 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -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, diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 79836f72e..370845fed 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -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 }, diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 18be90e7a..9d5b3c7f8 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -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, diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index e2698eda4..04d578e7b 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -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 diff --git a/src/app/api/sync/state/route.ts b/src/app/api/sync/state/route.ts index 4d4083640..713f52e74 100644 --- a/src/app/api/sync/state/route.ts +++ b/src/app/api/sync/state/route.ts @@ -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({ diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 0953708f4..6028f7644 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -38,6 +38,7 @@ import { userDayKey, weekdayOfDateKey, } from "@/lib/tz/format"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -246,18 +247,18 @@ export async function buildGlp1SnapshotBlock( active: true, }, include: { - schedules: true, - doseChanges: { orderBy: { effectiveFrom: "asc" } }, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, + doseChanges: { orderBy: [{ effectiveFrom: "asc" }, { id: "asc" }] }, // v1.16.10 — pen inventory reads the per-item entities (the rows // the intake consumption hook moves). The legacy event ledger // stays as a READ fallback for ledger-only accounts: when a // medication has zero items, the running-sum numbers surface so // the Coach still knows the pen count. - inventoryItems: { orderBy: { createdAt: "asc" } }, - inventoryEvents: { orderBy: { occurredAt: "asc" } }, + inventoryItems: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] }, + inventoryEvents: { orderBy: [{ occurredAt: "asc" }, { id: "asc" }] }, intakeEvents: { where: { takenAt: floor ? { not: null, gte: floor } : { not: null } }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], take: 1, select: { takenAt: true, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 57ca5fd1a..2bc7a4abc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -517,7 +517,7 @@ async function buildCoachSnapshotImpl( createdAt: true, schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, scheduleRevisions: { - orderBy: { validFrom: "asc" }, + orderBy: [{ validFrom: "asc" }, { id: "asc" }], select: { id: true, validFrom: true, @@ -533,7 +533,7 @@ async function buildCoachSnapshotImpl( intakeEvents: { // Tombstoned intake rows must never reach the Coach snapshot. where: { deletedAt: null, scheduledFor: { gte: cutoff } }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], select: { scheduledFor: true, takenAt: true, diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts index bec832e63..afa4bf568 100644 --- a/src/lib/analytics/schedule-anchored-compliance.ts +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -68,7 +68,7 @@ export async function buildScheduleAnchoredComplianceBuckets( // reaches this surface the moment it joins SCHEDULE_COMPLIANCE_SELECT. 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. diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 24382dfe5..f8b9d54af 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -49,6 +49,7 @@ import { TRACKED_INTAKE_WHERE, } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; export interface MedsTodayDueCandidate { medicationId: string; @@ -138,8 +139,8 @@ export async function buildMedsTodayBlock( // place on the doses card: not counted, never the next due dose. prisma.medication.findMany({ where: { userId, active: true, ...TRACKED_INTAKE_WHERE }, - include: { schedules: true }, - orderBy: { createdAt: "desc" }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }), prisma.medicationIntakeEvent.findMany({ where: { diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 35138b89d..31be1a0bb 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -202,7 +202,7 @@ async function medicationsPart(frame: DayFrame): Promise { doseTaken: true, medication: { select: { name: true, dose: true } }, }, - orderBy: { takenAt: "asc" }, + orderBy: [{ takenAt: "asc" }, { id: "asc" }], }), prisma.medicationDoseChange.findMany({ where: { @@ -229,7 +229,7 @@ async function medicationsPart(frame: DayFrame): Promise { doseValue: true, doseUnit: true, }, - orderBy: { effectiveFrom: "desc" }, + orderBy: [{ effectiveFrom: "desc" }, { id: "desc" }], distinct: ["medicationId"], }), ]); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 541dcd403..eb19713ef 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -322,7 +322,7 @@ export async function collectDoctorReportData( select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true }, }, scheduleRevisions: { - orderBy: { validFrom: "asc" }, + orderBy: [{ validFrom: "asc" }, { id: "asc" }], select: { id: true, validFrom: true, @@ -336,11 +336,11 @@ export async function collectDoctorReportData( courses: COURSES_COMPLIANCE_SELECT, doseChanges: { where: { effectiveFrom: { lte: end } }, - orderBy: { effectiveFrom: "asc" }, + orderBy: [{ effectiveFrom: "asc" }, { id: "asc" }], }, intakeEvents: { where: { takenAt: { not: null, lte: end } }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], take: 1, select: { takenAt: true, injectionSite: true }, }, @@ -367,7 +367,7 @@ export async function collectDoctorReportData( }, }, }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], }) : Promise.resolve([]), // Zero DB read when the mood leaf was not chosen: the payload reflects diff --git a/src/lib/import/json/schema.ts b/src/lib/import/json/schema.ts index aea967780..7b33bdd1a 100644 --- a/src/lib/import/json/schema.ts +++ b/src/lib/import/json/schema.ts @@ -10,6 +10,11 @@ * * Lives here rather than in the route because the route module may export * nothing but its handlers, and the guard needs the field set. + * + * The objects are deliberately not strict. A file is a foreign archive: the + * app's own files carry fields the import leaves out on purpose (row ids, the + * source, sync bookkeeping), and a strict object would refuse every one of + * them. An unknown key is stripped, never stored. */ import { z } from "zod/v4"; diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index f5e5d80bd..566e9cc3f 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -480,7 +480,7 @@ async function readBpMedicationContext( include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // 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. @@ -521,7 +521,7 @@ async function readBpMedicationContext( in: bpMedications.map((medication) => medication.id), }, }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], select: { medicationId: true, scheduledFor: true, diff --git a/src/lib/insights/comprehensive-overview/medication-compliance.ts b/src/lib/insights/comprehensive-overview/medication-compliance.ts index 43a4567ab..3382a6069 100644 --- a/src/lib/insights/comprehensive-overview/medication-compliance.ts +++ b/src/lib/insights/comprehensive-overview/medication-compliance.ts @@ -36,7 +36,7 @@ async function readScheduledMedications(userId: string) { // per-dose windows reach this surface like every other. schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // Archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, + scheduleRevisions: { orderBy: [{ validFrom: "asc" }, { id: "asc" }] }, // Pause eras so paused days drop out of the denominator. pauseEras: { select: { pausedAt: true, resumedAt: true } }, // The courses, so a gap between two expects nothing (#1024). @@ -85,7 +85,7 @@ export async function readMedicationCompliance(args: { deletedAt: null, scheduledFor: { gte: since }, }, - orderBy: { scheduledFor: "desc" }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], }) : []; diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 25f971c18..5626f2b5d 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -115,13 +115,13 @@ export async function fetchComplianceSeries( where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - 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: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], }); if (medications.length === 0) { return { @@ -138,7 +138,7 @@ export async function fetchComplianceSeries( medicationId: { in: medications.map((med) => med.id) }, scheduledFor: { gte: since }, }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], select: { medicationId: true, scheduledFor: true, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 9d5bbc908..2ac921ee6 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1667,7 +1667,7 @@ export async function extractFeatures( include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // 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. @@ -1696,7 +1696,7 @@ export async function extractFeatures( medicationId: { in: medications.map((med) => med.id) }, scheduledFor: { gte: ninetyDaysAgo }, }, - orderBy: { scheduledFor: "desc" }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], select: { medicationId: true, takenAt: true, diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 9612b8975..2857bcb24 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -248,7 +248,7 @@ async function readAdherenceSeries(userId: string, now: Date, userTz: string) { // v1.39.1 (#1033) — adherence counts tracked medications only. ...TRACKED_INTAKE_EVENT_WHERE, }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], select: { scheduledFor: true, takenAt: true, diff --git a/src/lib/insights/glp1-plateau.ts b/src/lib/insights/glp1-plateau.ts index 6f7d11bfd..6e1307e99 100644 --- a/src/lib/insights/glp1-plateau.ts +++ b/src/lib/insights/glp1-plateau.ts @@ -63,7 +63,10 @@ export async function detectGlp1Plateau( const meds = await prisma.medication.findMany({ where: { userId, treatmentClass: "GLP1", active: true }, include: { - doseChanges: { orderBy: { effectiveFrom: "desc" }, take: 1 }, + doseChanges: { + orderBy: [{ effectiveFrom: "desc" }, { id: "desc" }], + take: 1, + }, }, }); if (meds.length === 0) return null; diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index a71719ec9..f2d65ca92 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -286,7 +286,7 @@ async function buildMedicationComplianceSnapshot( medicationId: { in: medications.map((medication) => medication.id) }, scheduledFor: { gte: rangeStart }, }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], select: { medicationId: true, scheduledFor: true, @@ -394,13 +394,13 @@ async function readComplianceMedications(userId: string) { include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // 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: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], }); // A medication with no schedule expects no dose, and `calculateCompliance` diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 16265f1d2..2b7c7cc9f 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -269,7 +269,7 @@ export async function runIntakeAutoTakePass( autoMarkTakenSince: true, schedules: { select: SCHEDULE_SELECT }, scheduleRevisions: { - orderBy: { validFrom: "asc" }, + orderBy: [{ validFrom: "asc" }, { id: "asc" }], select: { id: true, validFrom: true, @@ -423,7 +423,7 @@ async function autoTakeMedication( deletedAt: null, takenAt: { not: null }, }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], select: { takenAt: true }, }); lastIntakeAt = last?.takenAt ?? null; diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index efe367658..eb9596aae 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -46,6 +46,7 @@ import { liveEraStart as liveEraStartOf, } from "@/lib/medications/scheduling/live-era"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; export interface ReminderCheckPayload { triggeredAt: string; @@ -227,7 +228,7 @@ export async function handleReminderCheck( const medications = await prisma.medication.findMany({ where: { active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, include: { - schedules: true, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, phaseConfig: true, scheduleRevisions: LIVE_ERA_REVISION_ARGS, user: { @@ -340,13 +341,10 @@ export async function handleReminderCheck( a.windowStart.localeCompare(b.windowStart), ); - // v1.5.0 — fetch the latest `takenAt` for this medication - // once per tick when any schedule on it is rolling. The - // canonical recurrence engine needs `lastIntakeAt` to compute - // the next-due instant for rolling cadences; calendar - // cadences ignore the field, so the fetch is conditional to - // avoid an extra round-trip on the common path. Failures bias - // toward "treat as never logged" so a flaky DB doesn't + // The latest `takenAt` for this medication, once per tick and only + // when a schedule on it is rolling: the recurrence engine needs + // `lastIntakeAt` for a rolling cadence, a calendar one ignores it. + // Failures read as "never logged", so a flaky DB does not // suppress reminders. const hasRollingSchedule = med.schedules.some( (s) => s.rollingIntervalDays !== null, @@ -362,7 +360,7 @@ export async function handleReminderCheck( deletedAt: null, takenAt: { not: null }, }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], select: { takenAt: true }, }); lastIntakeAt = lastIntake?.takenAt ?? null; diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index 483cbffa7..4092b6e39 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -45,6 +45,7 @@ import { getNutrients, NUTRIENT_LABELS } from "@/lib/mcp/nutrients-read"; import { metricStatusDiscoveryRows } from "@/lib/mcp/rich-reads"; import { summariseForVisit } from "./doctor-visit-summary"; import type { McpAuthContext } from "./auth"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; export interface McpResourceDefinition { name: string; @@ -195,13 +196,13 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ const medications = await prisma.medication.findMany({ where: { userId: ctx.userId }, include: { - schedules: true, + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, courses: { select: { startsOn: true, endsOn: true }, - orderBy: { startsOn: "asc" }, + orderBy: [{ startsOn: "asc" }, { id: "asc" }], }, }, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); annotate({ action: { name: "mcp.resource.read" }, @@ -567,7 +568,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [ const meds = await prisma.medication.findMany({ where: { userId: ctx.userId }, select: { id: true, name: true, dose: true }, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: 200, }); return meds.map((m) => ({ @@ -585,7 +586,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [ // never another tenant's row. const med = await prisma.medication.findFirst({ where: { id, userId: ctx.userId }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); annotate({ action: { name: "mcp.resource.read" }, diff --git a/src/lib/mcp/tools/get-medication-schedule.ts b/src/lib/mcp/tools/get-medication-schedule.ts index 0b3f06ba9..c69a3a525 100644 --- a/src/lib/mcp/tools/get-medication-schedule.ts +++ b/src/lib/mcp/tools/get-medication-schedule.ts @@ -15,6 +15,7 @@ import { dueSchedules } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; import { MODULE_DISABLED } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; // ── Operational reads (not Coach snapshot domains) ────────────────── // These answer "what should I do / why is my data stale" rather than "what @@ -69,8 +70,8 @@ export const getMedicationScheduleTool: McpToolDefinition = { }), prisma.medication.findMany({ where: { userId: ctx.userId, active: true }, - include: { schedules: true }, - orderBy: { createdAt: "desc" }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }), ]); diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 4d30bc45f..992e7825b 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -248,7 +248,7 @@ export async function buildCompliancePayload( deletedAt: null, scheduledFor: { gte: fetchFrom }, }, - orderBy: { scheduledFor: "desc" }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], select: { takenAt: true, skipped: true, diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 760b187f9..da6766994 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -125,7 +125,7 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) { if (!medication) throw new CourseWriteError("notFound"); const courses = await tx.medicationCourse.findMany({ where: { medicationId }, - orderBy: { startsOn: "asc" }, + orderBy: [{ startsOn: "asc" }, { id: "asc" }], select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, }); return { medication, courses }; @@ -423,7 +423,7 @@ export async function checkCurrentWindow( const todayKey = userDayKey(args.now ?? new Date(), args.timeZone); const courses = await prisma.medicationCourse.findMany({ where: { medicationId: args.medicationId, userId: args.userId }, - orderBy: { startsOn: "asc" }, + orderBy: [{ startsOn: "asc" }, { id: "asc" }], select: { id: true, startsOn: true, endsOn: true }, }); const latest = courses.at(-1); @@ -472,7 +472,7 @@ export async function loadCoursesFor( if (medicationIds.length === 0) return byMedication; const rows = await prisma.medicationCourse.findMany({ where: { medicationId: { in: medicationIds } }, - orderBy: { startsOn: "asc" }, + orderBy: [{ startsOn: "asc" }, { id: "asc" }], select: { id: true, medicationId: true, diff --git a/src/lib/medications/create-medication.ts b/src/lib/medications/create-medication.ts index 11162bfe6..609fcc1b3 100644 --- a/src/lib/medications/create-medication.ts +++ b/src/lib/medications/create-medication.ts @@ -31,6 +31,7 @@ import { medicationDetail, type ReadUser, } from "@/lib/medications/read-medication"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; export type CreateMedicationInput = z.output; type ScheduleInput = NonNullable[number]; @@ -103,7 +104,7 @@ async function existingMirrorOrCap( const userId = user.id; const existing = await prisma.medication.findFirst({ where: { userId, externalSource, externalId }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); if (existing) { return existingMirrorBody(user, existing); @@ -480,7 +481,7 @@ export async function createMedication(input: { if (isP2002 && externalSource !== undefined && externalId !== undefined) { const winner = await prisma.medication.findFirst({ where: { userId: user.id, externalSource, externalId }, - include: { schedules: true }, + include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } }, }); if (winner) return existingMirrorBody(user, winner); } diff --git a/src/lib/medications/efficacy/build-efficacy.ts b/src/lib/medications/efficacy/build-efficacy.ts index a1dde1418..9ec5e2065 100644 --- a/src/lib/medications/efficacy/build-efficacy.ts +++ b/src/lib/medications/efficacy/build-efficacy.ts @@ -605,7 +605,7 @@ export async function buildMedicationEfficacy( deletedAt: null, scheduledFor: { gte: fetchFrom }, }, - orderBy: { scheduledFor: "desc" }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], select: { takenAt: true, skipped: true, diff --git a/src/lib/medications/efficacy/load-segmented-effects.ts b/src/lib/medications/efficacy/load-segmented-effects.ts index 91923c93a..20cb7783b 100644 --- a/src/lib/medications/efficacy/load-segmented-effects.ts +++ b/src/lib/medications/efficacy/load-segmented-effects.ts @@ -176,7 +176,7 @@ export async function loadSegmentedEffects(params: { deletedAt: null, scheduledFor: { gte: since, lte: until }, }, - orderBy: { scheduledFor: "desc" }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], select: { takenAt: true, skipped: true, diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 2644489f6..96d1903b8 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -226,7 +226,7 @@ export async function dedupeUserIntakeSlots( // v1.16.9 — archived eras: an old ad-hoc row converges against the // schedule that was live at its takenAt, not today's times. scheduleRevisions: { - orderBy: { validFrom: "asc" }, + orderBy: [{ validFrom: "asc" }, { id: "asc" }], select: { id: true, validFrom: true, @@ -242,15 +242,11 @@ export async function dedupeUserIntakeSlots( // recompute after the collapse. Deduped so a busy day folds once. const daysToRecompute = new Set(); - // v1.20.2 — batch the per-medication reads. The pre-v1.20.2 shape - // issued a `findFirst` (latest takenAt) + a `findMany` (all live rows) - // PER medication → 2N round-trips (20 meds = 40). Pull every live row - // for the user's medication set in ONE ordered `findMany`, group in - // memory by `medicationId`, and derive the per-med `lastIntakeAt` (max - // non-null takenAt) from the same grouped set the `findFirst` would - // have scanned. The grouping preserves the `scheduledFor asc` order the - // snap + heal passes depend on (a stable Postgres sort keeps the - // per-medication slices in the same order a per-med query returned). + // One ordered read for the user's whole medication set, grouped in memory + // by `medicationId`, rather than a `findFirst` and a `findMany` per + // medication (2N round-trips). The per-med `lastIntakeAt` comes from the + // same rows. The order ends on the id, so each slice holds its rows in the + // slot order the snap and heal passes need, the same on every run. const medIds = medications .filter((m) => m.schedules.length > 0) .map((m) => m.id); @@ -277,7 +273,11 @@ export async function dedupeUserIntakeSlots( }, // `medicationId` first so the per-med slices are contiguous, then the // `scheduledFor asc` the loop relies on. - orderBy: [{ medicationId: "asc" }, { scheduledFor: "asc" }], + orderBy: [ + { medicationId: "asc" }, + { scheduledFor: "asc" }, + { id: "asc" }, + ], })) as DedupIntakeRow[]; for (const row of allRows) { diff --git a/src/lib/medications/intake-today.ts b/src/lib/medications/intake-today.ts index 9e6459e2a..fe242840e 100644 --- a/src/lib/medications/intake-today.ts +++ b/src/lib/medications/intake-today.ts @@ -43,7 +43,7 @@ export async function readTodayIntakes(input: { // medication with intake tracking off is not on it. ...TRACKED_INTAKE_EVENT_WHERE, }, - orderBy: { scheduledFor: "asc" }, + orderBy: [{ scheduledFor: "asc" }, { id: "asc" }], include: { medication: { select: { id: true, snoozedUntil: true } } }, }); diff --git a/src/lib/medications/inventory/consumption.ts b/src/lib/medications/inventory/consumption.ts index dfb0c3c7a..c8116e795 100644 --- a/src/lib/medications/inventory/consumption.ts +++ b/src/lib/medications/inventory/consumption.ts @@ -284,6 +284,7 @@ export async function consumeForIntake(input: { orderBy: [ { expiresAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, + { id: "asc" }, ], }); const active = await tx.medicationInventoryItem.findMany({ @@ -297,6 +298,7 @@ export async function consumeForIntake(input: { { printedExpiry: { sort: "asc", nulls: "last" } }, { purchasedAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, + { id: "asc" }, ], }); @@ -473,6 +475,7 @@ export async function consumeImportedIntakesBatch(input: { orderBy: [ { expiresAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, + { id: "asc" }, ], }), tx.medicationInventoryItem.findMany({ @@ -486,6 +489,7 @@ export async function consumeImportedIntakesBatch(input: { { printedExpiry: { sort: "asc", nulls: "last" } }, { purchasedAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, + { id: "asc" }, ], }), ]); diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index a931edb2a..9c916274b 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -43,6 +43,7 @@ import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era import { floorWhole } from "@/lib/medications/units-per-dose"; import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; export type MedicationsListResult = Array>; @@ -55,12 +56,10 @@ export async function buildMedicationsList( userTz, ); - // v1.15.10 — the slots the user has already acted on near "now", so the - // next-due search can skip them. A resolved slot is a taken, deliberately - // skipped, or cron-auto-missed row. Bound the window to [today-start, - // today-end + 2d] — the next-due lookahead only needs the slots adjacent to - // now, and a tight window keeps the read cheap. The 60s list cache covers - // the rest. + // The slots the user has already acted on near "now" (taken, skipped, or + // auto-missed), so the next-due search can skip them. The lookahead only + // needs the slots next to now, so the window ends two days past today; the + // 60s list cache covers the rest. const resolvedWindowEnd = new Date( todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000, ); @@ -84,8 +83,11 @@ export async function buildMedicationsList( prisma.medication.findMany({ where: { userId }, // v1.43 — the ingredient count only, for `hasIngredients`. - include: { schedules: true, _count: { select: { ingredients: true } } }, - orderBy: { createdAt: "desc" }, + include: { + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, + _count: { select: { ingredients: true } }, + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }), prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], @@ -100,13 +102,11 @@ export async function buildMedicationsList( }), prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], - // v1.7.0 sync — exclude tombstoned rows from the today-count map. - // v1.16.9 — count only ACTIONED rows (taken or skipped). The - // dashboard projector mints pending rows for every slot of the - // day, so counting all rows made `todayEventCount` cover every - // passed dose after any dashboard visit — and the cards' - // overdue-pill suppression (`todayEventCount < passedDoseCount`) - // went dark nondeterministically for genuinely overdue doses. + // Live, ACTIONED rows only (taken or skipped). The dashboard projector + // mints pending rows for every slot of the day, so counting all rows + // made `todayEventCount` cover every passed dose after any dashboard + // visit, and the cards' overdue pill (`todayEventCount < + // passedDoseCount`) went dark for genuinely overdue doses. where: { userId, deletedAt: null, diff --git a/src/lib/medications/read-medication.ts b/src/lib/medications/read-medication.ts index 248235e3e..6f7adea8d 100644 --- a/src/lib/medications/read-medication.ts +++ b/src/lib/medications/read-medication.ts @@ -63,7 +63,7 @@ async function loadDisplayDue( deletedAt: null, takenAt: { not: null }, }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], select: { takenAt: true }, }) : null; @@ -94,7 +94,7 @@ async function loadDisplayDue( // Superseded rows are audit records — a correction may have // shortened the era, so the boundary reads only active rows. where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, + orderBy: [{ validUntil: "desc" }, { id: "desc" }], select: { validUntil: true }, }); // Intake tracking off (#1033): nothing is due from the stored rows, and no diff --git a/src/lib/medications/read-order.ts b/src/lib/medications/read-order.ts new file mode 100644 index 000000000..766cdf9db --- /dev/null +++ b/src/lib/medications/read-order.ts @@ -0,0 +1,22 @@ +/** + * The order a medication's rows are read in, ties included. + * + * Postgres returns rows that tie on an `ORDER BY` in no particular order, and + * not the same order twice: the heap layout, a vacuum or a parallel plan all + * change it. A read ordered by a column two rows can share (two schedules at + * 08:00 on different medications, two intakes in the same slot, two + * medications created in one import) therefore answered its client a + * different order from one request to the next, and a "latest" lookup picked + * either of two rows. Every medication and intake read ends its order on the + * row id, and `medication-read-order-guard.test.ts` holds the tree to it. + */ +import type { Prisma } from "@/generated/prisma/client"; + +/** + * A medication's schedules, as every reader that hands them on includes + * them: by window, then by id, the order the backup already writes. + */ +export const MEDICATION_SCHEDULE_ORDER = [ + { windowStart: "asc" }, + { id: "asc" }, +] as const satisfies Prisma.MedicationScheduleOrderByWithRelationInput[]; diff --git a/src/lib/medications/record-intake/write.ts b/src/lib/medications/record-intake/write.ts index 4db4ca049..20371010e 100644 --- a/src/lib/medications/record-intake/write.ts +++ b/src/lib/medications/record-intake/write.ts @@ -66,7 +66,7 @@ export async function findReplayedIntake( skipped, createdAt: { gte: new Date(Date.now() - 60_000) }, }, - orderBy: { createdAt: "desc" }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], }); if (recentDuplicate) { return recentDuplicate; diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index f7c1863f7..20bfd2354 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -123,7 +123,7 @@ export async function projectTodayIntakesAndRecompute(input: { deletedAt: null, takenAt: { not: null }, }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], select: { takenAt: true }, }); lastIntakeAt = lastIntake?.takenAt ?? null; diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 13a791050..01fed425f 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -548,7 +548,7 @@ export async function resolveSlotInstantForWrite( deletedAt: null, takenAt: { not: null }, }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], select: { takenAt: true }, }); lastIntakeAt = lastIntake?.takenAt ?? null; @@ -603,7 +603,7 @@ async function loadAttributeMedication(input: { deletedAt: null, takenAt: { not: null }, }, - orderBy: { takenAt: "desc" }, + orderBy: [{ takenAt: "desc" }, { id: "desc" }], select: { takenAt: true }, }); lastIntakeAt = lastIntake?.takenAt ?? null; @@ -905,7 +905,7 @@ async function rollingIntakeInstantsIfNeeded( skipped: false, takenAt: { not: null, lte: around }, }, - orderBy: { takenAt: "asc" }, + orderBy: [{ takenAt: "asc" }, { id: "asc" }], select: { takenAt: true }, }); return rows.map((r) => r.takenAt).filter((d): d is Date => d !== null); diff --git a/src/lib/medications/update-medication/preflight.ts b/src/lib/medications/update-medication/preflight.ts index 5becef569..05eb8ce57 100644 --- a/src/lib/medications/update-medication/preflight.ts +++ b/src/lib/medications/update-medication/preflight.ts @@ -189,7 +189,7 @@ export async function findPrimaryScheduleGracePatch( if (topLevelGraceMinutes !== undefined && !schedules) { const primary = await prisma.medicationSchedule.findFirst({ where: { medicationId: id }, - orderBy: { windowStart: "asc" }, + orderBy: [{ windowStart: "asc" }, { id: "asc" }], select: { id: true }, }); if (primary) { diff --git a/src/lib/medications/update-medication/write.ts b/src/lib/medications/update-medication/write.ts index c15b69575..f210ddb0d 100644 --- a/src/lib/medications/update-medication/write.ts +++ b/src/lib/medications/update-medication/write.ts @@ -164,7 +164,7 @@ async function archiveEra( // Chain from the ACTIVE boundary: a superseded row is an // audit record whose `validUntil` may sit past its correction. where: { medicationId: id, supersededByRevisionId: null }, - orderBy: { validUntil: "desc" }, + orderBy: [{ validUntil: "desc" }, { id: "desc" }], select: { validUntil: true }, }); await tx.medicationScheduleRevision.create({ diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index ac8ad62ce..1c28f8775 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -175,12 +175,12 @@ export async function buildTargetsResponse(user: AuthedUser) { }, include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - scheduleRevisions: { orderBy: { validFrom: "asc" } }, + scheduleRevisions: { orderBy: [{ validFrom: "asc" }, { id: "asc" }] }, pauseEras: { select: { pausedAt: true, resumedAt: true } }, // v1.40 (#1024) — the courses, so a gap between two expects nothing. courses: COURSES_COMPLIANCE_SELECT, }, - orderBy: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], }); if (activeMedications.length === 0) { return { activeMedications, intakeEvents: [] }; @@ -194,7 +194,7 @@ export async function buildTargetsResponse(user: AuthedUser) { }, scheduledFor: { gte: thirtyDaysAgo }, }, - orderBy: { scheduledFor: "desc" }, + orderBy: [{ scheduledFor: "desc" }, { id: "desc" }], select: { medicationId: true, takenAt: true, diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 7b7bdf7c2..23301c3eb 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -1164,7 +1164,7 @@ export async function handleTextMessage(update: TelegramUpdate) { const meds = await prisma.medication.findMany({ where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, select: { id: true, name: true, dose: true }, - orderBy: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], }); if (meds.length === 0) { @@ -1280,7 +1280,7 @@ export async function handleTextMessage(update: TelegramUpdate) { const meds = await prisma.medication.findMany({ where: { userId: user.id, active: true, ...TRACKED_INTAKE_WHERE }, select: { id: true, name: true }, - orderBy: { name: "asc" }, + orderBy: [{ name: "asc" }, { id: "asc" }], take: 2, }); if (meds.length === 1) { diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index 73fdb5789..eeb0f9053 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -238,7 +238,7 @@ async function medicationsLane(frame: LaneFrame): Promise { endsOn: true, updatedAt: true, }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], }), prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts index dcafc0d9a..a65e313d4 100644 --- a/src/lib/timeline/readiness.ts +++ b/src/lib/timeline/readiness.ts @@ -285,7 +285,7 @@ async function medicationsLane(frame: Frame) { prisma.medication.findMany({ where: { userId: frame.userId }, select: { id: true, startsOn: true }, - orderBy: { createdAt: "asc" }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], }), prisma.medicationIntakeEvent.groupBy({ by: ["medicationId"], diff --git a/tests/integration/medication-read-order.test.ts b/tests/integration/medication-read-order.test.ts new file mode 100644 index 000000000..861798255 --- /dev/null +++ b/tests/integration/medication-read-order.test.ts @@ -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>(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"]); + }); +}); diff --git a/tests/integration/routes/parity/medication-state.ts b/tests/integration/routes/parity/medication-state.ts index b96f6b348..4790ff2c8 100644 --- a/tests/integration/routes/parity/medication-state.ts +++ b/tests/integration/routes/parity/medication-state.ts @@ -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 `` labels + * swapped under a loaded run. + */ export function sortRows(rows: readonly T[]): unknown[] { return rows .map(plain) @@ -112,6 +121,8 @@ export function sortRows(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,