From 6f37cbdb23a39fa96ea852c33931b61d2fdb34ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 14:17:42 +0200 Subject: [PATCH] fix(backup): carry each medication's category through backup and restore The clinical category a person picks for a medication lives in a side table created at runtime, outside the schema. The backup builder and the restore only walk schema models, so no backup ever carried it: after a restore every medication read as "Other". Both backup formats now carry the category inside its medication, and the restore writes it back in the same transaction as the medication. A file from before this change restores as before; a category this server does not know reads as Other instead of refusing the file. The backup plan gains a list for tables created with raw SQL, and a check that every such table in the source either maps to a schema model or has a verdict there, so the next one cannot drop out of backups unnoticed. --- docs/api/openapi.yaml | 3 + .../backup-plan-classification.test.ts | 64 ++++++++++++++++- .../__tests__/full-backup-payload.test.ts | 7 ++ .../restore-backup-round-trip.test.ts | 7 ++ src/lib/export/backup-plan.ts | 18 +++++ src/lib/export/full-backup-payload.ts | 11 +++ src/lib/export/restore-backup.ts | 14 ++++ src/lib/medication-category.ts | 19 ++++- src/lib/validations/backup.ts | 5 ++ tests/integration/backup-round-trip.test.ts | 72 +++++++++++++++++++ 10 files changed, 215 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 18a01132b..52fba7dfc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34173,6 +34173,9 @@ components: type: boolean trackIntake: type: boolean + category: + type: string + maxLength: 64 deliveryForm: type: string enum: diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index 0da7c7f34..99b3390e7 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -8,6 +8,7 @@ import { BACKED_UP_MODELS, DERIVED_MODELS, NOT_IN_BACKUP_MODELS, + RAW_SQL_TABLES, backupVerdict, } from "@/lib/export/backup-plan"; @@ -491,3 +492,62 @@ describe("every backed-up model travels both ways, or is named as debt", () => { } }); }); + +describe("tables outside the schema have a backup verdict too", () => { + const SRC = resolve(__dirname, ".."); + + function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + return entry.name === "generated" || entry.name === "__tests__" + ? [] + : sourceFiles(full); + } + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + } + + 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]); + } + } + return [...names]; + } + + const mappedTables = new Set( + [...readFileSync(SCHEMA_PATH, "utf8").matchAll(/@@map\("(\w+)"\)/g)].map( + (m) => m[1], + ), + ); + + it("finds the raw tables it is meant to judge (no vacuous pass)", () => { + expect(rawTables()).toContain("medication_categories"); + }); + + it("gives every raw table that is not a schema model a verdict", () => { + const unjudged = rawTables().filter( + (name) => !mappedTables.has(name) && !(name in RAW_SQL_TABLES), + ); + expect(unjudged).toEqual([]); + }); + + it("carries the medication category through both ends", () => { + const writer = readFileSync( + join(SRC, "lib/export/full-backup-payload.ts"), + "utf8", + ); + const restore = readFileSync( + join(SRC, "lib/export/restore-backup.ts"), + "utf8", + ); + expect(writer).toMatch(/getMedicationCategories\(/); + expect(restore).toMatch(/setMedicationCategory\(/); + }); +}); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index f9965e626..6b54c2c4c 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({ buildRecordsBackupSection: vi.fn(), })); +// 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"), +})); vi.mock("@/lib/export/paged-measurements", () => ({ iterateMeasurementPages: mocks.iterateMeasurementPages, })); 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 95cb9c53a..6de70e1c8 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -31,6 +31,13 @@ const mocks = vi.hoisted(() => ({ transaction: vi.fn(), })); +// 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"), +})); vi.mock("@/lib/db", () => ({ prisma: { dataBackup: { findUnique: vi.fn() }, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 2d689337a..21301b399 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -706,6 +706,24 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "Feedback on generated recommendations that are themselves DERIVED. Restoring the feedback without the item it judged leaves an orphan.", }; +/** + * Tables created with raw SQL at runtime rather than declared in the schema. + * Every check above walks `schema.prisma`, so a table outside it is invisible + * to all of them — which is how the medication category went missing from + * 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. + */ +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[] = [ "Session", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index f52a39c18..d5a9137d7 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -118,6 +118,7 @@ import { type OnboardingBackupCounts, type OnboardingBackupSection, } from "@/lib/export/onboarding-backup"; +import { getMedicationCategories } from "@/lib/medication-category"; export interface FullBackupCounts extends @@ -943,6 +944,14 @@ 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". + const categoryByMedication = await getMedicationCategories( + medications.map((m) => m.id), + prisma, + ); + // Where each archived era sits in its OWN drug's ordered list. Built once // here rather than per row, and scoped per medication because the supersede // pointer never crosses a drug — every route that writes it scopes the @@ -1008,6 +1017,8 @@ export async function buildFullBackupPayload( // record only. Both purposes carry it: a restore that dropped it would // start reminding them about a medication they chose not to track. trackIntake: m.trackIntake, + // Both purposes: the category is something the person chose. + category: categoryByMedication[m.id] ?? "OTHER", // Both purposes carry the creation instant: it is the floor of the // medication's expected slots, so a restore stamped with the restore // time would read the whole restored history as predating the diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 2cf75bdf4..c9141897a 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -101,6 +101,10 @@ 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 { invalidateUserData } from "@/lib/cache/invalidate"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { @@ -662,6 +666,10 @@ export async function restoreBackup( cleared: RestoreResponse["cleared"]; skipped: RestoreSkipSummary; }; + // 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( @@ -1236,6 +1244,12 @@ 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 + // 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") { + await setMedicationCategory(created.id, m.category, tx); + } // ── The archived schedule eras, in two passes ──────────────── // diff --git a/src/lib/medication-category.ts b/src/lib/medication-category.ts index c36476bf8..04d64d771 100644 --- a/src/lib/medication-category.ts +++ b/src/lib/medication-category.ts @@ -7,6 +7,17 @@ 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 + * 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" +>; + let ensureTablePromise: Promise | null = null; function normalizeCategory(input: unknown): MedicationCategory { @@ -16,7 +27,7 @@ function normalizeCategory(input: unknown): MedicationCategory { : DEFAULT_CATEGORY; } -async function ensureMedicationCategoryTable() { +export async function ensureMedicationCategoryTable() { if (!ensureTablePromise) { ensureTablePromise = (async () => { await prisma.$executeRawUnsafe(` @@ -42,11 +53,12 @@ async function ensureMedicationCategoryTable() { export async function getMedicationCategories( medicationIds: string[], + client: RawSqlClient = prisma, ): Promise> { if (medicationIds.length === 0) return {}; await ensureMedicationCategoryTable(); - const rows = await prisma.$queryRawUnsafe< + const rows = await client.$queryRawUnsafe< Array<{ medication_id: string; category: string }> >( ` @@ -70,11 +82,12 @@ export async function getMedicationCategories( export async function setMedicationCategory( medicationId: string, category: unknown, + client: RawSqlClient = prisma, ) { await ensureMedicationCategoryTable(); const normalized = normalizeCategory(category); - await prisma.$executeRawUnsafe( + await client.$executeRawUnsafe( ` INSERT INTO medication_categories (medication_id, category, updated_at) VALUES ($1, $2, NOW()) diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 76bdb532a..74ce2ea92 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -357,6 +357,11 @@ const medicationSchema = z // v1.39.1 (#1033) — absent in a backup written before the field existed; // the restore defaults it to tracking on, the behaviour those had. trackIntake: z.boolean().optional(), + // v1.39.4 — the clinical category. Absent in older files. A string, not + // the enum: a file from a newer server may name a category this one does + // not know, and the restore reads that as OTHER rather than refusing the + // whole file. + category: z.string().max(64).optional(), deliveryForm: z.enum(MedicationDeliveryForm).optional(), trackInjectionSites: z.boolean().optional(), allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index be6962acc..1046fee29 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -63,6 +63,10 @@ import { encryptWaveformToBytes, } from "@/lib/withings/ecg-waveform-codec"; import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { + getMedicationCategories, + setMedicationCategory, +} from "@/lib/medication-category"; import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { decryptNoteFromBytes } from "@/lib/labs/store"; import { decryptContextFromBytes } from "@/lib/labs/biomarker-store"; @@ -2755,6 +2759,74 @@ describe("every model the plan claims two-ended survives a real restore", () => * the target's key. A restore that dropped the readable JSON would bring * the thread back with every answer's table missing and nothing to say so. */ + it.each(["disaster-recovery", "portable-export"] as const)( + "carries each medication's category through a %s round trip", + async (purpose) => { + const prisma = getPrismaClient(); + await seedAdminSession(prisma); + await createOwner(prisma); + const thyroid = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Levothyroxine", dose: "50 µg" }, + }); + const mood = await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Sertraline", dose: "50 mg" }, + }); + await prisma.medication.create({ + data: { userId: OWNER_ID, name: "Unfiled", dose: "1 tab" }, + }); + await setMedicationCategory(thyroid.id, "THYROID"); + await setMedicationCategory(mood.id, "MENTAL_HEALTH"); + + const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, { + purpose, + }); + const exported = Object.fromEntries( + (payload.medications as Array<{ name: string; category?: string }>).map( + (m) => [m.name, m.category], + ), + ); + expect(exported).toEqual({ + Levothyroxine: "THYROID", + Sertraline: "MENTAL_HEALTH", + Unfiled: "OTHER", + }); + + await prisma.user.delete({ where: { id: OWNER_ID } }); + await createOwner(prisma); + const backup = await prisma.dataBackup.create({ + data: { + userId: OWNER_ID, + type: "TWO_ENDED_ROUND_TRIP", + data: encrypt(JSON.stringify(payload)), + }, + }); + const response = await POST( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as never, + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status, JSON.stringify(await response.json())).toBe(200); + + const restored = await prisma.medication.findMany({ + where: { userId: OWNER_ID }, + select: { id: true, name: true }, + }); + const categories = await getMedicationCategories( + restored.map((m) => m.id), + ); + expect( + Object.fromEntries(restored.map((m) => [m.name, categories[m.id]])), + ).toEqual({ + Levothyroxine: "THYROID", + Sertraline: "MENTAL_HEALTH", + Unfiled: "OTHER", + }); + }, + ); + it("carries a turn's tables readable in a portable file and seals them again on restore", async () => { const prisma = getPrismaClient(); await seedAdminSession(prisma);