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,