fix(medications): end every medication and intake read on the row id

Postgres returns rows that tie on an ORDER BY in no particular order,
and not the same one twice. The medication reads ordered by columns two
rows share all the time: every read that hands schedules on included
them with no order at all, medications were listed by createdAt alone
(one import writes many in a millisecond), intakes by scheduledFor (a
twice-daily medication puts every morning dose on one instant), and
"the latest intake" lookups by takenAt. A client read the same account
in a different order from one request to the next, and a latest-intake
lookup picked either of two rows.

Every findMany / findFirst on a medication table, nested includes
included, now ends its order on the id in the direction of its last key,
and schedules are read by window, then id (MEDICATION_SCHEDULE_ORDER, the
order the backup already wrote). medication-read-order-guard.test.ts
holds the tree to it. medication-read-order.test.ts writes tying rows in
the opposite order to the one each read promises, through the medication
list and today's intakes, and fails without the tie-breakers.

The parity suite's own state reader had the same gap: it sorts rows by
content with the ids blanked, and rows that tie then kept whatever order
the unordered read returned, so their <id:N> labels swapped under a
loaded run. Its reads are ordered by id, which for a cuid is the order
the rows were written in.

The JSON import's schema left the route, so its entry in the inline
schema strictness register goes; the schema module says why its objects
are not strict.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 21:54:25 +02:00
1 parent 7936ab9a57
commit 111047491e
57 files changed
+476 -142

No files matched your search

@@ -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,
@@ -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,
@@ -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,
@@ -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 `<id:N>`
* 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<string, string> = {
medicationCategoryAssignment: "medicationId",
};
/** Orders built in a variable, by file, with where the tie-breaker lives. */
const ORDER_VARIABLES: Record<string, string> = {
"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());
});
});
@@ -190,7 +190,6 @@ const DELIBERATELY_OPEN: Record<string, { count: number; reason: string }> = {
// not read would make a backup unrestorable on the version that has
// to restore it, which is the one job a backup has.
// ---------------------------------------------------------------
"import/route.ts": { count: 3, reason: "foreign archive" },
"medications/[id]/intake/import/route.ts": {
count: 1,
reason: "foreign archive",
@@ -11,6 +11,7 @@ import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
export const dynamic = "force-dynamic";
@@ -109,7 +110,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(scopedUserId ? { userId: scopedUserId } : {}),
},
include: {
schedules: true,
schedules: { orderBy: MEDICATION_SCHEDULE_ORDER },
user: { select: { id: true, username: true, timezone: true } },
},
});
+2 -2
View File
@@ -76,7 +76,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
},
},
orderBy: { createdAt: "desc" },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}),
resolveUserTimezone(user.id),
]);
@@ -114,7 +114,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const events = await prisma.medicationIntakeEvent.findMany({
where,
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
});
intakeCount = events.length;
sections.push("# Intake history");
+4 -3
View File
@@ -19,6 +19,7 @@ import {
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
/**
* Export user data.
@@ -122,8 +123,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "medications" || type === "all") {
const medications = await prisma.medication.findMany({
where: { userId },
include: { schedules: true },
orderBy: { createdAt: "desc" },
include: { schedules: { orderBy: MEDICATION_SCHEDULE_ORDER } },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
data.medications =
format === "csv"
@@ -135,7 +136,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
});
data.intakeEvents =
format === "csv"
+2 -2
View File
@@ -116,7 +116,7 @@ export const GET = apiHandler(async () => {
// per-dose windows reach the engine like every other surface.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
scheduleRevisions: { orderBy: [{ validFrom: "asc" }, { id: "asc" }] },
// v1.25 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
@@ -207,7 +207,7 @@ export const GET = apiHandler(async () => {
// intakes outside the 30-day rate window.
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
select: {
medicationId: true,
takenAt: true,
+6 -3
View File
@@ -48,10 +48,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const meds = await prisma.medication.findMany({
where: { userId: user.id, treatmentClass: "GLP1" },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
doseChanges: { orderBy: [{ effectiveFrom: "asc" }, { id: "asc" }] },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: 50,
},
// v1.16.10 divergence note: the stock READOUTS (glp1 details
@@ -63,7 +63,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// only grows through the legacy `POST /api/medications/[id]/glp1`
// inventory branch, so timelines on item-tracked medications show
// no new inventory entries.
inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 },
inventoryEvents: {
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: 20,
},
},
});
@@ -633,7 +633,7 @@ describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
expect(res.status).toBe(200);
expect(prisma.medicationSchedule.findFirst).toHaveBeenCalledWith({
where: { medicationId: "m1" },
orderBy: { windowStart: "asc" },
orderBy: [{ windowStart: "asc" }, { id: "asc" }],
select: { id: true },
});
expect(prisma.medicationSchedule.update).toHaveBeenCalledWith({
@@ -108,7 +108,7 @@ export const GET = apiHandler(
// chips agree with the % + the history on a "zugeordnet" dose.
attributionSource: true,
},
orderBy: { scheduledFor: "asc" },
orderBy: [{ scheduledFor: "asc" }, { id: "asc" }],
});
// The cadence-anchor is the medication's createdAt for stable
@@ -166,7 +166,7 @@ export const GET = apiHandler(
const events = await prisma.medicationIntakeEvent.findMany({
where: { medicationId: id, userId: user.id, deletedAt: null },
orderBy: { scheduledFor: "desc" },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
select: {
id: true,
scheduledFor: true,
@@ -50,7 +50,12 @@ export const GET = apiHandler(
const [items, medication] = await Promise.all([
prisma.medicationInventoryItem.findMany({
where: { userId: user.id, medicationId: id },
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
orderBy: [
{ state: "asc" },
{ expiresAt: "asc" },
{ createdAt: "asc" },
{ id: "asc" },
],
}),
prisma.medication.findUnique({
where: { id },
@@ -86,7 +86,7 @@ export const GET = apiHandler(
// Superseded rows are audit records behind a correction — the
// timeline shows the corrected state, like every era consumer.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validFrom: "desc" },
orderBy: [{ validFrom: "desc" }, { id: "desc" }],
select: {
id: true,
validFrom: true,
+4 -3
View File
@@ -21,6 +21,7 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order";
/**
* Batched card-compliance read: one round trip for every medication the
@@ -82,15 +83,15 @@ export const GET = apiHandler(async (request: Request) => {
// of rates, and an empty expected set must not read as 0% or 100%.
where: { userId: user.id, asNeeded: false, ...TRACKED_INTAKE_WHERE },
include: {
schedules: true,
schedules: { orderBy: MEDICATION_SCHEDULE_ORDER },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
scheduleRevisions: { orderBy: [{ validFrom: "asc" }, { id: "asc" }] },
// v1.25 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
// v1.40 (#1024) — the courses, so a gap between two expects nothing.
courses: COURSES_COMPLIANCE_SELECT,
},
orderBy: { createdAt: "desc" },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
// Bounded fan-out: each cold cell costs one bounded intake read + one
+1 -1
View File
@@ -83,7 +83,7 @@ export const GET = apiHandler(async (_request: NextRequest) => {
}),
prisma.medicationIntakeEvent.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
select: { updatedAt: true },
}),
prisma.medicationIntakeEvent.count({
+6 -5
View File
@@ -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,
+2 -2
View File
@@ -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,
@@ -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.
+3 -2
View File
@@ -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: {
+2 -2
View File
@@ -202,7 +202,7 @@ async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
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<SectionPart> {
doseValue: true,
doseUnit: true,
},
orderBy: { effectiveFrom: "desc" },
orderBy: [{ effectiveFrom: "desc" }, { id: "desc" }],
distinct: ["medicationId"],
}),
]);
+4 -4
View File
@@ -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
+5
View File
@@ -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";
+2 -2
View File
@@ -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,
@@ -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" }],
})
: [];
@@ -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,
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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,
+4 -1
View File
@@ -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;
@@ -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`
+2 -2
View File
@@ -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;
@@ -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;
+6 -5
View File
@@ -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" },
+3 -2
View File
@@ -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" }],
}),
]);
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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,
+3 -2
View File
@@ -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<typeof createMedicationSchema>;
type ScheduleInput = NonNullable<CreateMedicationInput["schedules"]>[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);
}
@@ -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,
@@ -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,
+11 -11
View File
@@ -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<string>();
// 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) {
+1 -1
View File
@@ -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 } } },
});
@@ -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" },
],
}),
]);
+15 -15
View File
@@ -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<Record<string, unknown>>;
@@ -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,
+2 -2
View File
@@ -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
+22
View File
@@ -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[];
+1 -1
View File
@@ -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;
@@ -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;
@@ -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);
@@ -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) {
@@ -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({
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -238,7 +238,7 @@ async function medicationsLane(frame: LaneFrame): Promise<LaneRead> {
endsOn: true,
updatedAt: true,
},
orderBy: { createdAt: "asc" },
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
+1 -1
View File
@@ -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"],
@@ -0,0 +1,120 @@
/**
* Medication rows that tie on their sort key come back in one order.
*
* Postgres answers rows that tie on an `ORDER BY` in no particular order;
* with nothing to break the tie a small table hands them back as they lie in
* the heap, which is the order they were written in. Each case below writes
* its tying rows in the opposite order to the one the read promises, so a read
* that lost its tie-breaker answers the heap order and fails, and does so on
* the first run rather than on an unlucky one:
*
* - two medications created in the same millisecond (an import writes many)
* are listed newest first, then by id;
* - two schedules of one medication in the same window come back by id;
* - two of today's intakes on the same instant (two medications due at
* 08:00) come back by id.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { GET as listMedications } from "@/app/api/medications/route";
import { GET as listIntakes } from "@/app/api/medications/intake/route";
import { callRoute } from "../kit/call-route";
import { makeSession, makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const CREATED = new Date("2026-10-01T09:00:00.000Z");
beforeAll(async () => {
await truncateAllTables(prisma);
});
afterAll(async () => {
await truncateAllTables(prisma);
});
describe("medication reads with tying sort keys", () => {
it("lists two medications created together newest first, then by id", async () => {
const user = await makeUser({ timezone: "Europe/Berlin" });
// Written a, then b; the list promises createdAt desc, id desc: b, a.
for (const id of ["ord-med-a", "ord-med-b"]) {
await prisma.medication.create({
data: {
id,
userId: user.id,
name: id,
dose: "5 mg",
createdAt: CREATED,
},
});
}
// Written b, then a, in one window; the list promises window, id: a, b.
for (const id of ["ord-sched-b", "ord-sched-a"]) {
await prisma.medicationSchedule.create({
data: {
id,
medicationId: "ord-med-a",
windowStart: "08:00",
windowEnd: "09:00",
},
});
}
const res = await callRoute<
Array<{ id: string; schedules: Array<{ id: string }> }>
>(listMedications, {
method: "GET",
route: "/api/medications",
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.map((m) => m.id)).toEqual(["ord-med-b", "ord-med-a"]);
const a = res.data.find((m) => m.id === "ord-med-a")!;
expect(a.schedules.map((s) => s.id)).toEqual([
"ord-sched-a",
"ord-sched-b",
]);
});
it("lists today's intakes on one instant by id", async () => {
const user = await makeUser({ timezone: "UTC" });
const now = new Date();
const due = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 1),
);
for (const [medicationId, intakeId] of [
["ord-today-med-1", "ord-intake-b"],
["ord-today-med-2", "ord-intake-a"],
] as const) {
await prisma.medication.create({
data: {
id: medicationId,
userId: user.id,
name: medicationId,
dose: "1",
},
});
// Written b, then a; the read promises scheduledFor asc, id asc: a, b.
await prisma.medicationIntakeEvent.create({
data: {
id: intakeId,
userId: user.id,
medicationId,
scheduledFor: due,
takenAt: due,
},
});
}
const res = await callRoute<Array<{ id: string }>>(listIntakes, {
method: "GET",
route: "/api/medications/intake",
query: { scope: "today" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.map((e) => e.id)).toEqual(["ord-intake-a", "ord-intake-b"]);
});
});
@@ -96,7 +96,16 @@ function orderKey(value: unknown): string {
});
}
/** Rows in content order, ids and stamps left out of the comparison. */
/**
* Rows in content order, ids and stamps left out of the comparison.
*
* Two rows can share their whole content once the ids are out (a schedule at
* 08:00 on each of two medications), and the sort keeps them in the order the
* read returned them. So every read below is ordered by id, which for a cuid
* is the order the rows were written in: an unordered read handed the ties
* back in whatever order the heap held them, and their `<id:N>` labels
* swapped under a loaded run.
*/
export function sortRows<T>(rows: readonly T[]): unknown[] {
return rows
.map(plain)
@@ -112,6 +121,8 @@ export function sortRows<T>(rows: readonly T[]): unknown[] {
* guard writes them fire-and-forget, so whether one has landed when the test
* reads is a race, not something the route decides.
*/
const BY_ID = { id: "asc" } as const;
export async function medicationState(prisma: Client, userIds: string[]) {
const byUser = { userId: { in: userIds } };
const byMedication = { medication: byUser };
@@ -130,19 +141,35 @@ export async function medicationState(prisma: Client, userIds: string[]) {
audit,
idempotency,
] = await Promise.all([
prisma.medication.findMany({ where: byUser }),
prisma.medicationSchedule.findMany({ where: byMedication }),
prisma.medicationScheduleRevision.findMany({ where: byMedication }),
prisma.medicationPauseEra.findMany({ where: byMedication }),
prisma.medicationCourse.findMany({ where: byMedication }),
prisma.medicationIntakeEvent.findMany({ where: byUser }),
prisma.medicationDoseChange.findMany({ where: byMedication }),
prisma.medicationInventoryItem.findMany({ where: byUser }),
prisma.medicationInventoryEvent.findMany({ where: byMedication }),
prisma.medicationCategoryAssignment.findMany({ where: byMedication }),
prisma.medicationIngredient.findMany({ where: byMedication }),
prisma.medication.findMany({ where: byUser, orderBy: BY_ID }),
prisma.medicationSchedule.findMany({ where: byMedication, orderBy: BY_ID }),
prisma.medicationScheduleRevision.findMany({
where: byMedication,
orderBy: BY_ID,
}),
prisma.medicationPauseEra.findMany({ where: byMedication, orderBy: BY_ID }),
prisma.medicationCourse.findMany({ where: byMedication, orderBy: BY_ID }),
prisma.medicationIntakeEvent.findMany({ where: byUser, orderBy: BY_ID }),
prisma.medicationDoseChange.findMany({
where: byMedication,
orderBy: BY_ID,
}),
prisma.medicationInventoryItem.findMany({ where: byUser, orderBy: BY_ID }),
prisma.medicationInventoryEvent.findMany({
where: byMedication,
orderBy: BY_ID,
}),
prisma.medicationCategoryAssignment.findMany({
where: byMedication,
orderBy: { medicationId: "asc" },
}),
prisma.medicationIngredient.findMany({
where: byMedication,
orderBy: BY_ID,
}),
prisma.auditLog.findMany({
where: { ...byUser, action: { notIn: [...GUARD_TRAIL_ACTIONS] } },
orderBy: BY_ID,
select: {
action: true,
userId: true,
@@ -153,6 +180,7 @@ export async function medicationState(prisma: Client, userIds: string[]) {
}),
prisma.idempotencyKey.findMany({
where: byUser,
orderBy: BY_ID,
select: {
userId: true,
key: true,