fix(backups): preserve canonical restore identity

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 13:25:37 +02:00
1 parent 8b7cbddd72
commit cd6b5f0cc6
10 files changed
+1722 -215

No files matched your search

+250 -54
View File
@@ -329,43 +329,141 @@ const handler = apiHandler(
where: { userId: ownerId },
});
// Stable ids are authoritative for canonical snapshots. Legacy payloads
// without ids fall back to the historic natural measurement key.
for (const measurement of payload.measurements) {
const measuredAt = new Date(measurement.measuredAt);
const source = measurement.source ?? "MANUAL";
const restoredData = {
type: measurement.type,
value: measurement.value,
unit: measurement.unit,
source,
measuredAt,
notes: null,
notesEncrypted: encryptNote(measurement.notes ?? null),
deletedAt: measurement.deletedAt
? new Date(measurement.deletedAt)
if (payload.appSettings) {
const settings = payload.appSettings;
const settingsData = {
registrationEnabled: settings.registrationEnabled,
mfaRequired: settings.mfaRequired,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
webPushGlobal: settings.webPushGlobal,
webPushVapidPublicKey: settings.webPushVapidPublicKey,
webPushVapidPrivateKeyEncrypted:
settings.webPushVapidPrivateKeyEncrypted,
webPushVapidSubject: settings.webPushVapidSubject,
apiGlobal: settings.apiGlobal,
moodLogGlobal: settings.moodLogGlobal,
umamiEnabled: settings.umamiEnabled,
umamiScriptUrl: settings.umamiScriptUrl,
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
adminAiKeyEncrypted: settings.adminAiKeyEncrypted,
adminAiModel: settings.adminAiModel,
adminAiBaseUrl: settings.adminAiBaseUrl,
adminCodexAccessTokenEncrypted:
settings.adminCodexAccessTokenEncrypted,
adminCodexRefreshTokenEncrypted:
settings.adminCodexRefreshTokenEncrypted,
adminCodexAccountIdEncrypted:
settings.adminCodexAccountIdEncrypted,
adminCodexTokenExpiresAt: settings.adminCodexTokenExpiresAt
? new Date(settings.adminCodexTokenExpiresAt)
: null,
adminCodexConnectedAt: settings.adminCodexConnectedAt
? new Date(settings.adminCodexConnectedAt)
: null,
adminCodexConnectionStatus:
settings.adminCodexConnectionStatus,
adminAiInsightsFeedbackSummary:
settings.adminAiInsightsFeedbackSummary as never,
defaultUserTimezone: settings.defaultUserTimezone,
assistantEnabled: settings.assistantEnabled,
assistantCoachEnabled: settings.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled:
settings.assistantInsightStatusEnabled,
assistantCorrelationsEnabled:
settings.assistantCorrelationsEnabled,
assistantHealthScoreExplainerEnabled:
settings.assistantHealthScoreExplainerEnabled,
moduleAvailabilityJson:
settings.moduleAvailabilityJson as never,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
};
await tx.appSettings.upsert({
where: { id: settings.id },
create: { id: settings.id, ...settingsData },
update: settingsData,
});
}
if (measurement.id) {
await tx.measurement.upsert({
where: { id: measurement.id, userId: ownerId },
create: {
id: measurement.id,
userId: ownerId,
...restoredData,
},
update: restoredData,
});
continue;
}
const toRestoredMeasurementData = (
measurement: (typeof payload.measurements)[number],
) => ({
type: measurement.type,
value: measurement.value,
valueMin: measurement.valueMin ?? null,
valueMax: measurement.valueMax ?? null,
unit: measurement.unit,
source: measurement.source ?? "MANUAL",
measuredAt: new Date(measurement.measuredAt),
notes: measurement.notes ?? null,
notesEncrypted:
measurement.notesEncrypted !== undefined
? measurement.notesEncrypted === null
? null
: decodeEncryptedBytes(measurement.notesEncrypted)
: encryptNote(measurement.notes ?? null),
externalId: measurement.externalId ?? null,
externalSourceVersion: measurement.externalSourceVersion ?? null,
glucoseContext: (measurement.glucoseContext ?? null) as never,
sleepStage: (measurement.sleepStage ?? null) as never,
rhythmClassification: (measurement.rhythmClassification ??
null) as never,
deviceType: measurement.deviceType ?? null,
syncVersion: measurement.syncVersion ?? 1,
deletedAt: measurement.deletedAt
? new Date(measurement.deletedAt)
: null,
...(measurement.createdAt
? { createdAt: new Date(measurement.createdAt) }
: {}),
...(measurement.updatedAt
? { updatedAt: new Date(measurement.updatedAt) }
: {}),
});
const stableRows = payload.measurements.flatMap((measurement) =>
measurement.id
? [
{
id: measurement.id,
userId: ownerId,
...toRestoredMeasurementData(measurement),
},
]
: [],
);
const measurementBatchSize = 1_000;
for (
let offset = 0;
offset < stableRows.length;
offset += measurementBatchSize
) {
await tx.measurement.createMany({
data: stableRows.slice(offset, offset + measurementBatchSize),
});
}
// v1 payloads did not require stable ids. Preserve their historical
// natural-key reconciliation without routing canonical v2 rows
// through it.
for (const measurement of payload.measurements) {
if (measurement.id) continue;
const restoredData = toRestoredMeasurementData(measurement);
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: measurement.type,
source,
measuredAt,
source: restoredData.source,
measuredAt: restoredData.measuredAt,
sleepStage: restoredData.sleepStage,
},
select: { id: true },
});
@@ -381,50 +479,97 @@ const handler = apiHandler(
}
}
// Medications + schedules — restore one at a time so we can
// wire schedules to their parent's freshly-generated id, AND
// build a name → id map for the intake events that follow.
const medByName = new Map<string, string>();
const restoredMedicationIds = new Set<string>();
for (const m of payload.medications) {
const created = await tx.medication.create({
data: {
...(m.id ? { id: m.id } : {}),
userId: ownerId,
name: m.name,
dose: m.dose,
treatmentClass: m.treatmentClass ?? "GENERIC",
dosesPerUnit: m.dosesPerUnit ?? null,
unitsPerDose: m.unitsPerDose ?? "1",
active: m.active ?? true,
notificationsEnabled: m.notificationsEnabled ?? true,
pausedAt: m.pausedAt ? new Date(m.pausedAt) : null,
snoozedUntil: m.snoozedUntil
? new Date(m.snoozedUntil)
: null,
startsOn: m.startsOn ? new Date(m.startsOn) : null,
endsOn: m.endsOn ? new Date(m.endsOn) : null,
oneShot: m.oneShot ?? false,
asNeeded: m.asNeeded ?? false,
deliveryForm: m.deliveryForm ?? "ORAL",
trackInjectionSites: m.trackInjectionSites ?? false,
allowedInjectionSites: m.allowedInjectionSites ?? [],
liveActivityEnabled: m.liveActivityEnabled ?? false,
criticalAlarmEnabled: m.criticalAlarmEnabled ?? false,
atcCode: m.atcCode ?? null,
rxNormCode: m.rxNormCode ?? null,
lowStockNotifiedAt: m.lowStockNotifiedAt
? new Date(m.lowStockNotifiedAt)
: null,
lowStockNotifiedThresholdDays:
m.lowStockNotifiedThresholdDays ?? null,
reorderLeadDays: m.reorderLeadDays ?? null,
externalSource: m.externalSource ?? null,
externalId: m.externalId ?? null,
...(m.createdAt ? { createdAt: new Date(m.createdAt) } : {}),
...(m.updatedAt ? { updatedAt: new Date(m.updatedAt) } : {}),
schedules: {
create: m.schedules.map((s) => ({
...(s.id ? { id: s.id } : {}),
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: s.daysOfWeek ?? null,
timesOfDay: s.timesOfDay ?? [],
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
rrule: s.rrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: (s.doseWindows ?? null) as never,
})),
},
},
});
// First write wins on duplicate names — keeps the round-trip
// deterministic against the restore-from-our-own-download case.
restoredMedicationIds.add(created.id);
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
}
// Intake events — only restore the ones whose `medication` name
// resolved against a row we just created. Orphans (medications
// that were deleted before the snapshot but still referenced by
// intake events) are dropped silently — the alternative would
// be an FK-violation crash, which is worse than a slightly
// smaller history.
if (payload.intakeEvents.length > 0) {
const rows = payload.intakeEvents
.map((e) => {
const medId = medByName.get(e.medication);
const medId =
e.medicationId && restoredMedicationIds.has(e.medicationId)
? e.medicationId
: medByName.get(e.medication);
if (!medId) return null;
return {
...(e.id ? { id: e.id } : {}),
userId: ownerId,
medicationId: medId,
scheduledFor: new Date(e.scheduledFor),
takenAt: e.takenAt ? new Date(e.takenAt) : null,
skipped: e.skipped ?? false,
source: (e.source ?? "WEB") as never,
autoMissed: e.autoMissed ?? false,
attributionSource: e.attributionSource ?? "AUTO",
source: e.source ?? "WEB",
idempotencyKey: e.idempotencyKey ?? null,
...(e.createdAt ? { createdAt: new Date(e.createdAt) } : {}),
injectionSite: e.injectionSite ?? null,
doseTaken: e.doseTaken ?? null,
inventoryConsumption: (e.inventoryConsumption ??
null) as never,
externalId: e.externalId ?? null,
...(e.updatedAt ? { updatedAt: new Date(e.updatedAt) } : {}),
syncVersion: e.syncVersion ?? 0,
deletedAt: e.deletedAt ? new Date(e.deletedAt) : null,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
@@ -526,6 +671,7 @@ const handler = apiHandler(
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
const biomarkerByName = new Map<string, string>();
const restoredBiomarkerIds = new Set<string>();
for (const biomarker of payload.biomarkers) {
const created = await tx.biomarker.create({
data: {
@@ -550,15 +696,23 @@ const handler = apiHandler(
},
});
biomarkerByName.set(biomarker.name, created.id);
restoredBiomarkerIds.add(created.id);
}
for (const lab of payload.labResults) {
const biomarkerId = lab.biomarkerName
? biomarkerByName.get(lab.biomarkerName)
: undefined;
if (lab.biomarkerName && !biomarkerId) {
const biomarkerId =
lab.biomarkerId !== undefined
? lab.biomarkerId === null
? null
: restoredBiomarkerIds.has(lab.biomarkerId)
? lab.biomarkerId
: undefined
: lab.biomarkerName
? biomarkerByName.get(lab.biomarkerName)
: null;
if (biomarkerId === undefined) {
throw new Error(
`Unknown biomarker reference: ${lab.biomarkerName}`,
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
);
}
await tx.labResult.create({
@@ -576,7 +730,14 @@ const handler = apiHandler(
takenAt: new Date(lab.takenAt),
source: lab.source,
noteEncrypted:
lab.note == null ? null : encryptNoteToBytes(lab.note),
lab.noteEncrypted !== undefined
? lab.noteEncrypted === null
? null
: decodeEncryptedBytes(lab.noteEncrypted)
: lab.note == null
? null
: encryptNoteToBytes(lab.note),
deletedAt: lab.deletedAt ? new Date(lab.deletedAt) : null,
...(lab.createdAt
? { createdAt: new Date(lab.createdAt) }
: {}),
@@ -612,7 +773,16 @@ const handler = apiHandler(
: null,
parentConditionId: null,
noteEncrypted:
episode.note == null ? null : encryptToBytes(episode.note),
episode.noteEncrypted !== undefined
? episode.noteEncrypted === null
? null
: decodeEncryptedBytes(episode.noteEncrypted)
: episode.note == null
? null
: encryptToBytes(episode.note),
deletedAt: episode.deletedAt
? new Date(episode.deletedAt)
: null,
...(episode.createdAt
? { createdAt: new Date(episode.createdAt) }
: {}),
@@ -667,7 +837,20 @@ const handler = apiHandler(
functionalImpact: dayLog.functionalImpact ?? null,
feverC: dayLog.feverC ?? null,
noteEncrypted:
dayLog.note == null ? null : encryptToBytes(dayLog.note),
dayLog.noteEncrypted !== undefined
? dayLog.noteEncrypted === null
? null
: decodeEncryptedBytes(dayLog.noteEncrypted)
: dayLog.note == null
? null
: encryptToBytes(dayLog.note),
tz: dayLog.tz ?? null,
deletedAt: dayLog.deletedAt
? new Date(dayLog.deletedAt)
: null,
...(dayLog.createdAt
? { createdAt: new Date(dayLog.createdAt) }
: {}),
...(dayLog.updatedAt
? { updatedAt: new Date(dayLog.updatedAt) }
: {}),
@@ -694,11 +877,24 @@ const handler = apiHandler(
status: allergy.status as never,
onsetAt: allergy.onsetAt ? new Date(allergy.onsetAt) : null,
reactionEncrypted:
allergy.reaction == null
? null
: encryptToBytes(allergy.reaction),
allergy.reactionEncrypted !== undefined
? allergy.reactionEncrypted === null
? null
: decodeEncryptedBytes(allergy.reactionEncrypted)
: allergy.reaction == null
? null
: encryptToBytes(allergy.reaction),
notesEncrypted:
allergy.note == null ? null : encryptToBytes(allergy.note),
allergy.notesEncrypted !== undefined
? allergy.notesEncrypted === null
? null
: decodeEncryptedBytes(allergy.notesEncrypted)
: allergy.note == null
? null
: encryptToBytes(allergy.note),
deletedAt: allergy.deletedAt
? new Date(allergy.deletedAt)
: null,
...(allergy.createdAt
? { createdAt: new Date(allergy.createdAt) }
: {}),
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";
import { buildCycleBackupSection } from "../backup";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const createdAt = new Date("2026-07-01T08:00:00.000Z");
const updatedAt = new Date("2026-07-19T11:00:00.000Z");
describe("buildCycleBackupSection disaster-recovery mode", () => {
it("preserves stable ids, reconciliation fields, and tombstones", async () => {
const prisma = {
cycleProfile: {
findUnique: vi.fn().mockResolvedValue({
id: "profile-dr",
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
secondarySymptom: "MUCUS",
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: true,
sensitiveCategoryEncryption: true,
createdAt,
updatedAt,
}),
},
menstrualCycle: {
findMany: vi.fn().mockResolvedValue([
{
id: "cycle-dr",
startDate: "2026-07-01",
endDate: "2026-07-28",
periodEndDate: "2026-07-05",
lengthDays: 28,
ovulationDate: "2026-07-14",
ovulationConfirmed: true,
isPredicted: false,
tz: "Europe/London",
syncVersion: 5,
deletedAt,
createdAt,
updatedAt,
},
]),
},
cycleDayLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "cycle-day-dr",
date: "2026-07-02",
cycleId: "cycle-dr",
flow: "HEAVY",
intermenstrualBleeding: false,
basalBodyTempC: 36.7,
temperatureExcluded: true,
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: "sensitive-ciphertext",
notesEncrypted: "notes-ciphertext",
source: "APPLE_HEALTH",
externalId: "cycle-day-external",
tz: "Europe/London",
syncVersion: 9,
deletedAt,
createdAt,
updatedAt,
symptomLinks: [{ symptom: { key: "cramps" } }],
},
]),
},
};
const section = await buildCycleBackupSection(
prisma as never,
"user-1",
{ purpose: "disaster-recovery" },
);
expect(prisma.menstrualCycle.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
expect(prisma.cycleDayLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
);
expect(section.cycleProfile).toEqual({
id: "profile-dr",
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
secondarySymptom: "MUCUS",
predictionEnabled: true,
rawChartMode: false,
discreetNotifications: true,
sensitiveCategoryEncryption: true,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
});
expect(section.cycles[0]).toEqual({
id: "cycle-dr",
startDate: "2026-07-01",
endDate: "2026-07-28",
periodEndDate: "2026-07-05",
lengthDays: 28,
ovulationDate: "2026-07-14",
ovulationConfirmed: true,
isPredicted: false,
tz: "Europe/London",
syncVersion: 5,
deletedAt: deletedAt.toISOString(),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
});
expect(section.cycleDayLogs[0]).toEqual({
id: "cycle-day-dr",
date: "2026-07-02",
cycleId: "cycle-dr",
flow: "HEAVY",
intermenstrualBleeding: false,
basalBodyTempC: 36.7,
temperatureExcluded: true,
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
protectedSex: null,
pregnancyTest: null,
progesteroneTest: null,
contraceptive: null,
sensitiveEncrypted: "sensitive-ciphertext",
notesEncrypted: "notes-ciphertext",
source: "APPLE_HEALTH",
externalId: "cycle-day-external",
tz: "Europe/London",
syncVersion: 9,
deletedAt: deletedAt.toISOString(),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
symptomKeys: ["cramps"],
});
});
});
+79 -5
View File
@@ -28,8 +28,13 @@ import type {
import type { BackupPayload } from "@/lib/validations/backup";
/** The cycle slice of a backup payload. */
export interface CycleBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
export interface CycleBackupSection {
cycleProfile: {
id?: string;
goal: string;
cycleTrackingEnabled: boolean | null;
typicalCycleLength: number | null;
@@ -40,8 +45,11 @@ export interface CycleBackupSection {
rawChartMode: boolean;
discreetNotifications: boolean;
sensitiveCategoryEncryption: boolean;
createdAt?: string;
updatedAt?: string;
} | null;
cycles: Array<{
id?: string;
startDate: string;
endDate: string | null;
periodEndDate: string | null;
@@ -49,8 +57,15 @@ export interface CycleBackupSection {
ovulationDate: string | null;
ovulationConfirmed: boolean;
tz: string | null;
isPredicted?: boolean;
syncVersion?: number;
deletedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}>;
cycleDayLogs: Array<{
id?: string;
cycleId?: string | null;
date: string;
flow: string | null;
intermenstrualBleeding: boolean;
@@ -71,6 +86,10 @@ export interface CycleBackupSection {
source: string;
externalId: string | null;
tz: string | null;
syncVersion?: number;
deletedAt?: string | null;
createdAt?: string;
updatedAt?: string;
symptomKeys: string[];
}>;
}
@@ -83,15 +102,19 @@ export interface CycleBackupSection {
export async function buildCycleBackupSection(
prisma: Pick<PrismaClient, "cycleProfile" | "menstrualCycle" | "cycleDayLog">,
userId: string,
options: CycleBackupOptions = {},
): Promise<CycleBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [profile, cycles, dayLogs] = await Promise.all([
prisma.cycleProfile.findUnique({ where: { userId } }),
prisma.menstrualCycle.findMany({
where: { userId, deletedAt: null, isPredicted: false },
where: disasterRecovery
? { userId }
: { userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { date: "asc" },
include: {
symptomLinks: { include: { symptom: { select: { key: true } } } },
@@ -102,6 +125,13 @@ export async function buildCycleBackupSection(
return {
cycleProfile: profile
? {
...(disasterRecovery
? {
id: profile.id,
createdAt: profile.createdAt.toISOString(),
updatedAt: profile.updatedAt.toISOString(),
}
: {}),
goal: profile.goal,
cycleTrackingEnabled: profile.cycleTrackingEnabled,
typicalCycleLength: profile.typicalCycleLength,
@@ -115,6 +145,16 @@ export async function buildCycleBackupSection(
}
: null,
cycles: cycles.map((c) => ({
...(disasterRecovery
? {
id: c.id,
isPredicted: c.isPredicted,
syncVersion: c.syncVersion,
deletedAt: c.deletedAt?.toISOString() ?? null,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
}
: {}),
startDate: c.startDate,
endDate: c.endDate,
periodEndDate: c.periodEndDate,
@@ -124,6 +164,16 @@ export async function buildCycleBackupSection(
tz: c.tz,
})),
cycleDayLogs: dayLogs.map((d) => ({
...(disasterRecovery
? {
id: d.id,
cycleId: d.cycleId,
syncVersion: d.syncVersion,
deletedAt: d.deletedAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
}
: {}),
date: d.date,
flow: d.flow,
intermenstrualBleeding: d.intermenstrualBleeding,
@@ -139,7 +189,6 @@ export async function buildCycleBackupSection(
pregnancyTest: d.pregnancyTest,
progesteroneTest: d.progesteroneTest,
contraceptive: d.contraceptive,
// Both ciphertext envelopes carried verbatim — never decrypted here.
sensitiveEncrypted: d.sensitiveEncrypted,
notesEncrypted: d.notesEncrypted,
source: d.source,
@@ -258,6 +307,7 @@ export async function restoreCycleData(
const p = payload.cycleProfile;
await tx.cycleProfile.create({
data: {
...(p.id ? { id: p.id } : {}),
userId: ownerId,
goal: enumOrNull(p.goal, CYCLE_GOALS) ?? "GENERAL_HEALTH",
cycleTrackingEnabled: p.cycleTrackingEnabled ?? null,
@@ -270,6 +320,8 @@ export async function restoreCycleData(
rawChartMode: p.rawChartMode ?? false,
discreetNotifications: p.discreetNotifications ?? false,
sensitiveCategoryEncryption: p.sensitiveCategoryEncryption ?? true,
...(p.createdAt ? { createdAt: new Date(p.createdAt) } : {}),
...(p.updatedAt ? { updatedAt: new Date(p.updatedAt) } : {}),
},
});
}
@@ -277,9 +329,11 @@ export async function restoreCycleData(
// Recreate observed cycle spans; map startDate → id so day-logs can
// re-attach to their owning span.
const cycleIdByStart = new Map<string, string>();
const restoredCycleIds = new Set<string>();
for (const c of payload.cycles) {
const created = await tx.menstrualCycle.create({
data: {
...(c.id ? { id: c.id } : {}),
userId: ownerId,
startDate: c.startDate,
endDate: c.endDate ?? null,
@@ -288,10 +342,15 @@ export async function restoreCycleData(
ovulationDate: c.ovulationDate ?? null,
ovulationConfirmed: c.ovulationConfirmed ?? false,
tz: c.tz ?? null,
isPredicted: false,
isPredicted: c.isPredicted ?? false,
syncVersion: c.syncVersion ?? 0,
deletedAt: c.deletedAt ? new Date(c.deletedAt) : null,
...(c.createdAt ? { createdAt: new Date(c.createdAt) } : {}),
...(c.updatedAt ? { updatedAt: new Date(c.updatedAt) } : {}),
},
});
cycleIdByStart.set(c.startDate, created.id);
restoredCycleIds.add(created.id);
}
// Resolve the seeded symptom catalogue once for the link re-creation.
@@ -328,11 +387,22 @@ export async function restoreCycleData(
const symptomIds = (d.symptomKeys ?? [])
.map((k) => symptomIdByKey.get(k))
.filter((v): v is string => v !== undefined);
const cycleId =
d.cycleId !== undefined
? d.cycleId === null
? null
: restoredCycleIds.has(d.cycleId)
? d.cycleId
: (() => {
throw new Error(`Unknown cycle reference: ${d.cycleId}`);
})()
: owningCycleId(d.date);
await tx.cycleDayLog.create({
data: {
...(d.id ? { id: d.id } : {}),
userId: ownerId,
date: d.date,
cycleId: owningCycleId(d.date),
cycleId,
flow: enumOrNull(d.flow, FLOW_LEVELS),
intermenstrualBleeding: d.intermenstrualBleeding ?? false,
basalBodyTempC: d.basalBodyTempC ?? null,
@@ -353,6 +423,10 @@ export async function restoreCycleData(
source: enumOrNull(d.source, CYCLE_SOURCES) ?? "MANUAL",
externalId: d.externalId ?? null,
tz: d.tz ?? null,
syncVersion: d.syncVersion ?? 0,
deletedAt: d.deletedAt ? new Date(d.deletedAt) : null,
...(d.createdAt ? { createdAt: new Date(d.createdAt) } : {}),
...(d.updatedAt ? { updatedAt: new Date(d.updatedAt) } : {}),
...(symptomIds.length > 0
? {
symptomLinks: {
@@ -1,4 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { Buffer } from "node:buffer";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const mocks = vi.hoisted(() => ({
findMeasurementsPaged: vi.fn(),
@@ -29,11 +34,132 @@ vi.mock("@/lib/export/records-backup", () => ({
import { buildFullBackupPayload } from "../full-backup-payload";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const measurementNote = encryptToBytes("canonical measurement note");
const appSettings = {
id: "singleton",
registrationEnabled: false,
mfaRequired: true,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
webPushVapidPublicKey: "public-key",
webPushVapidPrivateKeyEncrypted: "encrypted-private-key",
webPushVapidSubject: "mailto:ops@example.test",
apiGlobal: false,
moodLogGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.test/script.js",
umamiWebsiteId: "website-id",
glitchtipEnabled: true,
glitchtipDsn: "https://dsn.example.test/1",
glitchtipEnvironment: "recovery",
reminderLateMinutes: 45,
reminderMissedMinutes: 90,
adminAiKeyEncrypted: "encrypted-ai-key",
adminAiModel: "gpt-test",
adminAiBaseUrl: "https://ai.example.test/v1",
adminCodexAccessTokenEncrypted: "encrypted-access",
adminCodexRefreshTokenEncrypted: "encrypted-refresh",
adminCodexAccountIdEncrypted: "encrypted-account",
adminCodexTokenExpiresAt: new Date("2026-07-21T08:00:00.000Z"),
adminCodexConnectedAt: new Date("2026-07-20T08:00:00.000Z"),
adminCodexConnectionStatus: "connected",
adminAiInsightsFeedbackSummary: { helpful: 7, total: 9 },
defaultUserTimezone: "Europe/London",
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantHealthScoreExplainerEnabled: false,
moduleAvailabilityJson: { nutrients: false, insights: true },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: 9_876_543_210n,
};
function makePrisma() {
return {
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
appSettings: { findUnique: vi.fn().mockResolvedValue(appSettings) },
medication: {
findMany: vi.fn().mockResolvedValue([
{
id: "medication-canonical",
name: "Example",
dose: "2 mg",
treatmentClass: "GENERIC",
dosesPerUnit: 4,
unitsPerDose: { toString: () => "0.5000" },
active: false,
notificationsEnabled: false,
pausedAt: new Date("2026-07-18T10:00:00.000Z"),
snoozedUntil: new Date("2026-07-22T10:00:00.000Z"),
startsOn: new Date("2026-07-01T00:00:00.000Z"),
endsOn: new Date("2026-08-01T00:00:00.000Z"),
oneShot: false,
asNeeded: false,
deliveryForm: "INJECTION",
trackInjectionSites: true,
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
liveActivityEnabled: true,
criticalAlarmEnabled: true,
atcCode: "A10BX10",
rxNormCode: "12345",
lowStockNotifiedAt: new Date("2026-07-17T10:00:00.000Z"),
lowStockNotifiedThresholdDays: 7,
reorderLeadDays: 3,
externalSource: "APPLE_HEALTH",
externalId: "med-external",
createdAt: new Date("2026-06-01T10:00:00.000Z"),
updatedAt: new Date("2026-07-19T10:00:00.000Z"),
schedules: [
{
id: "schedule-canonical",
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: "1 mg",
daysOfWeek: "i2;1,3,5",
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: 90,
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
rollingIntervalDays: null,
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
doseWindows: [
{ timeOfDay: "08:00", start: "07:30", end: "09:00" },
],
},
],
},
]),
},
medicationIntakeEvent: {
findMany: vi.fn().mockResolvedValue([
{
id: "intake-canonical",
medicationId: "medication-canonical",
medication: { name: "Example" },
scheduledFor: new Date("2026-07-19T08:00:00.000Z"),
takenAt: new Date("2026-07-19T08:05:00.000Z"),
skipped: false,
autoMissed: false,
attributionSource: "USER_PIN",
source: "APPLE_HEALTH",
idempotencyKey: "intake-idempotency",
createdAt: new Date("2026-07-19T08:05:00.000Z"),
injectionSite: "THIGH_LEFT",
doseTaken: "1 mg",
inventoryConsumption: [{ itemId: "pen-1", units: 0.5 }],
externalId: "intake-external",
updatedAt: new Date("2026-07-19T08:06:00.000Z"),
syncVersion: 4,
deletedAt,
},
]),
},
moodEntry: {
findMany: vi.fn().mockResolvedValue([
{
@@ -54,17 +180,29 @@ function makePrisma() {
}
function installSectionMocks() {
void measurementNote;
mocks.findMeasurementsPaged.mockResolvedValue([
{
id: "measurement-tombstone",
type: "WEIGHT",
type: "SLEEP_DURATION",
value: 75,
unit: "kg",
valueMin: 60,
valueMax: 90,
unit: "minutes",
measuredAt: new Date("2026-07-19T07:00:00.000Z"),
source: "MANUAL",
source: "IMPORT",
notes: null,
notesEncrypted: null,
notesEncrypted: measurementNote,
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 7,
deletedAt,
createdAt: new Date("2026-07-19T07:01:00.000Z"),
updatedAt: new Date("2026-07-19T07:02:00.000Z"),
},
]);
mocks.buildCycleBackupSection.mockResolvedValue({
@@ -118,14 +256,63 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
{ purpose: "disaster-recovery" },
);
expect(payload).toMatchObject({
schemaVersion: "1",
schemaVersion: "2",
exportedAt: "2026-07-20T00:00:00.000Z",
appSettings: {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt.toISOString(),
adminCodexConnectedAt: appSettings.adminCodexConnectedAt.toISOString(),
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
},
measurements: [
{
id: "measurement-tombstone",
type: "SLEEP_DURATION",
value: 75,
valueMin: 60,
valueMax: 90,
unit: "minutes",
measuredAt: "2026-07-19T07:00:00.000Z",
source: "IMPORT",
notesEncrypted: Buffer.from(measurementNote).toString("base64"),
externalId: "oura-stage-deep",
externalSourceVersion: "oura-v3",
glucoseContext: null,
sleepStage: "DEEP",
rhythmClassification: null,
deviceType: "ring",
syncVersion: 7,
deletedAt: deletedAt.toISOString(),
createdAt: "2026-07-19T07:01:00.000Z",
updatedAt: "2026-07-19T07:02:00.000Z",
},
],
medications: [
expect.objectContaining({
id: "medication-canonical",
asNeeded: false,
deliveryForm: "INJECTION",
unitsPerDose: "0.5000",
schedules: [
expect.objectContaining({
id: "schedule-canonical",
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
}),
],
}),
],
intakeEvents: [
expect.objectContaining({
id: "intake-canonical",
medicationId: "medication-canonical",
syncVersion: 4,
deletedAt: deletedAt.toISOString(),
}),
],
moodEntries: [
{
id: "mood-tombstone",
@@ -234,6 +234,166 @@ describe("buildRecordsBackupSection", () => {
expect(section.documents[0].summary).toBe("Routine panel.");
});
it("preserves every canonical lab, illness, day-log, and allergy field verbatim", async () => {
const prisma = buildMockPrisma();
const deletedAt = new Date("2026-04-13T12:00:00.000Z");
const createdAt = new Date("2026-04-10T01:00:00.000Z");
const updatedAt = new Date("2026-04-12T23:00:00.000Z");
const labNote = encryptNoteToBytes("Canonical lab note");
const illnessNote = encryptToBytes("Canonical illness note");
const dayNote = encryptToBytes("Canonical day note");
const reaction = encryptToBytes("Canonical reaction");
const allergyNote = encryptToBytes("Canonical allergy note");
prisma.labResult.findMany.mockResolvedValueOnce([
{
id: "lab-canonical",
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: new Date("2026-04-01T09:00:00.000Z"),
biomarkerId: "biomarker-canonical",
biomarker: { name: "LDL Cholesterol" },
source: "IMPORT",
noteEncrypted: labNote,
createdAt,
updatedAt,
deletedAt,
},
]);
prisma.illnessEpisode.findMany.mockResolvedValueOnce([
{
id: "episode-canonical",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date("2026-04-10T00:00:00.000Z"),
resolvedAt: null,
parentConditionId: null,
noteEncrypted: illnessNote,
createdAt,
updatedAt,
deletedAt,
dayLogs: [
{
id: "day-canonical",
episodeId: "episode-canonical",
date: "2026-04-11",
functionalImpact: 2,
feverC: 38.2,
noteEncrypted: dayNote,
tz: "Europe/London",
createdAt,
updatedAt,
deletedAt,
symptomLinks: [{ severity: 3, symptom: { key: "headache" } }],
},
],
},
]);
prisma.allergy.findMany.mockResolvedValueOnce([
{
id: "allergy-canonical",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "INACTIVE",
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
reactionEncrypted: reaction,
notesEncrypted: allergyNote,
createdAt,
updatedAt,
deletedAt,
},
]);
prisma.biomarker.findMany.mockResolvedValueOnce([]);
prisma.familyHistoryEntry.findMany.mockResolvedValueOnce([]);
prisma.workout.findMany.mockResolvedValueOnce([]);
prisma.inboundDocument.findMany.mockResolvedValueOnce([]);
const section = await buildRecordsBackupSection(prisma as never, USER_ID, {
purpose: "disaster-recovery",
});
const episodeQuery = prisma.illnessEpisode.findMany.mock.calls.at(-1)?.[0];
expect(episodeQuery).toEqual(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(episodeQuery.include.dayLogs).not.toHaveProperty("where");
expect(prisma.allergy.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: USER_ID } }),
);
expect(section.labResults[0]).toEqual({
id: "lab-canonical",
panel: "Lipid panel",
analyte: "LDL",
value: 118,
valueText: null,
unit: "mg/dL",
referenceLow: null,
referenceHigh: 130,
takenAt: "2026-04-01T09:00:00.000Z",
biomarkerId: "biomarker-canonical",
biomarkerName: "LDL Cholesterol",
source: "IMPORT",
note: null,
noteEncrypted: Buffer.from(labNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
});
expect(section.illnessEpisodes[0]).toEqual({
id: "episode-canonical",
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: "2026-04-10T00:00:00.000Z",
resolvedAt: null,
parentConditionId: null,
note: null,
noteEncrypted: Buffer.from(illnessNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
dayLogs: [
{
id: "day-canonical",
episodeId: "episode-canonical",
date: "2026-04-11",
functionalImpact: 2,
feverC: 38.2,
note: null,
noteEncrypted: Buffer.from(dayNote).toString("base64"),
tz: "Europe/London",
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
symptoms: [{ key: "headache", severity: 3 }],
},
],
});
expect(section.allergies[0]).toEqual({
id: "allergy-canonical",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
severity: "SEVERE",
status: "INACTIVE",
onsetAt: "2020-01-01T00:00:00.000Z",
reaction: null,
note: null,
reactionEncrypted: Buffer.from(reaction).toString("base64"),
notesEncrypted: Buffer.from(allergyNote).toString("base64"),
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
deletedAt: deletedAt.toISOString(),
});
});
it("threads an illness flare's parentConditionId and nests its day-log + symptom", async () => {
const prisma = buildMockPrisma();
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
+174 -72
View File
@@ -10,6 +10,7 @@
* The decrypted notes are surfaced here (the backup is the human-readable
* artefact); an admin restore re-encrypts them on re-insert.
*/
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
@@ -50,90 +51,174 @@ export async function buildFullBackupPayload(
options: FullBackupOptions = {},
): Promise<FullBackupResult> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [measurements, medications, intakeEvents, moodEntries, cycle, records] =
await Promise.all([
// v1.28.25 — keyset-paginated read with a narrow select. The backup
// is inherently whole-history, so on a CGM / per-sample-HR account the
// previous single full-width `findMany` materialised a six-figure row
// set in one shot. The chunked read accumulates the same rows in the
// same `measuredAt desc` order; the payload FORMAT below is untouched
// (restore compatibility). The select is exactly the fields the
// serializer reads: type / value / unit / measuredAt / source + the
// note columns `readNote` decrypts + the `id` cursor key.
findMeasurementsPaged(
prisma,
disasterRecovery ? { userId } : { userId, deletedAt: null },
{
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
deletedAt: true,
},
),
prisma.medication.findMany({
where: { userId },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
const [
appSettings,
measurements,
medications,
intakeEvents,
moodEntries,
cycle,
records,
] = await Promise.all([
disasterRecovery
? prisma.appSettings.findUnique({ where: { id: "singleton" } })
: Promise.resolve(null),
findMeasurementsPaged(
prisma,
disasterRecovery ? { userId } : { userId, deletedAt: null },
{
id: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
deviceType: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
},
),
prisma.medication.findMany({
where: { userId },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
}),
prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
include: {
tagLinks: {
where: { rating: { not: null } },
select: {
rating: true,
moodTag: { select: { key: true } },
},
},
},
}),
buildCycleBackupSection(prisma, userId),
buildRecordsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
]);
},
}),
buildCycleBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
buildRecordsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
]);
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
exportedAt: (options.exportedAt ?? new Date()).toISOString(),
userId,
measurements: measurements.map((m) => ({
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
deletedAt: m.deletedAt?.toISOString() ?? null,
})),
appSettings:
disasterRecovery && appSettings
? {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null,
adminCodexConnectedAt:
appSettings.adminCodexConnectedAt?.toISOString() ?? null,
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
}
: null,
measurements: measurements.map((m) =>
disasterRecovery
? {
id: m.id,
type: m.type,
value: m.value,
valueMin: m.valueMin,
valueMax: m.valueMax,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: m.notes,
notesEncrypted: m.notesEncrypted
? Buffer.from(m.notesEncrypted).toString("base64")
: null,
externalId: m.externalId,
externalSourceVersion: m.externalSourceVersion,
glucoseContext: m.glucoseContext,
sleepStage: m.sleepStage,
rhythmClassification: m.rhythmClassification,
deviceType: m.deviceType,
syncVersion: m.syncVersion,
deletedAt: m.deletedAt?.toISOString() ?? null,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {
id: m.id,
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt.toISOString(),
source: m.source,
notes: readNote(m.notesEncrypted, m.notes),
deletedAt: m.deletedAt?.toISOString() ?? null,
},
),
medications: medications.map((m) => ({
...(disasterRecovery
? {
id: m.id,
treatmentClass: m.treatmentClass,
dosesPerUnit: m.dosesPerUnit,
unitsPerDose: m.unitsPerDose.toString(),
notificationsEnabled: m.notificationsEnabled,
pausedAt: m.pausedAt?.toISOString() ?? null,
snoozedUntil: m.snoozedUntil?.toISOString() ?? null,
startsOn: m.startsOn?.toISOString() ?? null,
endsOn: m.endsOn?.toISOString() ?? null,
oneShot: m.oneShot,
asNeeded: m.asNeeded,
deliveryForm: m.deliveryForm,
trackInjectionSites: m.trackInjectionSites,
allowedInjectionSites: m.allowedInjectionSites,
liveActivityEnabled: m.liveActivityEnabled,
criticalAlarmEnabled: m.criticalAlarmEnabled,
atcCode: m.atcCode,
rxNormCode: m.rxNormCode,
lowStockNotifiedAt: m.lowStockNotifiedAt?.toISOString() ?? null,
lowStockNotifiedThresholdDays: m.lowStockNotifiedThresholdDays,
reorderLeadDays: m.reorderLeadDays,
externalSource: m.externalSource,
externalId: m.externalId,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {}),
name: m.name,
dose: m.dose,
active: m.active,
schedules: m.schedules.map((s) => ({
...(disasterRecovery
? {
id: s.id,
daysOfWeek: s.daysOfWeek,
timesOfDay: s.timesOfDay,
reminderGraceMinutes: s.reminderGraceMinutes,
rrule: s.rrule,
rollingIntervalDays: s.rollingIntervalDays,
scheduleType: s.scheduleType,
cyclicOnWeeks: s.cyclicOnWeeks,
cyclicOffWeeks: s.cyclicOffWeeks,
doseWindows: s.doseWindows,
}
: {}),
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
@@ -141,6 +226,23 @@ export async function buildFullBackupPayload(
})),
})),
intakeEvents: intakeEvents.map((e) => ({
...(disasterRecovery
? {
id: e.id,
medicationId: e.medicationId,
autoMissed: e.autoMissed,
attributionSource: e.attributionSource,
idempotencyKey: e.idempotencyKey,
createdAt: e.createdAt.toISOString(),
injectionSite: e.injectionSite,
doseTaken: e.doseTaken,
inventoryConsumption: e.inventoryConsumption,
externalId: e.externalId,
updatedAt: e.updatedAt.toISOString(),
syncVersion: e.syncVersion,
deletedAt: e.deletedAt?.toISOString() ?? null,
}
: {}),
medication: e.medication.name,
scheduledFor: e.scheduledFor.toISOString(),
takenAt: e.takenAt?.toISOString() ?? null,
+99 -13
View File
@@ -57,10 +57,15 @@ export interface LabResultBackupEntry {
takenAt: string;
source: string;
/** Human-readable cross-reference into `biomarkers` below, not an id. */
/** Stable canonical FK; legacy portable payloads use biomarkerName. */
biomarkerId?: string | null;
biomarkerName: string | null;
note: string | null;
/** Base64 ciphertext in canonical DR payloads. */
noteEncrypted?: string | null;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
}
export interface BiomarkerBackupEntry {
@@ -140,11 +145,30 @@ interface DisasterRecoveryDocumentRow {
updatedAt: Date;
}
type CanonicalIllnessDayLog = IllnessDayLogDTO & {
noteEncrypted?: string | null;
tz?: string | null;
createdAt?: string;
deletedAt?: string | null;
};
type CanonicalIllnessEpisode = IllnessEpisodeDTO & {
noteEncrypted?: string | null;
deletedAt?: string | null;
dayLogs: CanonicalIllnessDayLog[];
};
type CanonicalAllergy = AllergyDTO & {
reactionEncrypted?: string | null;
notesEncrypted?: string | null;
deletedAt?: string | null;
};
export interface RecordsBackupSection {
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
illnessEpisodes: Array<IllnessEpisodeDTO & { dayLogs: IllnessDayLogDTO[] }>;
allergies: AllergyDTO[];
illnessEpisodes: CanonicalIllnessEpisode[];
allergies: CanonicalAllergy[];
familyHistory: FamilyHistoryEntryDTO[];
workouts: WorkoutBackupEntry[];
documents: DocumentBackupEntry[];
@@ -255,7 +279,7 @@ export async function buildRecordsBackupSection(
documentRows,
] = await Promise.all([
prisma.labResult.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { takenAt: "desc" },
include: { biomarker: { select: { name: true } } },
}),
@@ -264,18 +288,18 @@ export async function buildRecordsBackupSection(
orderBy: { name: "asc" },
}),
prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
include: {
dayLogs: {
where: { deletedAt: null },
...(disasterRecovery ? {} : { where: { deletedAt: null } }),
orderBy: { date: "asc" },
include: dayLogSymptomInclude,
},
},
}),
prisma.allergy.findMany({
where: { userId, deletedAt: null },
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.familyHistoryEntry.findMany({
@@ -293,10 +317,16 @@ export async function buildRecordsBackupSection(
...(disasterRecovery
? {
id: r.id,
biomarkerId: r.biomarkerId,
noteEncrypted: r.noteEncrypted
? Buffer.from(r.noteEncrypted).toString("base64")
: null,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
deletedAt: r.deletedAt?.toISOString() ?? null,
note: null,
}
: {}),
: { note: decryptNoteSoft(r.noteEncrypted) }),
panel: r.panel,
analyte: r.analyte,
value: r.value,
@@ -307,7 +337,6 @@ export async function buildRecordsBackupSection(
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
note: decryptNoteSoft(r.noteEncrypted),
}));
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
@@ -327,12 +356,69 @@ export async function buildRecordsBackupSection(
context: decryptContextSoft(b.contextEncrypted),
}));
const illnessEpisodes = episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
? episodeRows.map((row) => ({
id: row.id,
label: row.label,
type: row.type,
lifecycle: row.lifecycle,
onsetAt: row.onsetAt.toISOString(),
resolvedAt: row.resolvedAt?.toISOString() ?? null,
parentConditionId: row.parentConditionId,
note: null,
noteEncrypted: row.noteEncrypted
? Buffer.from(row.noteEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
dayLogs: row.dayLogs.map((dayLog) => ({
id: dayLog.id,
episodeId: dayLog.episodeId,
date: dayLog.date,
functionalImpact: dayLog.functionalImpact,
feverC: dayLog.feverC,
note: null,
noteEncrypted: dayLog.noteEncrypted
? Buffer.from(dayLog.noteEncrypted).toString("base64")
: null,
tz: dayLog.tz,
createdAt: dayLog.createdAt.toISOString(),
updatedAt: dayLog.updatedAt.toISOString(),
deletedAt: dayLog.deletedAt?.toISOString() ?? null,
symptoms: dayLog.symptomLinks.map((link) => ({
key: link.symptom.key,
severity: link.severity,
})),
})),
}))
: episodeRows.map((row) => ({
...toIllnessEpisodeDTO(row),
dayLogs: row.dayLogs.map(toIllnessDayLogDTO),
}));
const allergies = allergyRows.map(toAllergyDTO);
const allergies: CanonicalAllergy[] = disasterRecovery
? allergyRows.map((row) => ({
id: row.id,
substance: row.substance,
category: row.category,
type: row.type,
severity: row.severity,
status: row.status,
onsetAt: row.onsetAt?.toISOString() ?? null,
reaction: null,
note: null,
reactionEncrypted: row.reactionEncrypted
? Buffer.from(row.reactionEncrypted).toString("base64")
: null,
notesEncrypted: row.notesEncrypted
? Buffer.from(row.notesEncrypted).toString("base64")
: null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
}))
: allergyRows.map(toAllergyDTO);
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({
+52 -1
View File
@@ -18,7 +18,12 @@
* of bubbling out of `prisma.createMany` with a useless message.
*/
import { describe, expect, it } from "vitest";
import { backupPayloadSchema, summarizeBackup } from "../backup";
import {
backupPayloadSchema,
isCompatibleSchemaVersion,
parseBackupPayload,
summarizeBackup,
} from "../backup";
const baseEntry = {
date: "2026-05-08",
@@ -384,6 +389,52 @@ describe("backupPayloadSchema — v1.28 backup-completeness domains", () => {
});
});
describe("backupPayloadSchema — canonical v2 identity compatibility", () => {
const base = {
exportedAt: "2026-07-20T00:00:00.000Z",
userId: "u1",
};
it("treats an unversioned payload as legacy v1 and accepts measurements without ids", () => {
const payload = parseBackupPayload({
...base,
measurements: [
{
type: "WEIGHT",
value: 75,
unit: "kg",
measuredAt: "2026-07-19T07:00:00.000Z",
},
],
});
expect(payload.schemaVersion).toBe("1");
expect(isCompatibleSchemaVersion(payload.schemaVersion)).toBe(true);
});
it("requires stable measurement ids in canonical v2 payloads", () => {
const result = backupPayloadSchema.safeParse({
...base,
schemaVersion: "2",
measurements: [
{
type: "SLEEP_DURATION",
value: 120,
unit: "minutes",
measuredAt: "2026-07-19T22:00:00.000Z",
source: "IMPORT",
sleepStage: "DEEP",
},
],
});
expect(result.success).toBe(false);
expect(isCompatibleSchemaVersion("1")).toBe(true);
expect(isCompatibleSchemaVersion("2")).toBe(true);
expect(isCompatibleSchemaVersion("3")).toBe(false);
});
});
describe("backupPayloadSchema — database enum boundaries", () => {
const enumPayload = {
schemaVersion: "1",
+169 -12
View File
@@ -34,19 +34,28 @@ import {
DocumentSummaryState,
FamilyRelationship,
FlowLevel,
GlucoseContext,
HomeTestResult,
IllnessLifecycle,
IllnessType,
InboundDocumentKind,
InboundDocumentStatus,
InjectionSite,
IntakeAttributionSource,
IntakeSource,
MeasurementSource,
MeasurementType,
MedicationCategory,
MedicationDeliveryForm,
MedicationScheduleType,
OvulationTest,
RhythmClassification,
SecondarySymptom,
SleepStage,
} from "@/generated/prisma/enums";
export const BACKUP_SCHEMA_VERSION = "1" as const;
export const BACKUP_SCHEMA_VERSION = "2" as const;
const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const;
const isoDateTime = z
.string()
@@ -55,44 +64,112 @@ const isoDateTime = z
message: "Expected ISO-8601 date-time string",
});
const base64BytesSchema = z
.string()
.min(1)
.refine(
(value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value),
{ message: "Expected base64-encoded encrypted bytes" },
);
const measurementSchema = z
.object({
id: z.string().min(1).optional(),
type: z.enum(MeasurementType),
value: z.number(),
valueMin: z.number().nullable().optional(),
valueMax: z.number().nullable().optional(),
unit: z.string().min(1),
measuredAt: isoDateTime,
source: z.enum(MeasurementSource).optional(),
notes: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
externalId: z.string().nullable().optional(),
externalSourceVersion: z.string().nullable().optional(),
glucoseContext: z.enum(GlucoseContext).nullable().optional(),
sleepStage: z.enum(SleepStage).nullable().optional(),
rhythmClassification: z.enum(RhythmClassification).nullable().optional(),
deviceType: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const medicationScheduleSchema = z
.object({
id: z.string().min(1).optional(),
windowStart: z.string().min(1),
windowEnd: z.string().min(1),
label: z.string().nullable().optional(),
dose: z.string().nullable().optional(),
daysOfWeek: z.string().nullable().optional(),
timesOfDay: z.array(z.string()).optional(),
reminderGraceMinutes: z.number().int().nullable().optional(),
rrule: z.string().nullable().optional(),
rollingIntervalDays: z.number().int().nullable().optional(),
scheduleType: z.enum(MedicationScheduleType).optional(),
cyclicOnWeeks: z.number().int().nullable().optional(),
cyclicOffWeeks: z.number().int().nullable().optional(),
doseWindows: z.unknown().nullable().optional(),
})
.passthrough();
const medicationSchema = z
.object({
id: z.string().min(1).optional(),
name: z.string().min(1),
dose: z.string(),
treatmentClass: z.enum(MedicationCategory).optional(),
dosesPerUnit: z.number().int().nullable().optional(),
unitsPerDose: z.string().min(1).optional(),
active: z.boolean().optional(),
notificationsEnabled: z.boolean().optional(),
pausedAt: isoDateTime.nullable().optional(),
snoozedUntil: isoDateTime.nullable().optional(),
startsOn: isoDateTime.nullable().optional(),
endsOn: isoDateTime.nullable().optional(),
oneShot: z.boolean().optional(),
asNeeded: z.boolean().optional(),
deliveryForm: z.enum(MedicationDeliveryForm).optional(),
trackInjectionSites: z.boolean().optional(),
allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(),
liveActivityEnabled: z.boolean().optional(),
criticalAlarmEnabled: z.boolean().optional(),
atcCode: z.string().nullable().optional(),
rxNormCode: z.string().nullable().optional(),
lowStockNotifiedAt: isoDateTime.nullable().optional(),
lowStockNotifiedThresholdDays: z.number().int().nullable().optional(),
reorderLeadDays: z.number().int().nullable().optional(),
externalSource: z.enum(IntakeSource).nullable().optional(),
externalId: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
schedules: z.array(medicationScheduleSchema).default([]),
})
.passthrough();
const intakeEventSchema = z
.object({
id: z.string().min(1).optional(),
medicationId: z.string().min(1).optional(),
medication: z.string().min(1),
scheduledFor: isoDateTime,
takenAt: isoDateTime.nullable().optional(),
skipped: z.boolean().optional(),
autoMissed: z.boolean().optional(),
attributionSource: z.enum(IntakeAttributionSource).optional(),
source: z.enum(IntakeSource).optional(),
idempotencyKey: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
injectionSite: z.enum(InjectionSite).nullable().optional(),
doseTaken: z.string().nullable().optional(),
inventoryConsumption: z.unknown().nullable().optional(),
externalId: z.string().nullable().optional(),
updatedAt: isoDateTime.optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
@@ -156,13 +233,19 @@ const moodEntrySchema = z
*/
const cycleSpanSchema = z
.object({
id: z.string().min(1).optional(),
startDate: z.string().min(1),
endDate: z.string().nullable().optional(),
periodEndDate: z.string().nullable().optional(),
lengthDays: z.number().int().nullable().optional(),
ovulationDate: z.string().nullable().optional(),
ovulationConfirmed: z.boolean().optional(),
isPredicted: z.boolean().optional(),
tz: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -175,7 +258,9 @@ const cycleSpanSchema = z
*/
const cycleDayLogSchema = z
.object({
id: z.string().min(1).optional(),
date: z.string().min(1),
cycleId: z.string().nullable().optional(),
flow: z.enum(FlowLevel).nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().nullable().optional(),
@@ -195,6 +280,10 @@ const cycleDayLogSchema = z
source: z.enum(MeasurementSource).optional(),
externalId: z.string().nullable().optional(),
tz: z.string().nullable().optional(),
syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
symptomKeys: z.array(z.string()).default([]),
})
.passthrough();
@@ -202,6 +291,7 @@ const cycleDayLogSchema = z
/** Cycle-tracking preferences (one row per user). */
const cycleProfileSchema = z
.object({
id: z.string().min(1).optional(),
goal: z.enum(CycleTrackingGoal).optional(),
cycleTrackingEnabled: z.boolean().nullable().optional(),
typicalCycleLength: z.number().int().nullable().optional(),
@@ -212,6 +302,53 @@ const cycleProfileSchema = z
rawChartMode: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
sensitiveCategoryEncryption: z.boolean().optional(),
createdAt: isoDateTime.optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
const appSettingsBackupSchema = z
.object({
id: z.string().min(1),
registrationEnabled: z.boolean(),
mfaRequired: z.boolean(),
defaultLocale: z.string(),
telegramGlobal: z.boolean(),
ntfyGlobal: z.boolean(),
webPushGlobal: z.boolean(),
webPushVapidPublicKey: z.string().nullable(),
webPushVapidPrivateKeyEncrypted: z.string().nullable(),
webPushVapidSubject: z.string().nullable(),
apiGlobal: z.boolean(),
moodLogGlobal: z.boolean(),
umamiEnabled: z.boolean(),
umamiScriptUrl: z.string().nullable(),
umamiWebsiteId: z.string().nullable(),
glitchtipEnabled: z.boolean(),
glitchtipDsn: z.string().nullable(),
glitchtipEnvironment: z.string().nullable(),
reminderLateMinutes: z.number().int(),
reminderMissedMinutes: z.number().int(),
adminAiKeyEncrypted: z.string().nullable(),
adminAiModel: z.string(),
adminAiBaseUrl: z.string(),
adminCodexAccessTokenEncrypted: z.string().nullable(),
adminCodexRefreshTokenEncrypted: z.string().nullable(),
adminCodexAccountIdEncrypted: z.string().nullable(),
adminCodexTokenExpiresAt: isoDateTime.nullable(),
adminCodexConnectedAt: isoDateTime.nullable(),
adminCodexConnectionStatus: z.string(),
adminAiInsightsFeedbackSummary: z.unknown().nullable(),
defaultUserTimezone: z.string().nullable(),
assistantEnabled: z.boolean(),
assistantCoachEnabled: z.boolean(),
assistantBriefingEnabled: z.boolean(),
assistantInsightStatusEnabled: z.boolean(),
assistantCorrelationsEnabled: z.boolean(),
assistantHealthScoreExplainerEnabled: z.boolean(),
moduleAvailabilityJson: z.unknown().nullable(),
documentMaxFileBytes: z.number().int(),
documentQuotaBytes: z.string().regex(/^\d+$/),
})
.passthrough();
@@ -237,8 +374,11 @@ const labResultBackupSchema = z
takenAt: isoDateTime,
source: z.string().min(1),
biomarkerName: z.string().nullable().optional(),
biomarkerId: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -275,6 +415,10 @@ const illnessDayLogBackupSchema = z
symptoms: z.array(illnessSymptomBackupSchema).default([]),
note: z.string().nullable().optional(),
updatedAt: isoDateTime.optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
tz: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
@@ -291,6 +435,8 @@ const illnessEpisodeBackupSchema = z
parentConditionId: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
dayLogs: z.array(illnessDayLogBackupSchema).default([]),
})
@@ -308,6 +454,9 @@ const allergyBackupSchema = z
reaction: z.string().nullable().optional(),
note: z.string().nullable().optional(),
createdAt: isoDateTime.optional(),
reactionEncrypted: base64BytesSchema.nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
deletedAt: isoDateTime.nullable().optional(),
updatedAt: isoDateTime.optional(),
})
.passthrough();
@@ -346,14 +495,6 @@ const workoutBackupSchema = z
})
.passthrough();
const base64BytesSchema = z
.string()
.min(1)
.refine(
(value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value),
{ message: "Expected base64-encoded encrypted bytes" },
);
const documentBackupSchema = z
.object({
id: z.string().min(1),
@@ -401,9 +542,10 @@ const backupManifestSchema = z
*/
export const backupPayloadSchema = z
.object({
schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION),
schemaVersion: z.string().min(1).default(LEGACY_BACKUP_SCHEMA_VERSION),
exportedAt: isoDateTime,
userId: z.string().min(1),
appSettings: appSettingsBackupSchema.nullable().default(null),
measurements: z.array(measurementSchema).default([]),
medications: z.array(medicationSchema).default([]),
intakeEvents: z.array(intakeEventSchema).default([]),
@@ -425,7 +567,19 @@ export const backupPayloadSchema = z
documents: z.array(documentBackupSchema).default([]),
manifest: backupManifestSchema.nullable().default(null),
})
.passthrough();
.passthrough()
.superRefine((payload, ctx) => {
if (payload.schemaVersion !== BACKUP_SCHEMA_VERSION) return;
payload.measurements.forEach((measurement, index) => {
if (!measurement.id) {
ctx.addIssue({
code: "custom",
path: ["measurements", index, "id"],
message: "Canonical v2 measurements require a stable id",
});
}
});
});
export type BackupPayload = z.infer<typeof backupPayloadSchema>;
@@ -512,5 +666,8 @@ export function parseBackupPayload(input: string | unknown): BackupPayload {
* new shape carried.
*/
export function isCompatibleSchemaVersion(version: string): boolean {
return version === BACKUP_SCHEMA_VERSION;
return (
version === LEGACY_BACKUP_SCHEMA_VERSION ||
version === BACKUP_SCHEMA_VERSION
);
}
@@ -1,6 +1,7 @@
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -117,6 +118,50 @@ describe("canonical disaster-recovery backup round-trip", () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const ownerId = admin.id;
const settings = await prisma.appSettings.create({
data: {
id: "singleton",
registrationEnabled: false,
mfaRequired: true,
defaultLocale: "en",
telegramGlobal: false,
ntfyGlobal: false,
webPushGlobal: false,
webPushVapidPublicKey: "public-dr",
webPushVapidPrivateKeyEncrypted: "private-dr",
webPushVapidSubject: "mailto:dr@example.test",
apiGlobal: false,
moodLogGlobal: false,
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.test/script.js",
umamiWebsiteId: "website-dr",
glitchtipEnabled: true,
glitchtipDsn: "https://glitchtip.example.test/1",
glitchtipEnvironment: "dr",
reminderLateMinutes: 47,
reminderMissedMinutes: 93,
adminAiKeyEncrypted: "ai-key-dr",
adminAiModel: "model-dr",
adminAiBaseUrl: "https://ai.example.test/v1",
adminCodexAccessTokenEncrypted: "access-dr",
adminCodexRefreshTokenEncrypted: "refresh-dr",
adminCodexAccountIdEncrypted: "account-dr",
adminCodexTokenExpiresAt: new Date("2026-07-03T00:00:00.000Z"),
adminCodexConnectedAt: new Date("2026-07-01T00:00:00.000Z"),
adminCodexConnectionStatus: "connected",
adminAiInsightsFeedbackSummary: { helpful: 8, total: 10 },
defaultUserTimezone: "Europe/London",
assistantEnabled: false,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantHealthScoreExplainerEnabled: false,
moduleAvailabilityJson: { insights: true, nutrients: false },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: 9_876_543_210n,
},
});
const measurement = await prisma.measurement.create({
data: {
@@ -193,6 +238,146 @@ describe("canonical disaster-recovery backup round-trip", () => {
},
});
const medication = await prisma.medication.create({
data: {
id: "medication-dr",
userId: ownerId,
name: "Canonical medicine",
dose: "2 mg",
treatmentClass: "GENERIC",
dosesPerUnit: 4,
unitsPerDose: "0.5000",
active: false,
notificationsEnabled: false,
pausedAt: new Date("2026-06-25T08:00:00.000Z"),
snoozedUntil: new Date("2026-07-05T08:00:00.000Z"),
startsOn: new Date("2026-06-01T00:00:00.000Z"),
endsOn: new Date("2026-08-01T00:00:00.000Z"),
asNeeded: false,
deliveryForm: "INJECTION",
trackInjectionSites: true,
allowedInjectionSites: ["THIGH_LEFT", "THIGH_RIGHT"],
liveActivityEnabled: true,
criticalAlarmEnabled: true,
atcCode: "A10BX10",
rxNormCode: "12345",
lowStockNotifiedAt: new Date("2026-06-26T08:00:00.000Z"),
lowStockNotifiedThresholdDays: 7,
reorderLeadDays: 3,
externalSource: "APPLE_HEALTH",
externalId: "medication-external-dr",
schedules: {
create: {
id: "schedule-dr",
windowStart: "08:00",
windowEnd: "10:00",
label: "Morning",
dose: "1 mg",
daysOfWeek: "i2;1,3,5",
timesOfDay: ["08:00", "20:00"],
reminderGraceMinutes: 90,
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR",
scheduleType: "CYCLIC",
cyclicOnWeeks: 3,
cyclicOffWeeks: 1,
doseWindows: [
{ timeOfDay: "08:00", start: "07:30", end: "09:00" },
],
},
},
},
include: { schedules: true },
});
const intake = await prisma.medicationIntakeEvent.create({
data: {
id: "intake-dr",
userId: ownerId,
medicationId: medication.id,
scheduledFor: new Date("2026-06-29T08:00:00.000Z"),
takenAt: new Date("2026-06-29T08:05:00.000Z"),
autoMissed: false,
attributionSource: "USER_PIN",
source: "APPLE_HEALTH",
idempotencyKey: "intake-idempotency-dr",
injectionSite: "THIGH_LEFT",
doseTaken: "1 mg",
inventoryConsumption: [{ itemId: "pen-dr", units: 0.5 }],
externalId: "intake-external-dr",
syncVersion: 5,
deletedAt: tombstoneDeletedAt,
},
});
const cycleProfile = await prisma.cycleProfile.create({
data: {
id: "cycle-profile-dr",
userId: ownerId,
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
secondarySymptom: "CERVIX",
predictionEnabled: false,
rawChartMode: true,
discreetNotifications: true,
sensitiveCategoryEncryption: true,
},
});
const cycle = await prisma.menstrualCycle.create({
data: {
id: "cycle-dr",
userId: ownerId,
startDate: "2026-06-01",
endDate: "2026-06-28",
periodEndDate: "2026-06-05",
lengthDays: 28,
ovulationDate: "2026-06-14",
ovulationConfirmed: true,
isPredicted: false,
tz: "Europe/London",
syncVersion: 6,
deletedAt: tombstoneDeletedAt,
},
});
const cycleCategory =
await prisma.cycleSymptomCategory.findFirstOrThrow();
const cycleSymptom = await prisma.cycleSymptom.create({
data: {
id: "cycle-symptom-dr",
userId: ownerId,
categoryId: cycleCategory.id,
key: "cycle_cramps_dr",
labelKey: "cycle.symptom.crampsDr",
},
});
const cycleDay = await prisma.cycleDayLog.create({
data: {
id: "cycle-day-dr",
userId: ownerId,
cycleId: cycle.id,
date: "2026-06-02",
flow: "HEAVY",
intermenstrualBleeding: true,
basalBodyTempC: 36.7,
temperatureExcluded: true,
ovulationTest: "NEGATIVE",
cervicalMucus: "CREAMY",
cervixPosition: "LOW",
cervixFirmness: "FIRM",
cervixOpening: "CLOSED",
sexualActivity: false,
sensitiveEncrypted: "sensitive-cycle-dr",
notesEncrypted: "notes-cycle-dr",
source: "APPLE_HEALTH",
externalId: "cycle-day-external-dr",
tz: "Europe/London",
syncVersion: 7,
deletedAt: tombstoneDeletedAt,
symptomLinks: { create: { symptomId: cycleSymptom.id } },
},
});
const biomarker = await prisma.biomarker.create({
data: {
id: "biomarker-dr",
@@ -219,6 +404,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
takenAt: new Date("2026-06-30T09:00:00.000Z"),
source: "MANUAL",
noteEncrypted: encryptToBytes("Fasted"),
deletedAt: tombstoneDeletedAt,
},
});
@@ -232,6 +418,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
onsetAt: new Date("2026-06-20T00:00:00.000Z"),
resolvedAt: new Date("2026-06-24T00:00:00.000Z"),
noteEncrypted: encryptToBytes("Recovered fully"),
deletedAt: tombstoneDeletedAt,
},
});
const symptom = await prisma.illnessSymptom.create({
@@ -250,6 +437,8 @@ describe("canonical disaster-recovery backup round-trip", () => {
functionalImpact: 2,
feverC: 38.1,
noteEncrypted: encryptToBytes("Rest day"),
tz: "Europe/London",
deletedAt: tombstoneDeletedAt,
symptomLinks: {
create: { symptomId: symptom.id, severity: 2 },
},
@@ -268,6 +457,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
onsetAt: new Date("2020-01-01T00:00:00.000Z"),
reactionEncrypted: encryptToBytes("Hives"),
notesEncrypted: encryptToBytes("Avoid beta-lactams"),
deletedAt: tombstoneDeletedAt,
},
});
const family = await prisma.familyHistoryEntry.create({
@@ -342,14 +532,21 @@ describe("canonical disaster-recovery backup round-trip", () => {
decryptBackup(offhostObject!, Buffer.from(OFFHOST_ENCRYPTION_KEY, "hex")),
);
expect(payload).toMatchObject({
schemaVersion: "1",
schemaVersion: "2",
userId: ownerId,
appSettings: { id: settings.id },
});
expect(payload.documents[0]).not.toHaveProperty("summary");
expect(JSON.stringify(payload.documents[0])).not.toContain(
"private PDF bytes",
);
await prisma.appSettings.deleteMany();
await prisma.medicationIntakeEvent.deleteMany({ where: { userId: ownerId } });
await prisma.medication.deleteMany({ where: { userId: ownerId } });
await prisma.cycleDayLog.deleteMany({ where: { userId: ownerId } });
await prisma.menstrualCycle.deleteMany({ where: { userId: ownerId } });
await prisma.cycleProfile.deleteMany({ where: { userId: ownerId } });
await prisma.inboundDocument.deleteMany({ where: { userId: ownerId } });
await prisma.workout.deleteMany({ where: { userId: ownerId } });
await prisma.familyHistoryEntry.deleteMany({ where: { userId: ownerId } });
@@ -373,6 +570,44 @@ describe("canonical disaster-recovery backup round-trip", () => {
expect((await restore()).status).toBe(200);
expect(
await prisma.appSettings.findUniqueOrThrow({
where: { id: settings.id },
}),
).toEqual(settings);
expect(
await prisma.medication.findUniqueOrThrow({
where: { id: medication.id },
include: { schedules: true },
}),
).toEqual(medication);
expect(
await prisma.medicationIntakeEvent.findUniqueOrThrow({
where: { id: intake.id },
}),
).toEqual(intake);
expect(
await prisma.cycleProfile.findUniqueOrThrow({
where: { id: cycleProfile.id },
}),
).toEqual(cycleProfile);
expect(
await prisma.menstrualCycle.findUniqueOrThrow({
where: { id: cycle.id },
}),
).toEqual(cycle);
expect(
await prisma.cycleDayLog.findUniqueOrThrow({
where: { id: cycleDay.id },
}),
).toEqual(cycleDay);
expect(
await prisma.cycleSymptomLink.findMany({
where: { dayLogId: cycleDay.id },
select: { symptomId: true },
}),
).toEqual([{ symptomId: cycleSymptom.id }]);
expect(await prisma.labResult.count({ where: { userId: ownerId } })).toBe(
1,
);
@@ -397,45 +632,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
await prisma.measurement.findUniqueOrThrow({
where: { id: measurement.id },
}),
).toEqual(
expect.objectContaining({
userId: ownerId,
type: measurement.type,
value: measurement.value,
unit: measurement.unit,
measuredAt: measurement.measuredAt,
source: measurement.source,
deletedAt: null,
}),
);
).toEqual(measurement);
expect(
await prisma.measurement.findUniqueOrThrow({
where: { id: deletedMeasurement.id },
}),
).toEqual(
expect.objectContaining({
userId: ownerId,
deletedAt: tombstoneDeletedAt,
}),
);
).toEqual(deletedMeasurement);
const restoredLab = await prisma.labResult.findUniqueOrThrow({
where: { id: lab.id },
});
expect(decryptFromBytes(restoredLab.noteEncrypted!)).toBe("Fasted");
expect(restoredLab.biomarkerId).toBe(biomarker.id);
expect(restoredLab).toEqual(
expect.objectContaining({
panel: lab.panel,
analyte: lab.analyte,
value: lab.value,
unit: lab.unit,
referenceLow: lab.referenceLow,
referenceHigh: lab.referenceHigh,
takenAt: lab.takenAt,
source: lab.source,
}),
);
expect(restoredLab).toEqual(lab);
const restoredBiomarker = await prisma.biomarker.findUniqueOrThrow({
where: { id: biomarker.id },
});
@@ -456,26 +665,22 @@ describe("canonical disaster-recovery backup round-trip", () => {
const restoredIllness = await prisma.illnessEpisode.findUniqueOrThrow({
where: { id: illness.id },
});
expect(restoredIllness).toEqual(
expect.objectContaining({
label: illness.label,
type: illness.type,
lifecycle: illness.lifecycle,
onsetAt: illness.onsetAt,
resolvedAt: illness.resolvedAt,
}),
);
expect(restoredIllness).toEqual(illness);
expect(decryptFromBytes(restoredIllness.noteEncrypted!)).toBe(
"Recovered fully",
);
const restoredIllnessDay = await prisma.illnessDayLog.findUniqueOrThrow({
where: { id: illnessDay.id },
include: { symptomLinks: true },
});
expect(restoredIllnessDay).toEqual(illnessDay);
expect(decryptFromBytes(restoredIllnessDay.noteEncrypted!)).toBe(
"Rest day",
);
expect(restoredIllnessDay.symptomLinks).toEqual([
expect(
await prisma.illnessSymptomLink.findMany({
where: { dayLogId: illnessDay.id },
}),
).toEqual([
expect.objectContaining({ symptomId: symptom.id, severity: 2 }),
]);
@@ -483,16 +688,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
where: { id: allergy.id },
});
expect(decryptFromBytes(restoredAllergy.reactionEncrypted!)).toBe("Hives");
expect(restoredAllergy).toEqual(
expect.objectContaining({
substance: allergy.substance,
category: allergy.category,
type: allergy.type,
severity: allergy.severity,
status: allergy.status,
onsetAt: allergy.onsetAt,
}),
);
expect(restoredAllergy).toEqual(allergy);
const restoredFamily = await prisma.familyHistoryEntry.findUniqueOrThrow({
where: { id: family.id },
});
@@ -612,6 +808,149 @@ describe("canonical disaster-recovery backup round-trip", () => {
}),
).toEqual([{ id: illness.id }]);
});
it("rejects a stable measurement id owned by another account without clearing either owner", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const other = await prisma.user.create({
data: { username: "stable-id-owner-other" },
});
const original = await prisma.measurement.create({
data: {
id: "owner-original-measurement",
userId: admin.id,
type: "PULSE",
value: 70,
unit: "bpm",
measuredAt: new Date("2026-07-08T10:00:00.000Z"),
},
});
const conflicting = await prisma.measurement.create({
data: {
id: "cross-owner-stable-id",
userId: other.id,
type: "PULSE",
value: 65,
unit: "bpm",
measuredAt: new Date("2026-07-08T11:00:00.000Z"),
},
});
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-12T00:00:00.000Z",
userId: admin.id,
measurements: [
{
id: conflicting.id,
type: "PULSE",
value: 80,
unit: "bpm",
measuredAt: "2026-07-08T12:00:00.000Z",
source: "IMPORT",
},
],
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "STABLE_ID_OWNER_CONFLICT",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(500);
expect(
await prisma.measurement.findUnique({ where: { id: original.id } }),
).toEqual(original);
expect(
await prisma.measurement.findUnique({ where: { id: conflicting.id } }),
).toEqual(conflicting);
});
it("restores many stable-id measurements with a bounded number of writes", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const measurements = Array.from({ length: 250 }, (_, index) => ({
id: `stable-measurement-${index}`,
type: "PULSE" as const,
value: 60 + (index % 20),
unit: "bpm",
measuredAt: new Date(
Date.UTC(2026, 6, 10, 0, 0, 0, index),
).toISOString(),
source: "IMPORT" as const,
externalId: `external-${index}`,
syncVersion: 3,
deletedAt: index % 10 === 0 ? "2026-07-11T00:00:00.000Z" : null,
}));
const payload = backupPayloadSchema.parse({
schemaVersion: "2",
exportedAt: "2026-07-12T00:00:00.000Z",
userId: admin.id,
measurements,
});
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "BOUNDED_STABLE_ID_RESTORE",
data: encrypt(JSON.stringify(payload)),
},
});
let stableWriteQueries = 0;
const originalTransaction = prisma.$transaction.bind(prisma) as unknown as (
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
options?: { maxWait?: number; timeout?: number },
) => Promise<unknown>;
const transactionSpy = vi.spyOn(prisma, "$transaction");
transactionSpy.mockImplementation(
(async (
callback: (tx: Prisma.TransactionClient) => Promise<unknown>,
options?: { maxWait?: number; timeout?: number },
) =>
originalTransaction(async (tx) => {
const originalUpsert = tx.measurement.upsert.bind(tx.measurement);
const originalCreateMany = tx.measurement.createMany.bind(
tx.measurement,
);
vi.spyOn(tx.measurement, "upsert").mockImplementation(async (args) => {
stableWriteQueries += 1;
return originalUpsert(args);
});
vi.spyOn(tx.measurement, "createMany").mockImplementation(
async (args) => {
stableWriteQueries += 1;
return originalCreateMany(args);
},
);
return callback(tx);
}, options)) as never,
);
const response = await POST(
makeRequest(backup.id) as unknown as Parameters<typeof POST>[0],
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status).toBe(200);
transactionSpy.mockRestore();
expect(stableWriteQueries).toBeLessThanOrEqual(2);
expect(
await prisma.measurement.findMany({
where: { userId: admin.id },
orderBy: { id: "asc" },
select: { id: true },
}),
).toEqual(
measurements
.map(({ id }) => ({ id }))
.sort((left, right) => left.id.localeCompare(right.id)),
);
});
it.each([
{
label: "invalid enum",