From 350c64646362559d042b8548c96f588858a76874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 20:45:02 +0200 Subject: [PATCH] Adopt the medication category table into the schema The clinical category lived in a side table the app created at runtime, after migration 0004 had dropped it as orphaned. It is now the MedicationCategoryAssignment model. Migration 0368 creates the table IF NOT EXISTS with the runtime DDL, so an existing table converges with its rows untouched (verified with prisma migrate diff against a database holding the runtime table and against a fresh one). The helpers keep their names and run on the Prisma delegate; the runtime CREATE TABLE and the pre-transaction ensure call in the restore are gone. The backup plan judges the table as a schema model (backed up, two-ended, seeded in the round trip) instead of under RAW_SQL_TABLES, which is now empty; its guard proves its matcher on a fixture string instead. --- .../migration.sql | 20 +++++ prisma/schema.prisma | 62 +++++++++----- .../backup-plan-classification.test.ts | 39 +++++++-- .../__tests__/full-backup-payload.test.ts | 1 - .../restore-backup-round-trip.test.ts | 1 - src/lib/export/backup-plan.ts | 24 ++++-- src/lib/export/full-backup-payload.ts | 7 +- src/lib/export/restore-backup.ts | 11 +-- src/lib/medication-category.ts | 85 ++++++------------- tests/integration/backup-round-trip.test.ts | 8 ++ 10 files changed, 150 insertions(+), 108 deletions(-) create mode 100644 prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql diff --git a/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql new file mode 100644 index 000000000..d649406d1 --- /dev/null +++ b/prisma/migrations/0368_medication_courses_and_custom_categories/migration.sql @@ -0,0 +1,20 @@ +-- Medication categories: adopt the side table into the schema. +-- +-- `medication_categories` used to be created by the app at runtime (migration +-- 0004 had dropped it as orphaned and the app recreated it on first use). It +-- is now a Prisma model. The DDL below is identical to what the runtime +-- helper ran, and Postgres gives the unnamed primary key, foreign key and +-- index the same names Prisma expects, so a database that already holds the +-- runtime table keeps it untouched and every row in it; a fresh database gets +-- the same table. Idempotent. +CREATE TABLE IF NOT EXISTS "medication_categories" ( + "medication_id" TEXT NOT NULL, + "category" TEXT NOT NULL DEFAULT 'OTHER', + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "medication_categories_pkey" PRIMARY KEY ("medication_id"), + CONSTRAINT "medication_categories_medication_id_fkey" + FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "medication_categories_category_idx" + ON "medication_categories"("category"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36dbabbce..c7cd9569c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2753,33 +2753,33 @@ model Medication { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - schedules MedicationSchedule[] - intakeEvents MedicationIntakeEvent[] - intakeImportJobs MedicationIntakeImportJob[] - phaseConfig ReminderPhaseConfig? - telegramMessages TelegramReminderMessage[] - doseChanges MedicationDoseChange[] - inventoryEvents MedicationInventoryEvent[] + schedules MedicationSchedule[] + intakeEvents MedicationIntakeEvent[] + intakeImportJobs MedicationIntakeImportJob[] + phaseConfig ReminderPhaseConfig? + telegramMessages TelegramReminderMessage[] + doseChanges MedicationDoseChange[] + inventoryEvents MedicationInventoryEvent[] /// v1.4.25 W19b — per-pen / per-vial entities (state machine + /// 30-day in-use clock). Complements the running-sum /// MedicationInventoryEvent ledger. - inventoryItems MedicationInventoryItem[] + inventoryItems MedicationInventoryItem[] /// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on /// the medication detail page for GLP-1 treatmentClass only; the /// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. - sideEffects MedicationSideEffect[] + sideEffects MedicationSideEffect[] /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations /// on the linked MedicationIntakeEvent rows refresh the row inline; /// the read paths consume this instead of the per-event findMany. - complianceRollups MedicationComplianceRollup[] + complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. - scheduleRevisions MedicationScheduleRevision[] + scheduleRevisions MedicationScheduleRevision[] /// v1.25 — durable pause intervals. A pause stamps an open era /// (`resumedAt = null`); resume closes the latest open era. The /// compliance engine drops expected slots whose anchor falls inside a @@ -2787,12 +2787,15 @@ model Medication { /// as missed. The `pausedAt` column on this model stays the live /// single-pause marker the reminder/today surfaces read; the era table /// is the additive durable record the rate paths need. - pauseEras MedicationPauseEra[] + pauseEras MedicationPauseEra[] /// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows /// exist ONLY when the user explicitly overrides the derived (ATC / name) /// target resolution; absence means the resolver derives the target each /// read. Nothing about a class→outcome association is denormalised here. - efficacyTargets MedicationEfficacyTarget[] + efficacyTargets MedicationEfficacyTarget[] + /// v1.40 — the clinical category the person filed this medication under + /// (one row at most; absence reads as OTHER). + categoryAssignment MedicationCategoryAssignment? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2808,6 +2811,27 @@ model Medication { @@map("medications") } +/// v1.40 — the clinical category of a medication (BLOOD_PRESSURE, VITAMIN, +/// MENTAL_HEALTH, ... or a `custom:` key of one of the owner's +/// `MedicationCategoryLabel` rows). The table predates this model: it was a +/// raw-SQL side table the app created at runtime, which migration 0004 had +/// dropped as orphaned. Migration 0368 creates it `IF NOT EXISTS` with the +/// same DDL, so a database that already holds the runtime table converges +/// without drift. The foreign key keeps the runtime table's `NO ACTION` on +/// update. Absence of a row reads as OTHER. +model MedicationCategoryAssignment { + medicationId String @id @map("medication_id") + /// A built-in value from `MEDICATION_CATEGORY_VALUES` or `custom:`. + /// Only `src/lib/medication-category.ts` reads or writes it. + category String @default("OTHER") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([category]) + @@map("medication_categories") +} + /// v1.28 — the user's explicit override of a medication's efficacy target, /// the ONE thing the "Wirkung" view persists. Class→outcome resolution /// (ATC-prefix → name inference) is computed each read from the closed @@ -6203,19 +6227,19 @@ model RecommendationFeedback { // the user explicitly opted out of into it. The summarisation helper // in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis. model CoachConversation { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// @internal: legacy plaintext title, read only as a fallback for rows the /// free-text encryption backfill has not reached yet (v1.39.3). Every write /// since then goes to `titleEncrypted` and leaves this NULL; the backfill /// nulls it on the old rows. DROP this column in a later release, once the /// backfill reports zero remaining rows on every instance. - title String? + title String? /// v1.39.3 — AES-256-GCM ciphertext of the title (the first 80 characters of /// the opening Coach message, or the rename). Same Bytes codec as the notes. - titleEncrypted Bytes? @map("title_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + titleEncrypted Bytes? @map("title_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") // v1.11.0 — rolling encrypted summary of the elided older turns, so a long // conversation keeps memory of what came before without re-sending every diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 99b3390e7..6af03054a 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -508,14 +508,17 @@ describe("tables outside the schema have a backup verdict too", () => { }); } + const CREATE_TABLE = /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi; + + function tablesIn(text: string): string[] { + return [...text.matchAll(CREATE_TABLE)].map((m) => m[1]); + } + function rawTables(): string[] { const names = new Set(); for (const file of sourceFiles(SRC)) { - const text = readFileSync(file, "utf8"); - for (const m of text.matchAll( - /CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+"?(\w+)"?/gi, - )) { - names.add(m[1]); + for (const name of tablesIn(readFileSync(file, "utf8"))) { + names.add(name); } } return [...names]; @@ -527,8 +530,30 @@ describe("tables outside the schema have a backup verdict too", () => { ), ); - it("finds the raw tables it is meant to judge (no vacuous pass)", () => { - expect(rawTables()).toContain("medication_categories"); + it("finds a raw table when one is written (no vacuous pass)", () => { + // The source holds none since v1.40, so the matcher is proven against the + // exact DDL the medication category helper used to run at runtime. + expect( + tablesIn(` + CREATE TABLE IF NOT EXISTS medication_categories ( + medication_id TEXT PRIMARY KEY + ); + create table if not exists "quoted_name" (id TEXT); + `), + ).toEqual(["medication_categories", "quoted_name"]); + }); + + it("keeps no verdict for a raw table the source no longer creates", () => { + const present = new Set(rawTables()); + expect(Object.keys(RAW_SQL_TABLES).filter((t) => !present.has(t))).toEqual( + [], + ); + }); + + it("judges the adopted medication category table as a schema model", () => { + expect(mappedTables.has("medication_categories")).toBe(true); + expect(BACKED_UP_MODELS).toContain("MedicationCategoryAssignment"); + expect(TWO_ENDED_MODELS).toContain("MedicationCategoryAssignment"); }); it("gives every raw table that is not a schema model a verdict", () => { diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index c4a65b1ec..0471bf2d9 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -15,7 +15,6 @@ const mocks = vi.hoisted(() => ({ // The category side table is raw SQL on the shared client; the round trip // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ - ensureMedicationCategoryTable: vi.fn(async () => {}), getMedicationCategories: vi.fn(async () => ({})), setMedicationCategory: vi.fn(async () => "OTHER"), })); diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index a988ddfed..d95ed71c8 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => ({ // The category side table is raw SQL on the shared client; the round trip // through it is proven in tests/integration/backup-round-trip.test.ts. vi.mock("@/lib/medication-category", () => ({ - ensureMedicationCategoryTable: vi.fn(async () => {}), getMedicationCategories: vi.fn(async () => ({})), setMedicationCategory: vi.fn(async () => "OTHER"), })); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 6af2a3d66..a49e803ff 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -77,6 +77,12 @@ export const BACKED_UP_MODELS = [ "MedicationEfficacyTarget", "MedicationInventoryItem", "MedicationInventoryEvent", + // The clinical category the person filed each medication under. Before + // v1.40 this was a raw table the app created at runtime and judged under + // RAW_SQL_TABLES; it rides inside its medication (`category`) in both + // formats and is written back in the restore transaction. Without it every + // restored medication reads as Other. + "MedicationCategoryAssignment", // ── Mood ────────────────────────────────────────────────────────────────── "MoodEntry", @@ -251,6 +257,10 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // tables and not a parent-child ride. "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", + // The medication category is read through the one helper that normalises + // it; the payload writer calls the helper rather than the delegate. The + // same "the caller delegates" shape as the measurement pager. + "src/lib/medication-category.ts", ]; export const BACKUP_RESTORE_FILES: readonly string[] = [ @@ -269,6 +279,8 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/onboarding-backup.ts", "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", + // The restore writes the medication category through the same helper. + "src/lib/medication-category.ts", ]; /** @@ -297,6 +309,7 @@ export const TWO_ENDED_MODELS = [ "MedicationSideEffect", "MedicationPauseEra", "MedicationDoseChange", + "MedicationCategoryAssignment", "MoodEntry", "MoodContext", "MoodEntryTagLink", @@ -715,16 +728,13 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { * every backup until v1.39.4. The guard beside this file finds each * `CREATE TABLE IF NOT EXISTS` in the source and demands a verdict here, or a * schema model mapped to the same name. + * + * Empty since v1.40: the one such table, `medication_categories`, is the + * `MedicationCategoryAssignment` model now and judged with the others. */ export const RAW_SQL_TABLES: Readonly< Record -> = { - medication_categories: { - verdict: "BACKED_UP", - reason: - "The clinical category the person chose for each medication. Carried inside its medication in both backup formats and written back in the restore transaction; without it every restored medication reads as Other.", - }, -}; +> = {}; /** Auth material: not user data, and excluded from the wipe for the same reason. */ export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 5f5e35fed..aaec42083 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -962,9 +962,10 @@ export async function buildFullBackupPayload( : moodEntries!, }; - // The clinical category lives in a raw side table rather than on the - // medication row, so no Prisma include reaches it. Before v1.39.4 no backup - // carried it and every restored medication came back as "Other". + // The clinical category lives in its own table rather than on the + // medication row and is read through the one helper that normalises it. + // Before v1.39.4 no backup carried it and every restored medication came + // back as "Other". const categoryByMedication = await getMedicationCategories( medications.map((m) => m.id), prisma, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 8b80622d1..0dbfb4bd2 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -107,10 +107,7 @@ import { restoreAwardsData } from "@/lib/export/awards-backup"; import { restoreEnvironmentData } from "@/lib/export/environment-backup"; import { restoreEcgData } from "@/lib/export/ecg-backup"; import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at"; -import { - ensureMedicationCategoryTable, - setMedicationCategory, -} from "@/lib/medication-category"; +import { setMedicationCategory } from "@/lib/medication-category"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { stampSyncReset } from "@/lib/sync/reset"; @@ -682,10 +679,6 @@ export async function restoreBackup( skipped: RestoreSkipSummary; accountSettings: AccountSettingsRestoreResult; }; - // The category side table is created lazily. Doing that here, before the - // transaction, keeps its DDL (which takes a table lock) out of a - // transaction that already holds row locks on the same table. - await ensureMedicationCategoryTable(); try { report("clearing"); outcome = await prisma.$transaction( @@ -1265,7 +1258,7 @@ export async function restoreBackup( }); restoredMedicationIds.add(created.id); if (!medByName.has(m.name)) medByName.set(m.name, created.id); - // v1.39.4 — the clinical category lives in a side table keyed on the + // v1.39.4 — the clinical category lives in its own table keyed on the // medication id, so it is written after the row exists and inside the // same transaction. OTHER is what a missing row already reads as. if (m.category && m.category !== "OTHER") { diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index 04d64d771..ad4cd1c8b 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication"; @@ -8,18 +9,16 @@ export type MedicationCategory = (typeof MEDICATION_CATEGORIES)[number]; const DEFAULT_CATEGORY: MedicationCategory = "OTHER"; /** - * The raw-SQL surface the helpers below need. The default is the shared + * The client surface the helpers below need. The default is the shared * client; the backup builder and the restore pass their own (the restore's * transaction client, so a category row lands in the same transaction as * the medication it points at and its foreign key can see that row). */ -type RawSqlClient = Pick< - typeof prisma, - "$queryRawUnsafe" | "$executeRawUnsafe" +type CategoryClient = Pick< + Prisma.TransactionClient, + "medicationCategoryAssignment" >; -let ensureTablePromise: Promise | null = null; - function normalizeCategory(input: unknown): MedicationCategory { if (typeof input !== "string") return DEFAULT_CATEGORY; return MEDICATION_CATEGORIES.includes(input as MedicationCategory) @@ -27,54 +26,23 @@ function normalizeCategory(input: unknown): MedicationCategory { : DEFAULT_CATEGORY; } -export async function ensureMedicationCategoryTable() { - if (!ensureTablePromise) { - ensureTablePromise = (async () => { - await prisma.$executeRawUnsafe(` - CREATE TABLE IF NOT EXISTS medication_categories ( - medication_id TEXT PRIMARY KEY REFERENCES medications(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'OTHER', - updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - `); - - await prisma.$executeRawUnsafe(` - CREATE INDEX IF NOT EXISTS medication_categories_category_idx - ON medication_categories(category); - `); - })().catch((err) => { - ensureTablePromise = null; - throw err; - }); - } - - await ensureTablePromise; -} - export async function getMedicationCategories( medicationIds: string[], - client: RawSqlClient = prisma, + client: CategoryClient = prisma, ): Promise> { if (medicationIds.length === 0) return {}; - await ensureMedicationCategoryTable(); - const rows = await client.$queryRawUnsafe< - Array<{ medication_id: string; category: string }> - >( - ` - SELECT medication_id, category - FROM medication_categories - WHERE medication_id = ANY($1::text[]) - `, - medicationIds, - ); + const rows = await client.medicationCategoryAssignment.findMany({ + where: { medicationId: { in: medicationIds } }, + select: { medicationId: true, category: true }, + }); const map: Record = {}; for (const id of medicationIds) { map[id] = DEFAULT_CATEGORY; } for (const row of rows) { - map[row.medication_id] = normalizeCategory(row.category); + map[row.medicationId] = normalizeCategory(row.category); } return map; } @@ -82,29 +50,24 @@ export async function getMedicationCategories( export async function setMedicationCategory( medicationId: string, category: unknown, - client: RawSqlClient = prisma, + client: CategoryClient = prisma, ) { - await ensureMedicationCategoryTable(); const normalized = normalizeCategory(category); - await client.$executeRawUnsafe( - ` - INSERT INTO medication_categories (medication_id, category, updated_at) - VALUES ($1, $2, NOW()) - ON CONFLICT (medication_id) - DO UPDATE SET category = EXCLUDED.category, updated_at = NOW() - `, - medicationId, - normalized, - ); + await client.medicationCategoryAssignment.upsert({ + where: { medicationId }, + create: { medicationId, category: normalized }, + update: { category: normalized }, + }); return normalized; } -export async function deleteMedicationCategory(medicationId: string) { - await ensureMedicationCategoryTable(); - await prisma.$executeRawUnsafe( - `DELETE FROM medication_categories WHERE medication_id = $1`, - medicationId, - ); +export async function deleteMedicationCategory( + medicationId: string, + client: CategoryClient = prisma, +) { + await client.medicationCategoryAssignment.deleteMany({ + where: { medicationId }, + }); } diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 2ae7458d8..6d082054d 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -220,6 +220,9 @@ const COUNT_BACK: Record< // No own `userId` column — reached through the drug, like the schedules. MedicationDoseChange: (p, userId) => p.medicationDoseChange.count({ where: { medication: { userId } } }), + // No own `userId` column — reached through the drug, like the schedules. + MedicationCategoryAssignment: (p, userId) => + p.medicationCategoryAssignment.count({ where: { medication: { userId } } }), MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }), MoodContext: (p, userId) => p.moodContext.count({ where: { userId } }), MoodEntryTagLink: (p, userId) => @@ -423,6 +426,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { takenAt: AT("2026-07-01T08:04:00.000Z"), }, }); + // Not OTHER: a missing row already reads as OTHER, so only a chosen value + // proves the category came back. + await prisma.medicationCategoryAssignment.create({ + data: { medicationId: medication.id, category: "THYROID" }, + }); // The one row in this fixture that is also checked field by field after the // restore — see the assertion at the end of the test for why. await prisma.medicationSideEffect.create({