fix(backup): carry deleted family-history entries in the disaster-recovery file

Every record with a tombstone (allergies, labs, conditions, vaccinations,
life events, measurements and the rest) rides the disaster-recovery file
deleted rows included, so a restore gives back the account as it was. The
family history read only live entries for both files, so a restore lost
its deletions, and it carried the note as readable text that the restore
sealed again under the active key.

The disaster-recovery slice now carries every entry with its deletion and
the note as the ciphertext at rest, and the restore writes both back
verbatim; the portable file keeps the readable live entries. The parity
golden copies change for exactly that: the deleted entry, its note's key
and the shifted ids after it.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 18:59:07 +02:00
1 parent faedcbba93
commit 3b2c2c45f3
8 files changed
+576 -423

No files matched your search

@@ -8,7 +8,7 @@
*/
import { z } from "zod/v4";
import { FamilyRelationship } from "@/generated/prisma/enums";
import { isoDateTime } from "@/lib/export/backup-schema-kit";
import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit";
export const familyHistoryBackupSchema = z
.object({
@@ -17,7 +17,9 @@ export const familyHistoryBackupSchema = z
condition: z.string().min(1),
ageAtOnset: z.number().int().nullable().optional(),
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
+52 -6
View File
@@ -1,26 +1,61 @@
/**
* The family history, with both backup ends in one file.
*/
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
toFamilyHistoryEntryDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
import type { RestoreContext } from "@/lib/export/restore-context";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
/** The family-history slice of the records section. */
/**
* A family-history entry as the disaster-recovery file carries it: the note
* as the ciphertext at rest, and the deletion. The portable file carries the
* readable DTO of the live entries only.
*/
export type CanonicalFamilyHistoryEntry = FamilyHistoryEntryDTO & {
notesEncrypted?: string | null;
deletedAt?: string | null;
};
/**
* The family-history slice of the records section. A disaster-recovery file
* carries deleted entries too, as every other record with a tombstone does:
* a restore from it has to give back the account as it was, deletions
* included, and the file used to drop them.
*/
export async function buildFamilyHistoryBackupSlice(
prisma: Pick<PrismaClient, "familyHistoryEntry">,
userId: string,
): Promise<{ familyHistory: FamilyHistoryEntryDTO[] }> {
disasterRecovery: boolean,
): Promise<{ familyHistory: CanonicalFamilyHistoryEntry[] }> {
const familyRows = await prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
});
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const familyHistory: CanonicalFamilyHistoryEntry[] = disasterRecovery
? familyRows.map((row) => ({
id: row.id,
relationship: row.relationship,
condition: row.condition,
ageAtOnset: row.ageAtOnset,
note: null,
notesEncrypted: row.notesEncrypted
? Buffer.from(row.notesEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
}))
: familyRows.map(toFamilyHistoryEntryDTO);
return { familyHistory };
}
@@ -44,8 +79,19 @@ export async function restoreFamilyHistory(ctx: RestoreContext): Promise<void> {
relationship: familyEntry.relationship as never,
condition: familyEntry.condition,
ageAtOnset: familyEntry.ageAtOnset ?? null,
// The ciphertext verbatim when the file carries it, the readable note
// sealed here otherwise.
notesEncrypted:
familyEntry.note == null ? null : encryptToBytes(familyEntry.note),
familyEntry.notesEncrypted !== undefined
? familyEntry.notesEncrypted === null
? null
: decodeEncryptedBytes(familyEntry.notesEncrypted)
: familyEntry.note == null
? null
: encryptToBytes(familyEntry.note),
deletedAt: familyEntry.deletedAt
? new Date(familyEntry.deletedAt)
: null,
...(familyEntry.createdAt
? { createdAt: new Date(familyEntry.createdAt) }
: {}),
+1 -1
View File
@@ -122,7 +122,7 @@ export async function buildRecordsBackupSection(
buildLabsBackupSlice(prisma, userId, disasterRecovery),
buildIllnessBackupSlice(prisma, userId, disasterRecovery),
buildAllergiesBackupSlice(prisma, userId, disasterRecovery),
buildFamilyHistoryBackupSlice(prisma, userId),
buildFamilyHistoryBackupSlice(prisma, userId, disasterRecovery),
buildWorkoutsBackupSlice(prisma, userId, disasterRecovery),
buildDocumentsBackupSlice(
prisma,
File diff suppressed because it is too large. Load diff
@@ -22,7 +22,7 @@
"illnessEpisodes": 4,
"illnessDayLogs": 2,
"allergies": 2,
"familyHistory": 1,
"familyHistory": 2,
"workouts": 1,
"documents": 1,
"documentConditionLinks": 1,
@@ -78,7 +78,7 @@
"nutrientDays": 1,
"illnessEpisodes": 4,
"allergies": 2,
"familyHistory": 1,
"familyHistory": 2,
"workouts": 1,
"documents": 1,
"documentConditionLinks": 0,
@@ -294,7 +294,7 @@
"nutrientDays": 1,
"illnessEpisodes": 4,
"allergies": 2,
"familyHistory": 1,
"familyHistory": 2,
"workouts": 1,
"documents": 1,
"documentConditionLinks": 0,
@@ -350,7 +350,7 @@
"illnessEpisodes": 4,
"illnessDayLogs": 2,
"allergies": 2,
"familyHistory": 1,
"familyHistory": 2,
"workouts": 1,
"documents": 1,
"healthProfile": 1,
File diff suppressed because it is too large. Load diff
@@ -1707,9 +1707,11 @@
"relationship": "FATHER",
"condition": "Diabetes",
"ageAtOnset": 55,
"note": "type 2",
"note": null,
"notesEncrypted": "<sealed:v2:type 2>",
"createdAt": "<now>",
"updatedAt": "<now>"
"updatedAt": "<now>",
"deletedAt": null
}
],
"workouts": [
@@ -0,0 +1,53 @@
/**
* The disaster-recovery file carries deleted family-history entries, as it
* carries every other record's tombstones, and carries their note as the
* ciphertext at rest. The portable file carries the live entries only, with
* the note readable. The full round trip, the restored tombstone included, is
* pinned by the backup parity golden copies.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { buildFamilyHistoryBackupSlice } from "@/lib/export/family-history-backup";
import { makeUser } from "../kit/factories";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
beforeEach(() => truncateAllTables(prisma));
describe("family history in the backup", () => {
it("keeps a deleted entry in the disaster-recovery file and out of the portable one", async () => {
const user = await makeUser();
const notes = encryptToBytes("diagnosed at fifty");
await prisma.familyHistoryEntry.create({
data: { userId: user.id, relationship: "MOTHER", condition: "Asthma" },
});
const deleted = await prisma.familyHistoryEntry.create({
data: {
userId: user.id,
relationship: "FATHER",
condition: "Hypertension",
notesEncrypted: notes,
deletedAt: new Date("2026-09-30T10:00:00.000Z"),
},
});
const dr = await buildFamilyHistoryBackupSlice(prisma, user.id, true);
expect(dr.familyHistory).toHaveLength(2);
const tombstone = dr.familyHistory.find((e) => e.id === deleted.id);
expect(tombstone?.deletedAt).toBe("2026-09-30T10:00:00.000Z");
expect(tombstone?.note).toBeNull();
expect(tombstone?.notesEncrypted).toBe(
Buffer.from(notes).toString("base64"),
);
const portable = await buildFamilyHistoryBackupSlice(
prisma,
user.id,
false,
);
expect(portable.familyHistory.map((e) => e.condition)).toEqual(["Asthma"]);
});
});