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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 14:17:42 +02:00
1 parent e94e9b94a8
commit 6f37cbdb23
10 files changed
+215 -5

No files matched your search

+3
View File
@@ -34173,6 +34173,9 @@ components:
type: boolean
trackIntake:
type: boolean
category:
type: string
maxLength: 64
deliveryForm:
type: string
enum:
@@ -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<string>();
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\(/);
});
});
@@ -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,
}));
@@ -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() },
+18
View File
@@ -706,6 +706,24 @@ export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
"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<string, { verdict: "BACKED_UP" | "NOT_IN_BACKUP"; reason: string }>
> = {
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",
+11
View File
@@ -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
+14
View File
@@ -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 ────────────────
//
+16 -3
View File
@@ -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<void> | 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<Record<string, MedicationCategory>> {
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())
+5
View File
@@ -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(),
@@ -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);