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

@@ -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(),