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