mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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() },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user