mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(backup): carry the symptoms an account made, and stop dropping the ones it cannot find
restoreCycleData was loud about one missing reference and silent about another, three lines apart. An unknown cycle reference threw; an unknown symptom key was filtered out of the list, so the day-log came back with one of its symptoms quietly gone and the restore reported success. The cause was upstream: the seeded catalogue is reference data every instance has, but a symptom the user created lives only in their account and was never carried. The key had nothing to resolve against, and the filter turned that into silence. Both ends now. The account's own definitions travel with the backup, the seeded rows deliberately do not — shipping those would let one instance's restore rewrite another's reference data. And the resolve step throws on an unresolvable key rather than filtering it, so the same function answers the same question the same way twice. Widening the builder's client type made four export tests fail on a missing delegate, which is the sweep working: those are the call sites, named by the compiler instead of found by reading.
This commit is contained in:
1 parent
a2e9587781
commit
b2e19ce0b6
5 files changed
+189
-6
No files matched your search
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
|
||||
cycleProfile: { findUnique: vi.fn() },
|
||||
menstrualCycle: { findMany: vi.fn() },
|
||||
cycleDayLog: { findMany: vi.fn() },
|
||||
cycleSymptom: { findMany: vi.fn() },
|
||||
// v1.28 backup-completeness — the records section the full-backup
|
||||
// helper now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn() },
|
||||
@@ -408,6 +409,7 @@ describe("GET /api/export/full-backup", () => {
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.biomarker.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
@@ -476,6 +478,7 @@ describe("GET /api/export/full-backup", () => {
|
||||
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.cycleSymptom.findMany).mockResolvedValue([] as never);
|
||||
|
||||
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("@/lib/db", () => ({
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleSymptom: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
// v1.28 backup-completeness — the records section the shared payload
|
||||
// builder now also reads (`buildRecordsBackupSection`).
|
||||
labResult: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
|
||||
@@ -26,6 +26,19 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
|
||||
updatedAt,
|
||||
}),
|
||||
},
|
||||
cycleSymptom: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "sym-custom",
|
||||
key: "custom_ache",
|
||||
labelKey: "cycle.symptom.custom_ache",
|
||||
categoryId: "cat-1",
|
||||
icon: "Activity",
|
||||
sortOrder: 3,
|
||||
isActive: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
menstrualCycle: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -151,3 +164,68 @@ describe("buildCycleBackupSection disaster-recovery mode", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a symptom the account created survives the round trip", () => {
|
||||
/**
|
||||
* The defect this pins: the seeded symptom catalogue is reference data every
|
||||
* instance already has, but a symptom the user made exists only in their
|
||||
* account. It was never carried, so on restore its key resolved to nothing —
|
||||
* and the link was silently filtered out. The day-log came back, one of its
|
||||
* symptoms did not, and the restore reported success.
|
||||
*/
|
||||
function client(customSymptoms: unknown[]) {
|
||||
return {
|
||||
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
cycleSymptom: { findMany: vi.fn().mockResolvedValue(customSymptoms) },
|
||||
} as never;
|
||||
}
|
||||
|
||||
it("carries the account's own symptom definitions", async () => {
|
||||
const section = await buildCycleBackupSection(
|
||||
client([
|
||||
{
|
||||
id: "sym-1",
|
||||
key: "custom_ache",
|
||||
labelKey: "cycle.symptom.custom_ache",
|
||||
categoryId: "cat-1",
|
||||
icon: null,
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
},
|
||||
]),
|
||||
"user-1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
|
||||
expect(section.customSymptoms).toHaveLength(1);
|
||||
expect(section.customSymptoms[0]).toMatchObject({
|
||||
key: "custom_ache",
|
||||
labelKey: "cycle.symptom.custom_ache",
|
||||
categoryId: "cat-1",
|
||||
sortOrder: 2,
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("asks only for the account's own rows, never the seeded catalogue", async () => {
|
||||
// Carrying the seeded rows would let one instance's restore rewrite
|
||||
// another instance's reference data.
|
||||
const c = client([]);
|
||||
await buildCycleBackupSection(c, "user-1", {});
|
||||
expect(
|
||||
(c as unknown as { cycleSymptom: { findMany: ReturnType<typeof vi.fn> } })
|
||||
.cycleSymptom.findMany,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { userId: "user-1" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes an empty list rather than omitting the section", async () => {
|
||||
// An absent key and an empty list read the same to a careless consumer.
|
||||
// The restore distinguishes them, so the builder must be explicit.
|
||||
const section = await buildCycleBackupSection(client([]), "user-1", {});
|
||||
expect(section.customSymptoms).toEqual([]);
|
||||
});
|
||||
});
|
||||
+83
-6
@@ -92,6 +92,23 @@ export interface CycleBackupSection {
|
||||
updatedAt?: string;
|
||||
symptomKeys: string[];
|
||||
}>;
|
||||
/**
|
||||
* The account's OWN symptom definitions.
|
||||
*
|
||||
* The seeded catalogue is reference data every instance already has, but a
|
||||
* symptom the user created exists only here. Without it the restore looks up
|
||||
* a key that resolves to nothing and drops the link — the day-log comes back
|
||||
* with one of its symptoms quietly missing.
|
||||
*/
|
||||
customSymptoms: Array<{
|
||||
id?: string;
|
||||
key: string;
|
||||
labelKey: string;
|
||||
categoryId: string;
|
||||
icon: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,12 +117,15 @@ export interface CycleBackupSection {
|
||||
* client) so both writers share one read.
|
||||
*/
|
||||
export async function buildCycleBackupSection(
|
||||
prisma: Pick<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
|
||||
prisma: Pick<
|
||||
PrismaClient,
|
||||
"cycleProfile" | "menstrualCycle" | "cycleDayLog" | "cycleSymptom"
|
||||
>,
|
||||
userId: string,
|
||||
options: CycleBackupOptions = {},
|
||||
): Promise<CycleBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
const [profile, cycles, dayLogs] = await Promise.all([
|
||||
const [profile, cycles, dayLogs, customSymptoms] = await Promise.all([
|
||||
prisma.cycleProfile.findUnique({ where: { userId } }),
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: disasterRecovery
|
||||
@@ -120,6 +140,12 @@ export async function buildCycleBackupSection(
|
||||
symptomLinks: { include: { symptom: { select: { key: true } } } },
|
||||
},
|
||||
}),
|
||||
// `userId` set means the user made it. The NULL rows are the seeded
|
||||
// catalogue, which the restoring instance already has.
|
||||
prisma.cycleSymptom.findMany({
|
||||
where: { userId },
|
||||
orderBy: { key: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -196,6 +222,15 @@ export async function buildCycleBackupSection(
|
||||
tz: d.tz,
|
||||
symptomKeys: d.symptomLinks.map((l) => l.symptom.key),
|
||||
})),
|
||||
customSymptoms: customSymptoms.map((sym) => ({
|
||||
...(disasterRecovery ? { id: sym.id } : {}),
|
||||
key: sym.key,
|
||||
labelKey: sym.labelKey,
|
||||
categoryId: sym.categoryId,
|
||||
icon: sym.icon,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,7 +388,30 @@ export async function restoreCycleData(
|
||||
restoredCycleIds.add(created.id);
|
||||
}
|
||||
|
||||
// Resolve the seeded symptom catalogue once for the link re-creation.
|
||||
// Re-create the account's own symptom definitions BEFORE resolving links.
|
||||
// The seeded catalogue is already on this instance; a symptom the user made
|
||||
// exists only in the file, and a link cannot resolve to a row that was never
|
||||
// written back.
|
||||
for (const sym of payload.customSymptoms ?? []) {
|
||||
await tx.cycleSymptom.upsert({
|
||||
where: { key: sym.key },
|
||||
create: {
|
||||
...(sym.id ? { id: sym.id } : {}),
|
||||
userId: ownerId,
|
||||
key: sym.key,
|
||||
labelKey: sym.labelKey,
|
||||
categoryId: sym.categoryId,
|
||||
icon: sym.icon ?? null,
|
||||
sortOrder: sym.sortOrder,
|
||||
isActive: sym.isActive,
|
||||
},
|
||||
// `key` is globally unique, so a seeded key would collide. Leave the
|
||||
// catalogue row alone: the account's links resolve against it either way.
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the symptom catalogue once for the link re-creation.
|
||||
const allKeys = Array.from(
|
||||
new Set(payload.cycleDayLogs.flatMap((d) => d.symptomKeys ?? [])),
|
||||
);
|
||||
@@ -367,6 +425,20 @@ export async function restoreCycleData(
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
for (const r of rows) symptomIdByKey.set(r.key, r.id);
|
||||
|
||||
// Three lines below, an unknown CYCLE reference throws. An unknown symptom
|
||||
// used to be filtered out instead: the day-log came back with one of its
|
||||
// symptoms quietly missing, and the restore reported success. Same
|
||||
// function, same class of missing reference, two different answers — so
|
||||
// this one is loud now too.
|
||||
const unresolved = allKeys.filter((k) => !symptomIdByKey.has(k));
|
||||
if (unresolved.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown cycle symptom keys: ${unresolved.join(", ")}. ` +
|
||||
"The backup references symptoms that are neither in this instance's " +
|
||||
"seeded catalogue nor carried in the file.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Recreate day-logs (with symptom links). The owning cycle is the latest
|
||||
@@ -384,9 +456,14 @@ export async function restoreCycleData(
|
||||
};
|
||||
|
||||
for (const d of payload.cycleDayLogs) {
|
||||
const symptomIds = (d.symptomKeys ?? [])
|
||||
.map((k) => symptomIdByKey.get(k))
|
||||
.filter((v): v is string => v !== undefined);
|
||||
// Every key was proven resolvable above, so this maps rather than filters:
|
||||
// a `.filter(Boolean)` here would silently re-open the hole that check
|
||||
// just closed.
|
||||
const symptomIds = (d.symptomKeys ?? []).map((k) => {
|
||||
const id = symptomIdByKey.get(k);
|
||||
if (!id) throw new Error(`Unresolved cycle symptom key: ${k}`);
|
||||
return id;
|
||||
});
|
||||
const cycleId =
|
||||
d.cycleId !== undefined
|
||||
? d.cycleId === null
|
||||
|
||||
@@ -314,6 +314,27 @@ const cycleProfileSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A symptom the account created, as opposed to the seeded catalogue.
|
||||
*
|
||||
* Carried because a day-log's symptom link resolves by key: without the
|
||||
* definition the key resolves to nothing on the restoring instance, and the
|
||||
* link is lost. The seeded rows are deliberately NOT carried — every instance
|
||||
* already has them, and shipping them would mean a restore could rewrite
|
||||
* another instance's reference data.
|
||||
*/
|
||||
const customCycleSymptomSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
key: z.string().min(1),
|
||||
labelKey: z.string().min(1),
|
||||
categoryId: z.string().min(1),
|
||||
icon: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
isActive: z.boolean().default(true),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const appSettingsBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
@@ -561,6 +582,9 @@ export const backupPayloadSchema = z
|
||||
cycleProfile: cycleProfileSchema.nullable().default(null),
|
||||
cycles: z.array(cycleSpanSchema).default([]),
|
||||
cycleDayLogs: z.array(cycleDayLogSchema).default([]),
|
||||
// Defaulted rather than required: files written before this field existed
|
||||
// are still valid, and an account with no custom symptoms writes [].
|
||||
customSymptoms: z.array(customCycleSymptomSchema).default([]),
|
||||
// Structured records default to empty arrays so older backups remain
|
||||
// parseable. Canonical DR writers add stable ids and encrypted document
|
||||
// fields; portable exports retain the metadata-only subset.
|
||||
|
||||
Reference in new issue
Block a user