refactor(backup): build the export from per-domain slices

buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.

The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 07:46:02 +02:00
1 parent 508e19f190
commit 322652a86e
32 files changed
+3113 -2623

No files matched your search

@@ -322,7 +322,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
it("does not count a nested relation read as a restore write", () => {
expect(
touches(
["src/lib/export/measurements-backup.ts"],
["src/lib/export/measurements-restore.ts"],
"User",
WRITE_OPS,
WRITE_RELATION_OPS,
@@ -565,7 +565,7 @@ describe("tables outside the schema have a backup verdict too", () => {
it("carries the medication category through both ends", () => {
const writer = readFileSync(
join(SRC, "lib/export/full-backup-payload.ts"),
join(SRC, "lib/export/medications-backup.ts"),
"utf8",
);
const restore = readFileSync(
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 128,
"total": 153023,
"count": 126,
"total": 150668,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -68,11 +68,9 @@
"lib/dashboard/snapshot.ts": 1549,
"lib/day/records.ts": 1175,
"lib/doctor-report/collect.ts": 803,
"lib/export/backup-plan.ts": 854,
"lib/export/backup-plan.ts": 853,
"lib/export/coach-backup.ts": 1070,
"lib/export/full-backup-payload.ts": 1520,
"lib/export/profile-backup.ts": 1019,
"lib/export/records-backup.ts": 834,
"lib/fitbit/client.ts": 1529,
"lib/fitbit/sync-core.ts": 968,
"lib/gamification/achievements-result.ts": 1094,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 413,
"total": 149647,
"count": 410,
"total": 148267,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -319,11 +319,8 @@
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
"lib/documents/ingest.ts :: ingestDocument": 272,
"lib/export/backup-json-scan.ts :: scanBackupJson": 225,
"lib/export/full-backup-payload.ts :: buildFullBackupPayload": 804,
"lib/export/full-backup-payload.ts :: buildFullBackupPayload > medications.map(arg) #3": 268,
"lib/export/profile-backup.ts :: buildProfileBackupSection": 270,
"lib/export/profile-backup.ts :: restoreProfileData": 291,
"lib/export/records-backup.ts :: buildRecordsBackupSection": 308,
"lib/export/vaccinations-backup.ts :: restoreVaccinationsData": 216,
"lib/export/visits-backup.ts :: restoreVisitsData": 261,
"lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2961,
"total": 10571,
"count": 2959,
"total": 10559,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2044,11 +2044,9 @@
"lib/export/account-settings-backup.ts": 2,
"lib/export/backup-plan.ts": 9,
"lib/export/coach-backup.ts": 13,
"lib/export/full-backup-payload.ts": 8,
"lib/export/intraday-profile-backup.ts": 1,
"lib/export/passphrase-archive.ts": 1,
"lib/export/profile-backup.ts": 10,
"lib/export/records-backup.ts": 4,
"lib/export/reminders-backup.ts": 4,
"lib/export/visits-backup.ts": 5,
"lib/feature-flags/__tests__/index.test.ts": 2,
@@ -19,24 +19,27 @@
* backup-relevant; converting every model at once buys exclusion-list churn
* rather than safety.
*
* Both backup ends of the measurements live in the measurements domain
* (`src/lib/export/measurements-backup.ts` out, `measurements-restore.ts`
* back), so the guard checks the restore half too: every carried column is
* written back by `toRestoredMeasurementData`.
*
* Mutation check: comment out `sleepStage: true` in `MEASUREMENT_BACKUP_SELECT`
* and this goes red naming that column; add a scalar to the model without
* adding it to the select and it goes red naming the new one.
* adding it to the select and it goes red naming the new one; drop a key from
* `toRestoredMeasurementData` and the restore case goes red naming it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { readSource } from "./helpers/source-files";
const ROOT = process.cwd();
const SCHEMA = join(ROOT, "prisma", "schema.prisma");
const BACKUP_BUILDER = join(
ROOT,
"src",
"lib",
"export",
"full-backup-payload.ts",
);
const BACKUP_MODULE = "src/lib/export/measurements-backup.ts";
const RESTORE_MODULE = "src/lib/export/measurements-restore.ts";
const PROFILE_BACKUP_BUILDER = join(
ROOT,
"src",
@@ -105,18 +108,50 @@ function selectFields(source: string): string[] {
);
}
/** The text of one top-level function, from its declaration to its end. */
function functionBody(source: string, declaration: string): string {
const start = source.indexOf(declaration);
expect(start, `${declaration} not found`).toBeGreaterThan(-1);
const end = source.indexOf("\n}\n", start);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}
/**
* Fields either serialization arm actually emits. The two arms share one
* restorable wire format, so this is a union and therefore a weaker claim than
* the select comparison: it catches a column that is selected and then written
* to neither payload, not a column carried by only one arm.
* to neither payload, not a column carried by only one arm. Read from the
* serialiser alone, because the restore beside it spells its rows the same
* way and would answer for a column the export dropped.
*/
function serialisedFields(source: string): Set<string> {
const body = functionBody(source, "function serialiseMeasurement(");
return new Set(
[...source.matchAll(/([a-zA-Z_$][\w$]*): measurement\./g)].map((m) => m[1]),
[...body.matchAll(/([a-zA-Z_$][\w$]*): measurement\./g)].map((m) => m[1]),
);
}
/** Keys the restore writes for each row, the same file's other half. */
function restoredFields(source: string): Set<string> {
const body = functionBody(source, "function toRestoredMeasurementData(");
return new Set(
[
...body.matchAll(
/^ {4}(?:\.\.\.\(measurement\.([a-zA-Z_$][\w$]*)|([a-zA-Z_$][\w$]*):)/gm,
),
].map((m) => m[1] ?? m[2]),
);
}
/**
* Selected columns the per-row restore data does not write, with the reason.
* `id` is written beside it, from the file's id, by the batch insert.
*/
const RESTORED_ELSEWHERE: Record<string, string> = {
id: "written beside the row data by the batch insert, from the file's own id",
};
function interfaceFields(source: string, name: string): Set<string> {
const start = source.indexOf(`export interface ${name} {`);
expect(start, `interface ${name} not found`).toBeGreaterThan(-1);
@@ -139,7 +174,13 @@ function emittedModelFields(source: string, variable: string): Set<string> {
describe("measurement backup completeness", () => {
const schema = readFileSync(SCHEMA, "utf8");
const source = readFileSync(BACKUP_BUILDER, "utf8");
const source = readSource(BACKUP_MODULE, [
"const MEASUREMENT_BACKUP_SELECT = {",
"function serialiseMeasurement(",
]);
const restoreSource = readSource(RESTORE_MODULE, [
"function toRestoredMeasurementData(",
]);
const models = declaredModels(schema);
const columns = scalarFields(modelBlock(schema, "Measurement"), models);
const selected = selectFields(source);
@@ -179,6 +220,18 @@ describe("measurement backup completeness", () => {
).toHaveLength(0);
});
it("the restore writes every carried column back", () => {
const restored = restoredFields(restoreSource);
expect(restored.size).toBeGreaterThan(15);
const unwritten = selected.filter(
(c) => !restored.has(c) && !(c in RESTORED_ELSEWHERE),
);
expect(
unwritten,
`carried in the file but never written back: ${unwritten}`,
).toEqual([]);
});
it("the exclusion list carries no stale entry", () => {
for (const [column, reason] of Object.entries(EXCLUDED_COLUMNS)) {
expect(
@@ -123,7 +123,7 @@ const WRITERS: Record<string, Disposition> = {
},
// ── Restore ───────────────────────────────────────────────────────────
"lib/export/measurements-backup.ts": {
"lib/export/measurements-restore.ts": {
kind: "restore",
why: "replays the record's own export; dropping rows would break the restore contract",
},
@@ -81,7 +81,7 @@ const WRITE_SITES: Record<string, string> = {
"the import worker refolds the import's span and re-warms every imported type",
"lib/import/health-connect/import.ts":
"the Health Connect import worker refolds the import's span and re-warms every imported type",
"lib/export/measurements-backup.ts":
"lib/export/measurements-restore.ts":
"a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
// Value-preserving reshapes: the same readings folded into daily or hourly
// rows. The rollups are refolded; the figures an assessment reads do not
@@ -1,7 +1,7 @@
/**
* v1.43 — pair guard binding the `MedicationIngredient` model to both ends of
* the backup: the writer's `ingredients: m.ingredients.map(...)` block in
* `full-backup-payload.ts` and the nested create in `medications-restore.ts`.
* `medications-backup.ts` and the nested create in `medications-restore.ts`.
*
* The failure it catches is the one the measurement guard beside it was
* written for: a column added to the composition in a later release, the
@@ -79,7 +79,7 @@ describe("medication ingredient backup completeness", () => {
it("the writer carries every column", () => {
const keys = blockKeys(
read("src", "lib", "export", "full-backup-payload.ts"),
read("src", "lib", "export", "medications-backup.ts"),
"ingredients: m.ingredients.map((i) => ({",
);
expect(carried.filter((c) => !keys.has(c))).toEqual([]);
@@ -61,7 +61,7 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
"engagement streak of days with any logged action, no rate",
"app/api/export/medications/route.ts": "export of the recorded history",
"app/api/export/route.ts": "export of the recorded history",
"lib/export/full-backup-payload.ts": "backup of the recorded history",
"lib/export/medications-backup.ts": "backup of the recorded history",
"app/api/sync/state/route.ts": "sync watermarks, no rate",
"lib/jobs/measurement-tombstone-cleanup.ts": "tombstone purge",
"lib/ai/coach/tools/availability.ts":
@@ -94,7 +94,7 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
const MAY_READ_EVERY_ROW: Record<string, string> = {
// Record-keeping: a backup, a restore and an export carry the medication
// as stored, schedule and switch included.
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
"lib/export/medications-backup.ts": "backup carries the stored rows",
"lib/export/medications-restore.ts": "restore writes the stored rows back",
// v1.44 — the CSV import writes new medications and reads only their names
// and doses to skip duplicates; it derives nothing due.
+13 -8
View File
@@ -36,15 +36,12 @@ import { join } from "node:path";
import { describe, it, expect } from "vitest";
import { readSource } from "./helpers/source-files";
const ROOT = process.cwd();
const SCHEMA = join(ROOT, "prisma", "schema.prisma");
const BACKUP_BUILDER = join(
ROOT,
"src",
"lib",
"export",
"full-backup-payload.ts",
);
/** The mood domain's export half, where the selects and serialisers live. */
const BACKUP_MODULE = "src/lib/export/mood-backup.ts";
/**
* Columns the backup is correct to omit, each with the reason. The reasons are
@@ -175,7 +172,15 @@ function serialisedFields(source: string, variable: string): Set<string> {
describe("mood backup completeness", () => {
const schema = readFileSync(SCHEMA, "utf8");
const source = stripCommentLines(readFileSync(BACKUP_BUILDER, "utf8"));
const source = stripCommentLines(
readSource(BACKUP_MODULE, [
"const MOOD_ENTRY_BACKUP_SELECT = {",
"const MOOD_CONTEXT_BACKUP_SELECT = {",
"const MOOD_ENTRY_TAG_LINK_SELECT = {",
"function serialiseMoodEntry(",
"function serialiseMoodContext(",
]),
);
const models = declaredModels(schema);
const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models);
+47
View File
@@ -1,12 +1,59 @@
/**
* The allergy record, 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 { toAllergyDTO, type AllergyDTO } from "@/lib/records/dto";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
export type CanonicalAllergy = AllergyDTO & {
reactionEncrypted?: string | null;
notesEncrypted?: string | null;
deletedAt?: string | null;
};
/** The allergy slice of the records section. */
export async function buildAllergiesBackupSlice(
prisma: Pick<PrismaClient, "allergy">,
userId: string,
disasterRecovery: boolean,
): Promise<{ allergies: CanonicalAllergy[] }> {
const allergyRows = await prisma.allergy.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
});
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);
return { allergies };
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function clearAllergies(ctx: RestoreContext): Promise<void> {
+34
View File
@@ -14,9 +14,43 @@
* (`restoreInstanceSettings`), not one the account restore assumes.
*/
import { DEFAULT_REASONING_MAX_EFFORT } from "@/lib/ai/reasoning/levels";
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import type { RestoreContext } from "@/lib/export/restore-context";
import { foldLegacyCoachAvailability } from "@/lib/modules/operator-availability";
// ── Export ───────────────────────────────────────────────────────────────────
/**
* The instance settings slice: the singleton row, in a disaster-recovery
* payload only, with its timestamps and its quota made JSON-safe.
*/
export async function buildAppSettingsBackupPart(
ctx: BackupExportContext,
): Promise<BackupExportPart> {
const appSettings = ctx.disasterRecovery
? await ctx.prisma.appSettings.findUnique({ where: { id: "singleton" } })
: null;
return {
section: {
appSettings:
ctx.disasterRecovery && appSettings
? {
...appSettings,
adminCodexTokenExpiresAt:
appSettings.adminCodexTokenExpiresAt?.toISOString() ?? null,
adminCodexConnectedAt:
appSettings.adminCodexConnectedAt?.toISOString() ?? null,
documentQuotaBytes: appSettings.documentQuotaBytes.toString(),
}
: null,
},
counts: {},
};
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function restoreAppSettings(ctx: RestoreContext): Promise<void> {
+4 -2
View File
@@ -4,7 +4,9 @@
* Each domain keeps its schema slice, its export and its restore together in
* its own module (`<domain>-backup.ts`, with `-restore.ts` and
* `-backup-schema.ts` beside it where those are large). This file only says
* in which order the restore calls them. The order is the whole content:
* in which order the restore calls them (the export's order, which is the
* file's key order, is `backup-export-sections.ts`). The order is the whole
* content:
*
* - The account is emptied first, child before parent wherever a count
* is reported, because a parent's delete cascades into its children and
@@ -53,7 +55,7 @@ import {
clearMeasurementRollups,
clearMeasurements,
restoreMeasurements,
} from "@/lib/export/measurements-backup";
} from "@/lib/export/measurements-restore";
import {
clearIntakeEvents,
clearMedications,
+429
View File
@@ -0,0 +1,429 @@
/**
* The slices of a backup file, in the order their keys appear in it.
*
* Each domain builds its own slice in its own module; this file lays them
* out. The order is every backup file's key order so far, and the restore's
* dependency order lives beside it in `backup-domains.ts`.
*/
import { buildCycleBackupSection } from "@/lib/cycle/backup";
import { buildAccountSettingsBackupSection } from "@/lib/export/account-settings-backup";
import { buildAppSettingsBackupPart } from "@/lib/export/app-settings-backup";
import {
buildAwardsBackupSection,
countAwardsBackupSection,
} from "@/lib/export/awards-backup";
import {
buildCoachBackupSection,
buildCoachMemoryBackupSection,
countCoachBackupSection,
countCoachMemoryBackupSection,
} from "@/lib/export/coach-backup";
import { deferredRows } from "@/lib/export/deferred-rows";
import {
buildDocumentFilingBackupSection,
countDocumentFilingBackupSection,
} from "@/lib/export/document-filing-backup";
import { iterateBackupDocuments } from "@/lib/export/documents-backup";
import {
buildEcgBackupSection,
countEcgBackupSection,
iterateBackupEcgRecordings,
} from "@/lib/export/ecg-backup";
import {
buildEnvironmentBackupSection,
countEnvironmentBackupSection,
} from "@/lib/export/environment-backup";
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import {
buildHealthScoreBackupSection,
countHealthScoreBackupSection,
} from "@/lib/export/health-score-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
} from "@/lib/export/intraday-profile-backup";
import {
buildLifeEventsBackupSection,
countLifeEventsBackupSection,
} from "@/lib/export/life-events-backup";
import { buildMeasurementsBackupPart } from "@/lib/export/measurements-backup";
import { buildMedicationsBackupPart } from "@/lib/export/medications-backup";
import { buildMoodBackupPart } from "@/lib/export/mood-backup";
import { buildNutrientsBackupPart } from "@/lib/export/nutrients-backup";
import {
buildOnboardingBackupSection,
countOnboardingBackupSection,
} from "@/lib/export/onboarding-backup";
import {
buildProfileBackupSection,
countProfileBackupSection,
} from "@/lib/export/profile-backup";
import {
buildRecordsBackupSection,
countRecordsBackupSection,
} from "@/lib/export/records-backup";
import {
buildRelationLinksBackupSection,
countRelationLinksBackupSection,
} from "@/lib/export/relation-links-backup";
import {
buildRemindersBackupSection,
countRemindersBackupSection,
} from "@/lib/export/reminders-backup";
import {
buildSensitiveBackupSection,
countSensitiveBackupSection,
} from "@/lib/export/sensitive-backup";
import {
buildSymptomsBackupSection,
countSymptomsBackupSection,
} from "@/lib/export/symptoms-backup";
import {
buildVaccinationsBackupSection,
countVaccinationsBackupSection,
} from "@/lib/export/vaccinations-backup";
import {
buildVisitsBackupSection,
countVisitsBackupSection,
} from "@/lib/export/visits-backup";
/** One domain's slice of the file. */
export interface BackupExportSection {
/** The slice's name in `BACKUP_EXPORT_COUNT_ORDER`. */
readonly name: string;
build(ctx: BackupExportContext): Promise<BackupExportPart>;
}
/** A section module's slice and counts, as a part. */
function part(section: object, counts: object = {}): BackupExportPart {
return {
section: { ...section } as Record<string, unknown>,
counts: { ...counts } as Record<string, number>,
};
}
/**
* The slices of the file, in the order their keys appear in it. Every section
* is SPREAD, not hand-picked key by key: hand-picking is what lost the custom
* cycle symptoms, whose builder returned them while the list of keys copied
* out of it was never extended, so every backup carried `customSymptoms: []`.
* Spreading makes each section's own interface the declaration of what rides
* the wire.
*
* A key two slices both write is merged where it first appears: `manifest`,
* which the records slice opens and the sensitive slice adds its disclosures
* to.
*/
export const BACKUP_EXPORT_SECTIONS: readonly BackupExportSection[] = [
// The host's own settings, in a disaster-recovery payload only.
{ name: "appSettings", build: buildAppSettingsBackupPart },
{
// The account's own settings: units, language, modules, thresholds, the
// AI choices without the keys.
name: "accountSettings",
build: async (ctx) =>
part(
await buildAccountSettingsBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
}),
),
},
{ name: "measurements", build: buildMeasurementsBackupPart },
{ name: "medications", build: buildMedicationsBackupPart },
{ name: "mood", build: buildMoodBackupPart },
{
name: "cycle",
build: async (ctx) => {
const cycle = await buildCycleBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
});
return part(cycle, {
cycles: cycle.cycles.length,
cycleDayLogs: cycle.cycleDayLogs.length,
});
},
},
{
// Labs, illness, allergies, family history, workouts and documents, with
// the manifest that discloses what the file leaves out. Deferred, the
// documents keep their position (a key that already exists keeps its
// place when it is assigned again) and are read a document at a time by
// the streaming writer (#1239).
name: "records",
build: async (ctx) => {
const records = await buildRecordsBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
deferDocuments: ctx.deferBulk,
});
return part(
{
...records,
...(ctx.deferBulk
? {
documents: deferredRows(() =>
iterateBackupDocuments(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
}),
),
}
: {}),
},
countRecordsBackupSection(records),
);
},
},
{
// Durable self-context, user-defined metrics, and persisted pattern
// decisions.
name: "profile",
build: async (ctx) => {
const profile = await buildProfileBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
});
return part(profile, countProfileBackupSection(profile));
},
},
{
// The hourly shape of a cumulative day.
name: "intradayProfiles",
build: async (ctx) => {
const intraday = await buildIntradayProfileBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(intraday, countIntradayProfileBackupSection(intraday));
},
},
{
// The score as it was SHOWN on each local day.
name: "healthScoreRecords",
build: async (ctx) => {
const score = await buildHealthScoreBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(score, countHealthScoreBackupSection(score));
},
},
{
// The needs-based setup answers.
name: "onboardingRecord",
build: async (ctx) => {
const onboarding = await buildOnboardingBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(onboarding, countOnboardingBackupSection(onboarding));
},
},
{
// Visits, the address book behind them, and the three link tables.
name: "visits",
build: async (ctx) => {
const visits = await buildVisitsBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
});
return part(visits, countVisitsBackupSection(visits));
},
},
{
// The immunization log and the pages it was transcribed from.
name: "vaccinations",
build: async (ctx) => {
const vaccinations = await buildVaccinationsBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(vaccinations, countVaccinationsBackupSection(vaccinations));
},
},
{
// The Vorsorge reminders and their completion ledger.
name: "reminders",
build: async (ctx) => {
const reminders = await buildRemindersBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(reminders, countRemindersBackupSection(reminders));
},
},
{
// The Coach's conversations, turns and document links.
name: "coach",
build: async (ctx) => {
const coach = await buildCoachBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
});
return part(coach, countCoachBackupSection(coach));
},
},
{
// What the Coach keeps BETWEEN threads.
name: "coachMemory",
build: async (ctx) => {
const memory = await buildCoachMemoryBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(memory, countCoachMemoryBackupSection(memory));
},
},
{
// The screener history and the consent record, disaster-recovery only;
// its manifest entries join the records slice's manifest.
name: "sensitive",
build: async (ctx) => {
const sensitive = await buildSensitiveBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(
{
mentalHealthAssessments: sensitive.mentalHealthAssessments,
consentReceipts: sensitive.consentReceipts,
manifest: sensitive.manifest,
},
countSensitiveBackupSection(sensitive),
);
},
},
{
// Which conditions a document was filed against, and what the
// extraction pass read out of it.
name: "documentFiling",
build: async (ctx) => {
const filing = await buildDocumentFilingBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(filing, countDocumentFilingBackupSection(filing));
},
},
{
// The bests and the badges. No `purpose`: neither model tombstones and
// neither holds ciphertext, so both purposes carry the same bytes.
name: "awards",
build: async (ctx) => {
const awards = await buildAwardsBackupSection(ctx.prisma, ctx.userId);
return part(awards, countAwardsBackupSection(awards));
},
},
{
// The per-day readings and the location periods that explain them.
name: "environment",
build: async (ctx) => {
const environment = await buildEnvironmentBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(environment, countEnvironmentBackupSection(environment));
},
},
{
// The ECG strips, their rhythm verdicts and their traces; deferred, read
// a page of strips at a time by the streaming writer.
name: "ecg",
build: async (ctx) => {
const ecg = await buildEcgBackupSection(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
defer: ctx.deferBulk,
});
return part(
{
...ecg,
...(ctx.deferBulk
? {
ecgRecordings: deferredRows(() =>
iterateBackupEcgRecordings(ctx.prisma, ctx.userId, {
purpose: ctx.purpose,
}),
),
}
: {}),
},
countEcgBackupSection(ecg),
);
},
},
{
// The person's own symptoms with their occurrences nested inside.
name: "symptoms",
build: async (ctx) => {
const symptoms = await buildSymptomsBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(symptoms, countSymptomsBackupSection(symptoms));
},
},
{
// The person's life events.
name: "lifeEvents",
build: async (ctx) => {
const lifeEvents = await buildLifeEventsBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(lifeEvents, countLifeEventsBackupSection(lifeEvents));
},
},
{
// The five relation links that close the relation matrix.
name: "relationLinks",
build: async (ctx) => {
const links = await buildRelationLinksBackupSection(
ctx.prisma,
ctx.userId,
{ purpose: ctx.purpose },
);
return part(links, countRelationLinksBackupSection(links));
},
},
{ name: "nutrients", build: buildNutrientsBackupPart },
];
/**
* The order the audit trail lists the slices' row counts in. It is not the
* file's order: the counts were laid out before most sections existed, and
* every backup job's audit row since reads this way.
*/
export const BACKUP_EXPORT_COUNT_ORDER: readonly string[] = [
"measurements",
"medications",
"mood",
"cycle",
"nutrients",
"records",
"profile",
"intradayProfiles",
"healthScoreRecords",
"onboardingRecord",
"visits",
"vaccinations",
"reminders",
"coach",
"coachMemory",
"sensitive",
"documentFiling",
"awards",
"environment",
"ecg",
"symptoms",
"lifeEvents",
"relationLinks",
];
+18 -19
View File
@@ -238,12 +238,19 @@ export const BACKED_UP_MODELS = [
* live, and the guard reads those files instead of guessing which ones matter.
*/
export const BACKUP_WRITER_FILES: readonly string[] = [
"src/lib/export/full-backup-payload.ts",
// Measurements are read through this pager, not directly. The guard caught
// its absence from this list on the first run — the same "the caller
// delegates" mistake the list exists to prevent, made while writing the list.
"src/lib/export/measurements-backup.ts",
"src/lib/export/labs-backup.ts",
"src/lib/export/illness-backup.ts",
"src/lib/export/allergies-backup.ts",
"src/lib/export/family-history-backup.ts",
"src/lib/export/workouts-backup.ts",
"src/lib/export/nutrients-backup.ts",
"src/lib/export/documents-backup.ts",
"src/lib/export/medications-backup.ts",
"src/lib/export/mood-backup.ts",
// Measurements are read through this pager, not directly — the "caller
// delegates" mistake this list exists to prevent, caught on its first run.
"src/lib/export/paged-measurements.ts",
"src/lib/export/records-backup.ts",
// The illness day-log's symptom include is a shared constant living here,
// not a literal in the records writer. Same "the caller delegates" shape as
// the measurement pager above, found the same way: by a check that stopped
@@ -273,7 +280,7 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// reasons for that live in the module itself.
"src/lib/export/sensitive-backup.ts",
// What a document was filed against and what was read out of it. The
// documents themselves are read by `records-backup.ts`; these two read
// documents themselves are read by `documents-backup.ts`; these two read
// through their OWN delegates here, for the reason the visits comment gives.
"src/lib/export/document-filing-backup.ts",
// The bests and the badges, and the environmental history with the location
@@ -300,30 +307,22 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// ends beside each other, each through its OWN delegate.
"src/lib/export/relation-links-backup.ts",
"src/lib/cycle/backup.ts",
// The medication category is read through the one helper that normalises
// it; the payload writer calls the helper rather than the delegate. The
// same "the caller delegates" shape as the measurement pager.
// The medication category, read and written through its one helper.
"src/lib/medication-category.ts",
];
const WRITER_FILES_WITHOUT_RESTORE = new Set([
"src/lib/export/full-backup-payload.ts",
"src/lib/export/measurements-backup.ts",
"src/lib/export/medications-backup.ts",
"src/lib/export/mood-backup.ts",
"src/lib/export/paged-measurements.ts",
"src/lib/export/records-backup.ts",
"src/lib/illness/dto.ts",
]);
export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/measurements-backup.ts",
"src/lib/export/measurements-restore.ts",
"src/lib/export/medications-restore.ts",
"src/lib/export/mood-restore.ts",
"src/lib/export/labs-backup.ts",
"src/lib/export/illness-backup.ts",
"src/lib/export/allergies-backup.ts",
"src/lib/export/family-history-backup.ts",
"src/lib/export/workouts-backup.ts",
"src/lib/export/nutrients-backup.ts",
"src/lib/export/documents-backup.ts",
...BACKUP_WRITER_FILES.filter(
(file) => !WRITER_FILES_WITHOUT_RESTORE.has(file),
),
+54
View File
@@ -0,0 +1,54 @@
/**
* A bulk table the payload declares but has NOT read.
*
* `buildFullBackupPayload({ deferBulk: true })` puts one of these where the
* row array would sit. The streaming writer recognises it, opens the JSON
* array itself and pulls rows through page by page, so a table with half a
* million rows never exists as an array at all. Every other caller leaves
* `deferBulk` unset and gets the arrays, unchanged.
*
* The marker key is a symbol so it cannot collide with a payload field and so
* the deferred payload can never be handed to `JSON.stringify` by accident and
* come back looking merely empty — `streamFullBackupJson` is the only reader.
*/
const DEFERRED_ROWS = Symbol("healthlog.backup.deferredRows");
export interface DeferredRows {
readonly [DEFERRED_ROWS]: true;
/**
* Open the row source. Called exactly once, by the streaming writer. A row
* is a plain object, or a `StreamedField` (`streamed-field.ts`) for a row
* with one field too large to hold as a string.
*/
rows(): AsyncIterable<unknown>;
}
export function deferredRows(rows: () => AsyncIterable<unknown>): DeferredRows {
return { [DEFERRED_ROWS]: true, rows };
}
/** True when a payload value is a deferred bulk table rather than an array. */
export function isDeferredRows(value: unknown): value is DeferredRows {
return (
typeof value === "object" &&
value !== null &&
(value as Record<symbol, unknown>)[DEFERRED_ROWS] === true
);
}
/**
* How many rows one page of a bulk table carries.
*
* Smaller than the measurement pager's 10 000 on purpose: an intake event
* drags its medication's name along and a mood entry drags its links and its
* day context, so a page of either costs several times what a page of
* measurements costs. The number is a memory budget, not a throughput knob.
*/
export const BULK_PAGE_SIZE = 5_000;
/** Drain an async row source into an array. The materialising arm's only use. */
export async function collect<T>(rows: AsyncIterable<T>): Promise<T[]> {
const out: T[] = [];
for await (const row of rows) out.push(row);
return out;
}
+306
View File
@@ -4,6 +4,13 @@
* A disaster-recovery file carries each document's stored bytes verbatim; a
* portable one carries metadata only and cannot be restored into documents.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { decryptDocumentSummary } from "@/lib/documents/store";
import { getEvent } from "@/lib/logging/context";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { streamedField, type StreamedField } from "@/lib/export/streamed-field";
import {
decodeEncryptedBytes,
type RestoreContext,
@@ -11,6 +18,305 @@ import {
import { normaliseSourceInstance } from "@/lib/validations/inbound-documents";
import type { BackupPayload } from "@/lib/validations/backup";
// ── Export ───────────────────────────────────────────────────────────────────
export interface DocumentsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
export interface DocumentBackupEntry {
id: string;
kind: string;
title: string | null;
filename: string | null;
mimeType: string;
byteSize: number;
status: string;
reportDate: string | null;
documentDate: string | null;
/** Portable export only; canonical DR carries summaryEncrypted instead. */
summary?: string | null;
createdAt: string;
updatedAt?: string;
/** Base64 of the already-encrypted BYTEA value; never plaintext. */
contentEncrypted?: string;
contentSha256?: string | null;
contentCodec?: string;
providerType?: string | null;
errorReason?: string | null;
/** Base64 of the already-encrypted summary BYTEA value. */
summaryEncrypted?: string | null;
summaryGeneratedAt?: string | null;
summaryState?: string;
/** Refs #776 — the index-attempt record, carried verbatim (canonical DR). */
lastIndexAttemptAt?: string | null;
lastIndexOutcome?: string | null;
/**
* Where an imported document came from, both payload
* kinds. Optional so a file written before the fields existed still parses.
*/
sourceSystem?: string | null;
sourceId?: string | null;
/** The source instance (origin); absent in older files. */
sourceInstance?: string | null;
/** Canonical DR only: the import's hold on automatic AI reading. */
aiReadDeferred?: boolean;
}
/** The document columns both purposes carry. */
const DOCUMENT_PORTABLE_SELECT = {
id: true,
kind: true,
title: true,
filename: true,
mimeType: true,
byteSize: true,
status: true,
reportDate: true,
documentDate: true,
summaryEncrypted: true,
sourceSystem: true,
sourceId: true,
sourceInstance: true,
createdAt: true,
} as const satisfies Prisma.InboundDocumentSelect;
/**
* A disaster-recovery document without its stored bytes. The paged reader
* reads this for every document at once and the bytes one slice at a time.
*/
const DOCUMENT_DR_METADATA_SELECT = {
...DOCUMENT_PORTABLE_SELECT,
contentSha256: true,
contentCodec: true,
providerType: true,
errorReason: true,
summaryGeneratedAt: true,
summaryState: true,
lastIndexAttemptAt: true,
lastIndexOutcome: true,
aiReadDeferred: true,
updatedAt: true,
} as const satisfies Prisma.InboundDocumentSelect;
const DOCUMENT_DR_SELECT = {
...DOCUMENT_DR_METADATA_SELECT,
contentEncrypted: true,
} as const satisfies Prisma.InboundDocumentSelect;
/** Total, so two exports of the same account list the documents alike. */
const DOCUMENT_ORDER = [
{ createdAt: "desc" },
{ id: "desc" },
] as const satisfies Prisma.InboundDocumentOrderByWithRelationInput[];
type PortableDocumentRow = Prisma.InboundDocumentGetPayload<{
select: typeof DOCUMENT_PORTABLE_SELECT;
}>;
type DisasterRecoveryDocumentRow = Prisma.InboundDocumentGetPayload<{
select: typeof DOCUMENT_DR_METADATA_SELECT;
}>;
type DocumentRow =
| PortableDocumentRow
| Prisma.InboundDocumentGetPayload<{ select: typeof DOCUMENT_DR_SELECT }>;
/**
* One document as it rides the wire. `contentEncrypted` is the base64 of the
* stored bytes for a disaster-recovery entry and null for a portable one,
* which carries the readable summary instead. Shared by the materialising
* reader and the paged one, so the two cannot drift.
*/
function serialiseDocument(
d: PortableDocumentRow | DisasterRecoveryDocumentRow,
contentEncrypted: string | null,
): DocumentBackupEntry {
const metadata = {
id: d.id,
kind: d.kind,
title: d.title,
filename: d.filename,
mimeType: d.mimeType,
byteSize: d.byteSize,
status: d.status,
reportDate: d.reportDate ? dateOnlyKey(d.reportDate) : null,
documentDate: d.documentDate ? dateOnlyKey(d.documentDate) : null,
sourceSystem: d.sourceSystem,
sourceId: d.sourceId,
sourceInstance: d.sourceInstance,
createdAt: d.createdAt.toISOString(),
};
if (contentEncrypted !== null && "contentCodec" in d) {
const dr: DisasterRecoveryDocumentRow = d;
return {
...metadata,
reportDate: dr.reportDate?.toISOString() ?? null,
documentDate: dr.documentDate?.toISOString() ?? null,
updatedAt: dr.updatedAt.toISOString(),
contentEncrypted,
contentSha256: dr.contentSha256,
contentCodec: dr.contentCodec,
providerType: dr.providerType,
errorReason: dr.errorReason,
summaryEncrypted: dr.summaryEncrypted
? Buffer.from(dr.summaryEncrypted).toString("base64")
: null,
summaryGeneratedAt: dr.summaryGeneratedAt?.toISOString() ?? null,
summaryState: dr.summaryState,
lastIndexAttemptAt: dr.lastIndexAttemptAt?.toISOString() ?? null,
lastIndexOutcome: dr.lastIndexOutcome,
aiReadDeferred: dr.aiReadDeferred,
};
}
return {
...metadata,
summary: decryptSummarySoft(d.summaryEncrypted),
};
}
/**
* How much of a document's stored bytes one read takes. A multiple of three,
* so the base64 of every slice joins into the base64 of the whole.
*/
const DOCUMENT_SLICE_BYTES = 1536 * 1024;
/**
* How much of a document's base64 the streaming writer shows its observer
* (the restore preview). The key id sits in the first characters; the cap is
* above the longest value the preview keeps as a key sample, so a shortened
* value is never kept as one. A multiple of four, so it still reads as base64.
*/
const DOCUMENT_OBSERVED_CHARS = 128 * 1024;
/**
* Every document of the account, one at a time, for the streaming backup
* writer (#1239).
*
* A document is up to a hundred megabytes, and the materialising read holds
* every document's bytes, their base64 and the section's JSON at once: about
* five times the size of the vault in heap, which a 524 MB process does not
* have for a vault of 100 MB. Here the metadata of all documents is read
* once, and each document's bytes are read a slice at a time and handed on as
* base64 pieces (`streamedField`), so what is held is one slice.
*
* A document that disappears between the metadata read and its first slice
* is left out, as a later read would not have seen it. One that disappears
* halfway fails the copy: half a document is not a document.
*/
export async function* iterateBackupDocuments(
prisma: Pick<PrismaClient, "inboundDocument" | "$queryRaw">,
userId: string,
options: DocumentsBackupOptions = {},
): AsyncGenerator<DocumentBackupEntry | StreamedField, void, void> {
if (options.purpose !== "disaster-recovery") {
const rows = await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_PORTABLE_SELECT,
});
for (const row of rows) yield serialiseDocument(row, null);
return;
}
const rows = await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_DR_METADATA_SELECT,
});
const slice = async (id: string, from: number): Promise<Buffer | null> => {
const [row] = await prisma.$queryRaw<Array<{ part: Uint8Array | null }>>`
SELECT substring(content_encrypted FROM ${from + 1}::int
FOR ${DOCUMENT_SLICE_BYTES}::int) AS part
FROM inbound_documents
WHERE id = ${id} AND user_id = ${userId}
`;
if (!row) return null;
const part = row.part ?? new Uint8Array(0);
// A view, not a copy: the slice is read once and dropped.
return Buffer.from(part.buffer, part.byteOffset, part.byteLength);
};
for (const row of rows) {
const first = await slice(row.id, 0);
if (first === null) continue;
const head = first.toString("base64");
const pieces = async function* (): AsyncGenerator<string, void, void> {
yield head;
let last = first.byteLength;
for (let at = last; last === DOCUMENT_SLICE_BYTES; at += last) {
const next = await slice(row.id, at);
if (next === null) {
throw new Error(
`Document ${row.id} was removed while it was being backed up; ` +
"the next run copies the account again.",
);
}
last = next.byteLength;
if (last > 0) yield next.toString("base64");
}
};
yield streamedField(
serialiseDocument(row, ""),
"contentEncrypted",
head.length > DOCUMENT_OBSERVED_CHARS
? head.slice(0, DOCUMENT_OBSERVED_CHARS)
: head,
pieces,
);
}
}
/** Decrypt a document's optional AI summary fail-soft (null on any error). */
function decryptSummarySoft(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptDocumentSummary(buf);
} catch (err) {
getEvent()?.addWarning(
`document summary decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
/**
* The document slice of the records section. A portable entry carries the
* metadata and the readable summary; a disaster-recovery one the stored
* bytes verbatim, unless the caller reads them itself (`deferDocuments`).
*/
export async function buildDocumentsBackupSlice(
prisma: Pick<PrismaClient, "inboundDocument">,
userId: string,
disasterRecovery: boolean,
deferDocuments: boolean,
): Promise<{ documents: DocumentBackupEntry[] }> {
const documentRows: DocumentRow[] = deferDocuments
? []
: disasterRecovery
? await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_DR_SELECT,
})
: await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_PORTABLE_SELECT,
});
const documents: DocumentBackupEntry[] = documentRows.map((d) =>
serialiseDocument(
d,
disasterRecovery && "contentEncrypted" in d
? Buffer.from(d.contentEncrypted).toString("base64")
: null,
),
);
return { documents };
}
// ── Restore ──────────────────────────────────────────────────────────────────
/**
+30
View File
@@ -0,0 +1,30 @@
/**
* What every domain's export is handed, and what it hands back.
*
* Each domain reads its own tables and returns its slice of the payload with
* its row counts. `buildFullBackupPayload` lays the slices out in the order
* `backup-domains.ts` lists them, which is the order the file has always had.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export type BackupPurpose = "portable-export" | "disaster-recovery";
export interface BackupExportContext {
readonly prisma: PrismaClient;
readonly userId: string;
readonly purpose: BackupPurpose;
/** `purpose === "disaster-recovery"`, spelled out because every row asks. */
readonly disasterRecovery: boolean;
/**
* Leave the bulk tables unread and put `DeferredRows` markers where their
* arrays sit (`deferred-rows.ts`); only the streaming writer asks for this.
*/
readonly deferBulk: boolean;
}
export interface BackupExportPart {
/** The payload keys this domain writes, in the order they appear. */
section: Record<string, unknown>;
/** Row counts for the audit trail, in the order they appear. */
counts: Record<string, number>;
}
+20
View File
@@ -1,9 +1,29 @@
/**
* The family history, with both backup ends in one file.
*/
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";
// ── Export ───────────────────────────────────────────────────────────────────
/** The family-history slice of the records section. */
export async function buildFamilyHistoryBackupSlice(
prisma: Pick<PrismaClient, "familyHistoryEntry">,
userId: string,
): Promise<{ familyHistory: FamilyHistoryEntryDTO[] }> {
const familyRows = await prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
});
const familyHistory = familyRows.map(toFamilyHistoryEntryDTO);
return { familyHistory };
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function clearFamilyHistory(ctx: RestoreContext): Promise<void> {
File diff suppressed because it is too large. Load diff
+96
View File
@@ -2,13 +2,109 @@
* The illness history: episodes, their day logs, and the symptoms each day
* recorded. 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 {
toIllnessEpisodeDTO,
toIllnessDayLogDTO,
dayLogSymptomInclude,
type IllnessEpisodeDTO,
type IllnessDayLogDTO,
} from "@/lib/illness/dto";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
import { recordUnknownKeys } from "@/lib/export/restore-skips";
// ── Export ───────────────────────────────────────────────────────────────────
type CanonicalIllnessDayLog = IllnessDayLogDTO & {
noteEncrypted?: string | null;
tz?: string | null;
createdAt?: string;
deletedAt?: string | null;
};
export type CanonicalIllnessEpisode = IllnessEpisodeDTO & {
noteEncrypted?: string | null;
/** The body site's ciphertext, disaster-recovery purpose only (v1.39.2). */
bodySiteEncrypted?: string | null;
deletedAt?: string | null;
dayLogs: CanonicalIllnessDayLog[];
};
/** The illness slice of the records section: episodes with their day logs. */
export async function buildIllnessBackupSlice(
prisma: Pick<PrismaClient, "illnessEpisode">,
userId: string,
disasterRecovery: boolean,
): Promise<{ illnessEpisodes: CanonicalIllnessEpisode[] }> {
const episodeRows = await prisma.illnessEpisode.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
include: {
dayLogs: {
...(disasterRecovery ? {} : { where: { deletedAt: null } }),
orderBy: { date: "asc" },
include: dayLogSymptomInclude,
},
},
});
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,
// Ciphertext verbatim, like the note: the disaster-recovery file never
// carries the site in the clear.
bodySite: null,
bodySiteEncrypted: row.bodySiteEncrypted
? Buffer.from(row.bodySiteEncrypted).toString("base64")
: null,
laterality: row.laterality,
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),
}));
return { illnessEpisodes };
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function clearIllness(ctx: RestoreContext): Promise<void> {
+259 -3
View File
@@ -6,15 +6,271 @@
* restore mints fresh marker ids, and `(userId, name)` is what the catalogue
* is unique on, so the name is what survives the trip.
*/
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { Buffer } from "node:buffer";
import type { PrismaClient } from "@/generated/prisma/client";
import {
decryptContextFromBytes,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { annotate } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
/**
* Decrypt an encrypted free-text column for a portable export, or say plainly
* that this one could not be read.
*
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
* that is right — one bad row must not take the page down. In an export it is
* not: `null` is exactly what a column that was never written looks like, so
* the restore importer writes nothing and a note the person did write
* disappears without anyone being told. The Coach transcript and the doctor
* report both refuse that trade, one with a placeholder sentence and one with
* a rendered flag; this follows the same rule.
*
* The warning the soft helpers emitted is kept, so a systemic key gap still
* shows up in the wide event rather than only in the file.
*/
function readForExport(
buf: Uint8Array | null,
label: string,
decode: (bytes: Uint8Array) => string,
): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decode(buf);
} catch (err) {
getEvent()?.addWarning(
`${label} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNREADABLE_EXPORT_MARKER;
}
}
export interface LabResultBackupEntry {
/** Present in canonical DR payloads so rows keep a stable identity. */
id?: string;
/** The source row's id in a portable payload, for the relation links. */
ref?: string;
panel: string | null;
analyte: string;
value: number | null;
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** The window the reading's own source report printed, verbatim + derived. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
/**
* The value and unit the source report printed, kept when a write
* converted the reading into its marker's unit.
*/
sourceValue?: number | null;
sourceUnit?: string | null;
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 {
/** Present in canonical DR payloads; name remains the natural key. */
id?: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hidden: boolean;
context: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
* The other spellings the marker answers to, with where
* each came from. Absent on files written before them.
*/
aliases?: BiomarkerAliasBackupEntry[];
createdAt?: string;
updatedAt?: string;
}
export interface BiomarkerAliasBackupEntry {
/** Present in canonical DR payloads. */
id?: string;
name: string;
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
createdAt?: string;
}
/**
* One marker folded into another. The target travels by id
* in a disaster-recovery file and by name in both, the way a lab result's
* marker does; the source is gone, so its id and name are the record.
*/
export interface BiomarkerMergeBackupEntry {
id?: string;
targetBiomarkerId?: string;
targetName: string;
sourceBiomarkerId: string;
sourceName: string;
readingCount: number;
mergedAt: string;
}
/** The lab slice of the records section: results, markers, merge ledger. */
export async function buildLabsBackupSlice(
prisma: Pick<
PrismaClient,
"labResult" | "biomarker" | "biomarkerAlias" | "biomarkerMerge"
>,
userId: string,
disasterRecovery: boolean,
): Promise<{
labResults: LabResultBackupEntry[];
biomarkers: BiomarkerBackupEntry[];
biomarkerMerges: BiomarkerMergeBackupEntry[];
}> {
const [labResultRows, biomarkerRows, biomarkerAliasRows, biomarkerMergeRows] =
await Promise.all([
prisma.labResult.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { takenAt: "desc" },
include: { biomarker: { select: { name: true } } },
}),
prisma.biomarker.findMany({
where: { userId },
orderBy: { name: "asc" },
}),
prisma.biomarkerAlias.findMany({
where: { userId },
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }],
select: {
id: true,
biomarkerId: true,
name: true,
source: true,
createdAt: true,
},
}),
prisma.biomarkerMerge.findMany({
where: { userId },
orderBy: { mergedAt: "asc" },
include: { target: { select: { name: true } } },
}),
]);
const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({
...(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,
}
: {
// No id: a portable restore mints a fresh one. The relation links
// still name this row, so the file says which row they mean.
ref: r.id,
note: readForExport(
r.noteEncrypted,
"lab note",
decryptNoteFromBytes,
),
}),
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
sourceReferenceHigh: r.sourceReferenceHigh,
sourceReferenceText: r.sourceReferenceText,
sourceValue: r.sourceValue,
sourceUnit: r.sourceUnit,
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
}));
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
for (const alias of biomarkerAliasRows) {
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
list.push({
name: alias.name,
source: alias.source as BiomarkerAliasBackupEntry["source"],
...(disasterRecovery
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
: {}),
});
aliasesByMarker.set(alias.biomarkerId, list);
}
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
(m) => ({
...(disasterRecovery
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
: {}),
targetName: m.target.name,
sourceBiomarkerId: m.sourceBiomarkerId,
sourceName: m.sourceName,
readingCount: m.readingCount,
mergedAt: m.mergedAt.toISOString(),
}),
);
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
...(disasterRecovery
? {
id: b.id,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}
: {}),
name: b.name,
unit: b.unit,
lowerBound: b.lowerBound,
upperBound: b.upperBound,
panel: b.panel,
hidden: b.hidden,
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
return { labResults, biomarkers, biomarkerMerges };
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function clearLabResults(ctx: RestoreContext): Promise<void> {
+138 -414
View File
@@ -1,429 +1,153 @@
/**
* The measurements, with both backup ends in one file.
*
* The restore reads the readings twice. The first read (`readStreamedBackup`,
* before anything is deleted) checks each one and notes what the second read
* has to know about the file as a whole: which `stats:` aggregates are live,
* and which nights the retired heart-rate variability filing shares with an
* RMSSD reading (`createMeasurementRestorePrepass`). The second read, inside
* the transaction, writes them a batch at a time (`restoreMeasurements`), so
* the restore never holds more than one batch.
* The measurements domain's export half: the select every carried column is
* named in, the two wire shapes, and the paged read. The restore half lives
* in `measurements-restore.ts`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import { encryptNote } from "@/lib/crypto/note-cipher";
import {
isLegacyRmssdRow,
LEGACY_RMSSD_SOURCES,
} from "@/lib/export/legacy-rmssd";
import {
insertMeasurementRows,
type MeasurementInsertRow,
} from "@/lib/export/measurement-bulk-insert";
import {
decodeEncryptedBytes,
type RestoreContext,
} from "@/lib/export/restore-context";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
import {
FOLDED_TYPES,
compactionTombstoneCoverId,
} from "@/lib/measurements/folded-window";
import type { BackupMeasurement } from "@/lib/validations/backup";
import { Buffer } from "node:buffer";
// ── Restore ──────────────────────────────────────────────────────────────────
/** Both unique identities of a row, as it would be filed under `HRV_RMSSD`. */
function rmssdTwinKeysOf(row: BackupMeasurement): string[] {
const keys = [
`at|${row.source}|${new Date(row.measuredAt).toISOString()}|${row.sleepStage ?? ""}`,
];
if (row.externalId) keys.push(`ext|${row.source}|${row.externalId}`);
return keys;
}
const foldStatsKey = (source: string, type: string, externalId: string) =>
`${source}|${type}|${externalId}`;
/** What the file's first read noted about its readings as a whole. */
export interface MeasurementRestorePrepass {
/** Called for every reading as the first read checks it. */
note(row: BackupMeasurement): void;
/** The live `stats:` ids of the folded types, keyed by source and type. */
readonly liveFoldStatsIds: ReadonlySet<string>;
/** A row the migration's rule leaves out under either type. */
isShadowedLegacyRmssd(row: BackupMeasurement): boolean;
}
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import { collect, deferredRows } from "@/lib/export/deferred-rows";
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import { iterateMeasurementPages } from "@/lib/export/paged-measurements";
/**
* The notes the measurement pass takes from the first read of the file.
* Every restorable `Measurement` scalar. `userId` is deliberately absent — the
* backup is user-scoped, so the owner is implicit in the query and restored
* from the target account rather than the payload.
*
* The live `stats:` ids of the folded types, so the measurement pass can tell
* a compaction tombstone from a person's deletion without a third read of the
* file. Keyed by source as well: Health Connect folds into the same `stats:`
* ids as Apple Health, and an Apple Health hour must not make a Health
* Connect deletion look folded.
*
* Migration 0420 refiled Fitbit and Google Health HRV as `HRV_RMSSD`: both
* providers report a nightly RMSSD, which their mappers had written as the
* SDNN series. A file written before that release still carries those rows
* as `HEART_RATE_VARIABILITY`, and restoring it verbatim would undo the
* migration for the account. The restore applies the migration's rule:
*
* - a legacy row moves to RMSSD, unless a LIVE RMSSD row of its source
* holds the same night under either unique identity (external id, or
* measured-at and sleep stage). Then the twin holds the night and the
* legacy row is not written under either type: as RMSSD it would break
* the unique key, as SDNN it would put RMSSD back into the SDNN series;
* - a legacy row whose only twin is a tombstone wins: the tombstone is the
* row left out, since it shares the key and holds nothing;
* - a legacy tombstone with any RMSSD twin is left out.
*
* The keys are gathered on the first read.
* Named rather than inlined so `measurement-backup-completeness.test.ts` can
* compare it field-by-field against the model in `prisma/schema.prisma`. A
* column added to the model and forgotten here is how the aggregation
* provenance went missing from disaster-recovery backups for several releases.
*/
export function createMeasurementRestorePrepass(): MeasurementRestorePrepass {
const liveFoldStatsIds = new Set<string>();
const foldedTypes = new Set<string>(FOLDED_TYPES);
const foldSources = new Set<string>(["APPLE_HEALTH", "HEALTH_CONNECT"]);
const noteLiveFoldRow = (row: BackupMeasurement) => {
if (
row.deletedAt == null &&
row.source !== undefined &&
foldSources.has(row.source) &&
foldedTypes.has(row.type) &&
row.externalId?.startsWith("stats:")
) {
liveFoldStatsIds.add(foldStatsKey(row.source, row.type, row.externalId));
}
};
const rmssdLiveKeys = new Set<string>();
const rmssdAnyKeys = new Set<string>();
const legacyLiveKeys = new Set<string>();
const noteRmssdTwin = (row: BackupMeasurement) => {
if (isLegacyRmssdRow(row)) {
if (row.deletedAt == null) {
for (const key of rmssdTwinKeysOf(row)) legacyLiveKeys.add(key);
}
return;
}
if (
row.type === "HRV_RMSSD" &&
row.source !== undefined &&
LEGACY_RMSSD_SOURCES.has(row.source)
) {
for (const key of rmssdTwinKeysOf(row)) {
rmssdAnyKeys.add(key);
if (row.deletedAt == null) rmssdLiveKeys.add(key);
}
}
};
const anyKeyIn = (row: BackupMeasurement, keys: ReadonlySet<string>) =>
rmssdTwinKeysOf(row).some((key) => keys.has(key));
const isShadowedLegacyRmssd = (row: BackupMeasurement): boolean => {
if (isLegacyRmssdRow(row)) {
return anyKeyIn(
row,
row.deletedAt == null ? rmssdLiveKeys : rmssdAnyKeys,
);
}
// An RMSSD tombstone on the night a live legacy row is about to take.
return (
row.type === "HRV_RMSSD" &&
row.deletedAt != null &&
row.source !== undefined &&
LEGACY_RMSSD_SOURCES.has(row.source) &&
anyKeyIn(row, legacyLiveKeys) &&
!anyKeyIn(row, rmssdLiveKeys)
);
};
return {
note(row) {
noteLiveFoldRow(row);
noteRmssdTwin(row);
},
liveFoldStatsIds,
isShadowedLegacyRmssd,
};
}
const MEASUREMENT_BACKUP_SELECT = {
id: true,
type: true,
value: true,
valueMin: true,
valueMax: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
externalId: true,
externalSourceVersion: true,
aggregationProvenance: true,
glucoseContext: true,
sleepStage: true,
rhythmClassification: true,
deviceType: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.MeasurementSelect;
export async function clearMeasurements(ctx: RestoreContext): Promise<void> {
const measurements = await ctx.tx.measurement.deleteMany({
where: { userId: ctx.ownerId },
});
ctx.cleared.measurements = measurements.count;
}
type BackupMeasurementRow = Prisma.MeasurementGetPayload<{
select: typeof MEASUREMENT_BACKUP_SELECT;
}>;
/**
* The persisted measurement rollup partition, wiped for the same reason as
* the mood one: kept, it would hold the previous owner's daily means after
* the underlying measurements were replaced, and the chart's `source=rollup`
* fast-path could surface a stale 30-day mean built from rows that no longer
* exist. The rebuild after the commit mints fresh rows from the restored set.
* One measurement, in whichever of the two wire shapes the caller asked for.
*
* Named rather than inlined so the paged reader below and the materialising
* reader beside it cannot drift: both call this, so a column that reaches one
* shape reaches the other by construction. `measurement-backup-completeness.
* test.ts` proves a selected column reaches a payload by matching
* `<key>: measurement.`, which is why the parameter keeps that spelling.
*/
export async function clearMeasurementRollups(
ctx: RestoreContext,
): Promise<void> {
await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } });
}
function toRestoredMeasurementData(measurement: BackupMeasurement) {
return {
type: isLegacyRmssdRow(measurement)
? ("HRV_RMSSD" as const)
: 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: null,
notesEncrypted:
measurement.notesEncrypted == null
? encryptNote(measurement.notes ?? null)
: decodeEncryptedBytes(measurement.notesEncrypted),
externalId: measurement.externalId ?? null,
externalSourceVersion: measurement.externalSourceVersion ?? null,
// Authority of an Apple Health `stats:` aggregate. A backup
// written before this field rode the payload carries nothing, and
// restoring NULL would flatten the authority ladder: an export.xml
// source-day estimate could then overwrite a native HealthKit
// statistic, which is the ambiguity migration 0263 already
// repaired once. Such a row is exactly the case LEGACY_UNKNOWN
// exists for, so it comes back repairable instead of ambiguous.
//
// The gate is scoped exactly as 0263 scoped its backfill and as
// the cumulative drain scopes its own LEGACY_UNKNOWN writes:
// Apple Health rows only. Other providers mint `stats:` ids for
// their cumulative dailies too (Fitbit, Google Health) and have
// never carried provenance — marking those would invent a state
// they were never in. Ordinary point rows keep NULL honestly.
//
// Only an ABSENT field is the old-backup case. A current backup
// writes the field for every row, and an explicit null is the row's
// real state: the nightly daily-mean fold writes Apple Health
// `stats:` rows with no provenance. Treating that null as absent
// turned every such row into LEGACY_UNKNOWN on restore (#1031).
aggregationProvenance: (measurement.aggregationProvenance !== undefined
? measurement.aggregationProvenance
: measurement.source === "APPLE_HEALTH" &&
measurement.externalId?.startsWith("stats:")
? "LEGACY_UNKNOWN"
: null) as never,
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) }
: {}),
};
}
/**
* Which deleted readings the file carries are not written back.
*
* Deleted readings the file carries are restored as deleted rows
* (tombstones), with one exception: a tombstone deleted longer ago than
* `TOMBSTONE_RETENTION_DAYS` is not written at all.
*
* Such a row is exactly what the nightly tombstone purge
* (`measurement-tombstone-cleanup.ts`) hard-deletes, so writing it costs the
* restore a row in thirteen indexes for a row that is gone by the next
* morning. Nothing reads it in between: the delta feed answers
* `cursorExpired` for every cursor older than the same horizon, so no client
* that would still be served this tombstone exists; the step-consolidation
* repair and the hourly rebuild only look inside the horizon; and the undo of
* a delete is offered for what the purge has not removed yet, which this row
* would not survive either.
*
* Younger tombstones are written. They still have readers: a paired client
* that has not yet drained that deletion from the delta feed, the undo of a
* recent delete, the consolidation repairs that rebuild from folded samples,
* and the unique `(user, type, source, externalId)` key that stops a device
* from uploading a folded sample again. The backup file itself is unchanged
* and keeps every row.
*
* Nor is a compaction tombstone written, whatever its age: a raw sample the
* folds soft-deleted before v1.42, whose whole hour or day the fold had
* reached when it was deleted and which a live `stats:` row of the same
* source in the same file covers. The folds delete those outright now and the
* backlog purge removes the old ones, so writing one back would only hand the
* purge the same row again; the `folded_window` ingest guard is what keeps
* the sample from being uploaded again. Classified by the rule the purge and
* the sync feed use (`folded-window.ts`), against the `stats:` ids gathered
* on the file's first read and the zone the account will have after the
* restore. Counted with the expired ones.
*/
async function expiredTombstoneFilter(
ctx: RestoreContext,
): Promise<(measurement: BackupMeasurement) => boolean> {
const { tx, ownerId, payload } = ctx;
const tombstoneHorizonMs = Date.now() - TOMBSTONE_RETENTION_DAYS * 86_400_000;
const foldTz = resolveUserTimezone(
payload.accountSettings?.timezone ??
(
await tx.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
})
)?.timezone ??
null,
);
// Only once the fold repair has been through the means this file holds.
// The repair recomputes a mean an older release folded from part of its
// day, and these tombstones are what it reads. A file exported after the
// account's repair finished carries repaired means, so its compaction
// tombstones can go. Any other file is written back whole, the account's
// repair marker is removed, and the repair runs again once the transaction
// commits; the purge then removes the tombstones as usual.
const foldRepair = await tx.measurementFoldRepair.findUnique({
where: { userId: ownerId },
select: { completedAt: true },
});
const dropCompactionTombstones =
foldRepair !== null &&
new Date(payload.exportedAt).getTime() > foldRepair.completedAt.getTime();
if (!dropCompactionTombstones) {
await tx.measurementFoldRepair.deleteMany({
where: { userId: ownerId },
});
ctx.outputs.foldRepairReset = true;
}
const { liveFoldStatsIds } = ctx.measurementPrepass;
const isCompactionTombstone = (measurement: BackupMeasurement) => {
if (!dropCompactionTombstones) return false;
if (measurement.deletedAt == null) return false;
const source = measurement.source ?? "MANUAL";
const covering = compactionTombstoneCoverId(
{
function serialiseMeasurement(
measurement: BackupMeasurementRow,
disasterRecovery: boolean,
) {
return disasterRecovery
? {
id: measurement.id,
type: measurement.type,
source,
externalId: measurement.externalId ?? null,
measuredAt: new Date(measurement.measuredAt),
deletedAt: new Date(measurement.deletedAt),
},
foldTz,
);
return (
covering !== null &&
liveFoldStatsIds.has(foldStatsKey(source, measurement.type, covering))
);
};
return (measurement: BackupMeasurement) =>
measurement.deletedAt != null &&
(new Date(measurement.deletedAt).getTime() < tombstoneHorizonMs ||
isCompactionTombstone(measurement));
}
/**
* Two later sections point INTO the measurements (a record's
* `sourceMeasurementId`, an ECG strip's `measurementId`) and have to know
* which of those ids this transaction actually wrote. They used to be handed
* every written id; they only ever ask about the ones they reference, so only
* those are collected.
*/
function referencedMeasurementIdsOf(
payload: RestoreContext["payload"],
): Set<string> {
const referenced = new Set<string>();
for (const record of payload.personalRecords) {
if (record.sourceMeasurementId) {
referenced.add(record.sourceMeasurementId);
}
}
for (const recording of payload.ecgRecordings) {
if (recording.measurementId) {
referenced.add(recording.measurementId);
}
}
return referenced;
}
/**
* v1 payloads did not require stable ids. Their historical natural-key
* reconciliation is preserved without routing canonical v2 rows through it.
*/
async function reconcileWithoutId(
tx: Prisma.TransactionClient,
ownerId: string,
measurement: BackupMeasurement,
): Promise<void> {
const restoredData = toRestoredMeasurementData(measurement);
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: measurement.type,
source: restoredData.source,
measuredAt: restoredData.measuredAt,
sleepStage: restoredData.sleepStage,
},
select: { id: true },
});
if (existing) {
await tx.measurement.update({
where: { id: existing.id, userId: ownerId },
data: restoredData,
});
} else {
await tx.measurement.create({
data: { userId: ownerId, ...restoredData },
});
}
}
/**
* The measurements, a batch at a time from a second read of the file, so the
* restore never holds more than one batch of them.
*/
export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
const { tx, ownerId, payload, streamed } = ctx;
const referencedMeasurementIds = referencedMeasurementIdsOf(payload);
const writtenReferencedIds = ctx.links.writtenReferencedMeasurementIds;
const measurementBatchSize = 1_000;
const isExpiredTombstone = await expiredTombstoneFilter(ctx);
const { isShadowedLegacyRmssd } = ctx.measurementPrepass;
ctx.reportMeasurements(0);
await streamed.forEachMeasurementBatch(
measurementBatchSize,
async (fileBatch) => {
const kept = fileBatch.filter(
(measurement) => !isExpiredTombstone(measurement),
);
ctx.outputs.expiredTombstonesSkipped += fileBatch.length - kept.length;
const batch = kept.filter(
(measurement) => !isShadowedLegacyRmssd(measurement),
);
const stableRows = batch.flatMap((measurement): MeasurementInsertRow[] =>
measurement.id
? [
{
id: measurement.id,
userId: ownerId,
...toRestoredMeasurementData(measurement),
},
]
: [],
);
// One statement per batch, not `createMany`: see
// `insertMeasurementRows` for what the latter cost here.
await insertMeasurementRows(tx, stableRows);
// Counted through the file, so the bar ends at the file's total.
ctx.reportMeasurements(fileBatch.length);
for (const row of stableRows) {
if (referencedMeasurementIds.has(row.id)) {
writtenReferencedIds.add(row.id);
}
value: measurement.value,
valueMin: measurement.valueMin,
valueMax: measurement.valueMax,
unit: measurement.unit,
measuredAt: measurement.measuredAt.toISOString(),
source: measurement.source,
notes: measurement.notes,
notesEncrypted: measurement.notesEncrypted
? Buffer.from(measurement.notesEncrypted).toString("base64")
: null,
externalId: measurement.externalId,
externalSourceVersion: measurement.externalSourceVersion,
aggregationProvenance: measurement.aggregationProvenance,
glucoseContext: measurement.glucoseContext,
sleepStage: measurement.sleepStage,
rhythmClassification: measurement.rhythmClassification,
deviceType: measurement.deviceType,
syncVersion: measurement.syncVersion,
deletedAt: measurement.deletedAt?.toISOString() ?? null,
createdAt: measurement.createdAt.toISOString(),
updatedAt: measurement.updatedAt.toISOString(),
}
for (const measurement of batch) {
if (measurement.id) continue;
await reconcileWithoutId(tx, ownerId, measurement);
}
},
: {
id: measurement.id,
type: measurement.type,
value: measurement.value,
unit: measurement.unit,
measuredAt: measurement.measuredAt.toISOString(),
source: measurement.source,
notes: readNote(measurement.notesEncrypted, measurement.notes),
deletedAt: measurement.deletedAt?.toISOString() ?? null,
};
}
/**
* Every backup measurement, one serialised row at a time.
*
* Keyset-paged through `iterateMeasurementPages`, so the caller decides
* whether to keep the rows (the materialising payload does) or hand each
* straight to a writer and forget it (the streaming writer does). Nothing here
* holds more than one page.
*/
export async function* iterateBackupMeasurements(
prisma: PrismaClient,
userId: string,
disasterRecovery: boolean,
): AsyncGenerator<Record<string, unknown>, void, void> {
const pages = iterateMeasurementPages(
prisma,
disasterRecovery ? { userId } : { userId, deletedAt: null },
MEASUREMENT_BACKUP_SELECT,
);
for await (const page of pages) {
for (const measurement of page) {
yield serialiseMeasurement(measurement, disasterRecovery);
}
}
}
/** The measurements slice of the payload: the rows, or a marker for them. */
export async function buildMeasurementsBackupPart(
ctx: BackupExportContext,
): Promise<BackupExportPart> {
const { prisma, userId, disasterRecovery } = ctx;
if (ctx.deferBulk) {
return {
section: {
measurements: deferredRows(() =>
iterateBackupMeasurements(prisma, userId, disasterRecovery),
),
},
counts: { measurements: 0 },
};
}
const measurements = await collect(
iterateBackupMeasurements(prisma, userId, disasterRecovery),
);
return {
section: { measurements },
counts: { measurements: measurements.length },
};
}
+419
View File
@@ -0,0 +1,419 @@
/**
* The restore half of the measurements domain. The export half, with the
* select every column is carried by, lives in `measurements-backup.ts`.
*
* The restore reads the readings twice. The first read (`readStreamedBackup`,
* before anything is deleted) checks each one and notes what the second read
* has to know about the file as a whole: which `stats:` aggregates are live,
* and which nights the retired heart-rate variability filing shares with an
* RMSSD reading (`createMeasurementRestorePrepass`). The second read, inside
* the transaction, writes them a batch at a time (`restoreMeasurements`), so
* the restore never holds more than one batch.
*/
import type { Prisma } from "@/generated/prisma/client";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import { encryptNote } from "@/lib/crypto/note-cipher";
import {
isLegacyRmssdRow,
LEGACY_RMSSD_SOURCES,
} from "@/lib/export/legacy-rmssd";
import {
insertMeasurementRows,
type MeasurementInsertRow,
} from "@/lib/export/measurement-bulk-insert";
import {
decodeEncryptedBytes,
type MeasurementRestorePrepass,
type RestoreContext,
} from "@/lib/export/restore-context";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
import {
FOLDED_TYPES,
compactionTombstoneCoverId,
} from "@/lib/measurements/folded-window";
import type { BackupMeasurement } from "@/lib/validations/backup";
/** Both unique identities of a row, as it would be filed under `HRV_RMSSD`. */
function rmssdTwinKeysOf(row: BackupMeasurement): string[] {
const keys = [
`at|${row.source}|${new Date(row.measuredAt).toISOString()}|${row.sleepStage ?? ""}`,
];
if (row.externalId) keys.push(`ext|${row.source}|${row.externalId}`);
return keys;
}
const foldStatsKey = (source: string, type: string, externalId: string) =>
`${source}|${type}|${externalId}`;
/**
* The notes the measurement pass takes from the first read of the file.
*
* The live `stats:` ids of the folded types, so the measurement pass can tell
* a compaction tombstone from a person's deletion without a third read of the
* file. Keyed by source as well: Health Connect folds into the same `stats:`
* ids as Apple Health, and an Apple Health hour must not make a Health
* Connect deletion look folded.
*
* Migration 0420 refiled Fitbit and Google Health HRV as `HRV_RMSSD`: both
* providers report a nightly RMSSD, which their mappers had written as the
* SDNN series. A file written before that release still carries those rows
* as `HEART_RATE_VARIABILITY`, and restoring it verbatim would undo the
* migration for the account. The restore applies the migration's rule:
*
* - a legacy row moves to RMSSD, unless a LIVE RMSSD row of its source
* holds the same night under either unique identity (external id, or
* measured-at and sleep stage). Then the twin holds the night and the
* legacy row is not written under either type: as RMSSD it would break
* the unique key, as SDNN it would put RMSSD back into the SDNN series;
* - a legacy row whose only twin is a tombstone wins: the tombstone is the
* row left out, since it shares the key and holds nothing;
* - a legacy tombstone with any RMSSD twin is left out.
*
* The keys are gathered on the first read.
*/
export function createMeasurementRestorePrepass(): MeasurementRestorePrepass {
const liveFoldStatsIds = new Set<string>();
const foldedTypes = new Set<string>(FOLDED_TYPES);
const foldSources = new Set<string>(["APPLE_HEALTH", "HEALTH_CONNECT"]);
const noteLiveFoldRow = (row: BackupMeasurement) => {
if (
row.deletedAt == null &&
row.source !== undefined &&
foldSources.has(row.source) &&
foldedTypes.has(row.type) &&
row.externalId?.startsWith("stats:")
) {
liveFoldStatsIds.add(foldStatsKey(row.source, row.type, row.externalId));
}
};
const rmssdLiveKeys = new Set<string>();
const rmssdAnyKeys = new Set<string>();
const legacyLiveKeys = new Set<string>();
const noteRmssdTwin = (row: BackupMeasurement) => {
if (isLegacyRmssdRow(row)) {
if (row.deletedAt == null) {
for (const key of rmssdTwinKeysOf(row)) legacyLiveKeys.add(key);
}
return;
}
if (
row.type === "HRV_RMSSD" &&
row.source !== undefined &&
LEGACY_RMSSD_SOURCES.has(row.source)
) {
for (const key of rmssdTwinKeysOf(row)) {
rmssdAnyKeys.add(key);
if (row.deletedAt == null) rmssdLiveKeys.add(key);
}
}
};
const anyKeyIn = (row: BackupMeasurement, keys: ReadonlySet<string>) =>
rmssdTwinKeysOf(row).some((key) => keys.has(key));
const isShadowedLegacyRmssd = (row: BackupMeasurement): boolean => {
if (isLegacyRmssdRow(row)) {
return anyKeyIn(
row,
row.deletedAt == null ? rmssdLiveKeys : rmssdAnyKeys,
);
}
// An RMSSD tombstone on the night a live legacy row is about to take.
return (
row.type === "HRV_RMSSD" &&
row.deletedAt != null &&
row.source !== undefined &&
LEGACY_RMSSD_SOURCES.has(row.source) &&
anyKeyIn(row, legacyLiveKeys) &&
!anyKeyIn(row, rmssdLiveKeys)
);
};
return {
note(row) {
noteLiveFoldRow(row);
noteRmssdTwin(row);
},
liveFoldStatsIds,
isShadowedLegacyRmssd,
};
}
export async function clearMeasurements(ctx: RestoreContext): Promise<void> {
const measurements = await ctx.tx.measurement.deleteMany({
where: { userId: ctx.ownerId },
});
ctx.cleared.measurements = measurements.count;
}
/**
* The persisted measurement rollup partition, wiped for the same reason as
* the mood one: kept, it would hold the previous owner's daily means after
* the underlying measurements were replaced, and the chart's `source=rollup`
* fast-path could surface a stale 30-day mean built from rows that no longer
* exist. The rebuild after the commit mints fresh rows from the restored set.
*/
export async function clearMeasurementRollups(
ctx: RestoreContext,
): Promise<void> {
await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } });
}
function toRestoredMeasurementData(measurement: BackupMeasurement) {
return {
type: isLegacyRmssdRow(measurement)
? ("HRV_RMSSD" as const)
: 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: null,
notesEncrypted:
measurement.notesEncrypted == null
? encryptNote(measurement.notes ?? null)
: decodeEncryptedBytes(measurement.notesEncrypted),
externalId: measurement.externalId ?? null,
externalSourceVersion: measurement.externalSourceVersion ?? null,
// Authority of an Apple Health `stats:` aggregate. A backup
// written before this field rode the payload carries nothing, and
// restoring NULL would flatten the authority ladder: an export.xml
// source-day estimate could then overwrite a native HealthKit
// statistic, which is the ambiguity migration 0263 already
// repaired once. Such a row is exactly the case LEGACY_UNKNOWN
// exists for, so it comes back repairable instead of ambiguous.
//
// The gate is scoped exactly as 0263 scoped its backfill and as
// the cumulative drain scopes its own LEGACY_UNKNOWN writes:
// Apple Health rows only. Other providers mint `stats:` ids for
// their cumulative dailies too (Fitbit, Google Health) and have
// never carried provenance — marking those would invent a state
// they were never in. Ordinary point rows keep NULL honestly.
//
// Only an ABSENT field is the old-backup case. A current backup
// writes the field for every row, and an explicit null is the row's
// real state: the nightly daily-mean fold writes Apple Health
// `stats:` rows with no provenance. Treating that null as absent
// turned every such row into LEGACY_UNKNOWN on restore (#1031).
aggregationProvenance: (measurement.aggregationProvenance !== undefined
? measurement.aggregationProvenance
: measurement.source === "APPLE_HEALTH" &&
measurement.externalId?.startsWith("stats:")
? "LEGACY_UNKNOWN"
: null) as never,
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) }
: {}),
};
}
/**
* Which deleted readings the file carries are not written back.
*
* Deleted readings the file carries are restored as deleted rows
* (tombstones), with one exception: a tombstone deleted longer ago than
* `TOMBSTONE_RETENTION_DAYS` is not written at all.
*
* Such a row is exactly what the nightly tombstone purge
* (`measurement-tombstone-cleanup.ts`) hard-deletes, so writing it costs the
* restore a row in thirteen indexes for a row that is gone by the next
* morning. Nothing reads it in between: the delta feed answers
* `cursorExpired` for every cursor older than the same horizon, so no client
* that would still be served this tombstone exists; the step-consolidation
* repair and the hourly rebuild only look inside the horizon; and the undo of
* a delete is offered for what the purge has not removed yet, which this row
* would not survive either.
*
* Younger tombstones are written. They still have readers: a paired client
* that has not yet drained that deletion from the delta feed, the undo of a
* recent delete, the consolidation repairs that rebuild from folded samples,
* and the unique `(user, type, source, externalId)` key that stops a device
* from uploading a folded sample again. The backup file itself is unchanged
* and keeps every row.
*
* Nor is a compaction tombstone written, whatever its age: a raw sample the
* folds soft-deleted before v1.42, whose whole hour or day the fold had
* reached when it was deleted and which a live `stats:` row of the same
* source in the same file covers. The folds delete those outright now and the
* backlog purge removes the old ones, so writing one back would only hand the
* purge the same row again; the `folded_window` ingest guard is what keeps
* the sample from being uploaded again. Classified by the rule the purge and
* the sync feed use (`folded-window.ts`), against the `stats:` ids gathered
* on the file's first read and the zone the account will have after the
* restore. Counted with the expired ones.
*/
async function expiredTombstoneFilter(
ctx: RestoreContext,
): Promise<(measurement: BackupMeasurement) => boolean> {
const { tx, ownerId, payload } = ctx;
const tombstoneHorizonMs = Date.now() - TOMBSTONE_RETENTION_DAYS * 86_400_000;
const foldTz = resolveUserTimezone(
payload.accountSettings?.timezone ??
(
await tx.user.findUnique({
where: { id: ownerId },
select: { timezone: true },
})
)?.timezone ??
null,
);
// Only once the fold repair has been through the means this file holds.
// The repair recomputes a mean an older release folded from part of its
// day, and these tombstones are what it reads. A file exported after the
// account's repair finished carries repaired means, so its compaction
// tombstones can go. Any other file is written back whole, the account's
// repair marker is removed, and the repair runs again once the transaction
// commits; the purge then removes the tombstones as usual.
const foldRepair = await tx.measurementFoldRepair.findUnique({
where: { userId: ownerId },
select: { completedAt: true },
});
const dropCompactionTombstones =
foldRepair !== null &&
new Date(payload.exportedAt).getTime() > foldRepair.completedAt.getTime();
if (!dropCompactionTombstones) {
await tx.measurementFoldRepair.deleteMany({
where: { userId: ownerId },
});
ctx.outputs.foldRepairReset = true;
}
const { liveFoldStatsIds } = ctx.measurementPrepass;
const isCompactionTombstone = (measurement: BackupMeasurement) => {
if (!dropCompactionTombstones) return false;
if (measurement.deletedAt == null) return false;
const source = measurement.source ?? "MANUAL";
const covering = compactionTombstoneCoverId(
{
type: measurement.type,
source,
externalId: measurement.externalId ?? null,
measuredAt: new Date(measurement.measuredAt),
deletedAt: new Date(measurement.deletedAt),
},
foldTz,
);
return (
covering !== null &&
liveFoldStatsIds.has(foldStatsKey(source, measurement.type, covering))
);
};
return (measurement: BackupMeasurement) =>
measurement.deletedAt != null &&
(new Date(measurement.deletedAt).getTime() < tombstoneHorizonMs ||
isCompactionTombstone(measurement));
}
/**
* Two later sections point INTO the measurements (a record's
* `sourceMeasurementId`, an ECG strip's `measurementId`) and have to know
* which of those ids this transaction actually wrote. They used to be handed
* every written id; they only ever ask about the ones they reference, so only
* those are collected.
*/
function referencedMeasurementIdsOf(
payload: RestoreContext["payload"],
): Set<string> {
const referenced = new Set<string>();
for (const record of payload.personalRecords) {
if (record.sourceMeasurementId) {
referenced.add(record.sourceMeasurementId);
}
}
for (const recording of payload.ecgRecordings) {
if (recording.measurementId) {
referenced.add(recording.measurementId);
}
}
return referenced;
}
/**
* v1 payloads did not require stable ids. Their historical natural-key
* reconciliation is preserved without routing canonical v2 rows through it.
*/
async function reconcileWithoutId(
tx: Prisma.TransactionClient,
ownerId: string,
measurement: BackupMeasurement,
): Promise<void> {
const restoredData = toRestoredMeasurementData(measurement);
const existing = await tx.measurement.findFirst({
where: {
userId: ownerId,
type: measurement.type,
source: restoredData.source,
measuredAt: restoredData.measuredAt,
sleepStage: restoredData.sleepStage,
},
select: { id: true },
});
if (existing) {
await tx.measurement.update({
where: { id: existing.id, userId: ownerId },
data: restoredData,
});
} else {
await tx.measurement.create({
data: { userId: ownerId, ...restoredData },
});
}
}
/**
* The measurements, a batch at a time from a second read of the file, so the
* restore never holds more than one batch of them.
*/
export async function restoreMeasurements(ctx: RestoreContext): Promise<void> {
const { tx, ownerId, payload, streamed } = ctx;
const referencedMeasurementIds = referencedMeasurementIdsOf(payload);
const writtenReferencedIds = ctx.links.writtenReferencedMeasurementIds;
const measurementBatchSize = 1_000;
const isExpiredTombstone = await expiredTombstoneFilter(ctx);
const { isShadowedLegacyRmssd } = ctx.measurementPrepass;
ctx.reportMeasurements(0);
await streamed.forEachMeasurementBatch(
measurementBatchSize,
async (fileBatch) => {
const kept = fileBatch.filter(
(measurement) => !isExpiredTombstone(measurement),
);
ctx.outputs.expiredTombstonesSkipped += fileBatch.length - kept.length;
const batch = kept.filter(
(measurement) => !isShadowedLegacyRmssd(measurement),
);
const stableRows = batch.flatMap((measurement): MeasurementInsertRow[] =>
measurement.id
? [
{
id: measurement.id,
userId: ownerId,
...toRestoredMeasurementData(measurement),
},
]
: [],
);
// One statement per batch, not `createMany`: see
// `insertMeasurementRows` for what the latter cost here.
await insertMeasurementRows(tx, stableRows);
// Counted through the file, so the bar ends at the file's total.
ctx.reportMeasurements(fileBatch.length);
for (const row of stableRows) {
if (referencedMeasurementIds.has(row.id)) {
writtenReferencedIds.add(row.id);
}
}
for (const measurement of batch) {
if (measurement.id) continue;
await reconcileWithoutId(tx, ownerId, measurement);
}
},
);
}
+521
View File
@@ -0,0 +1,521 @@
/**
* The medications domain's export half: every drug with everything that hangs
* off it, the account's own categories, and the dose history. The restore
* half lives in `medications-restore.ts`.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import {
BULK_PAGE_SIZE,
collect,
deferredRows,
} from "@/lib/export/deferred-rows";
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import {
getMedicationCategories,
readMedicationCategoryLabelsForBackup,
} from "@/lib/medication-category";
import { dayKeyOfDate } from "@/lib/medications/course-window";
// Side effects ride INSIDE their medication, exactly as the schedules
// beside them do. The alternative — a top-level array carrying
// `medicationId` — would have to survive a restore that mints new
// medication ids for a portable file, which is the id-remap the intake
// events already have to work around by drug name. A nested create has
// no id to remap: Prisma binds the child to whatever id the parent row
// actually got.
const MEDICATION_BACKUP_INCLUDE = {
schedules: true,
sideEffects: { orderBy: { occurredAt: "desc" } },
pauseEras: { orderBy: { pausedAt: "asc" } },
courses: { orderBy: { startsOn: "asc" } },
doseChanges: { orderBy: { effectiveFrom: "asc" } },
inventoryItems: { orderBy: { createdAt: "asc" } },
inventoryEvents: { orderBy: { occurredAt: "asc" } },
phaseConfig: true,
// The composition per unit; ordered so two exports of the
// same account write it identically.
ingredients: { orderBy: { nutrient: "asc" } },
// The archived schedule eras. Ordered TOTALLY, not just by the column
// that reads like the sort key: the position in this list is the
// identifier the supersede pointer travels as, so two eras sharing a
// `validFrom` must not be free to swap places between two exports of
// the same account.
scheduleRevisions: {
orderBy: [
{ validFrom: "asc" },
{ createdAt: "asc" },
{ id: "asc" },
] as const,
},
// What the drug was supposed to move. The biomarker is read by NAME
// rather than by id, exactly as the lab results beside it are: a
// portable restore mints a fresh biomarker id, and `(userId, name)` is
// the natural key the catalogue is already unique on.
efficacyTargets: {
orderBy: { createdAt: "asc" },
include: { biomarker: { select: { name: true } } },
},
} satisfies Prisma.MedicationInclude;
type BackupMedicationRow = Prisma.MedicationGetPayload<{
include: typeof MEDICATION_BACKUP_INCLUDE;
}>;
/** The drug's own columns, before the rows that hang off it. */
function serialiseMedicationRow(
m: BackupMedicationRow,
disasterRecovery: boolean,
category: string,
) {
return {
...(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,
updatedAt: m.updatedAt.toISOString(),
}
: // No id: a portable restore mints a fresh one. The relation links
// still name this drug, so the file says which row they mean.
{ ref: m.id }),
name: m.name,
dose: m.dose,
active: m.active,
// The person's decision to keep this medication as a
// record only. Both purposes carry it: a restore that dropped it would
// start reminding them about a medication they chose not to track.
trackIntake: m.trackIntake,
// The person's choice to have doses recorded for them, and
// the floor before which none is. Both purposes: a restore without
// them would start reminding again, or fill history it never owned.
autoMarkTaken: m.autoMarkTaken,
autoMarkTakenSince: m.autoMarkTakenSince?.toISOString() ?? null,
// Both purposes: the category is something the person chose.
category,
// Both purposes carry the creation instant: it is the floor of the
// medication's expected slots, so a restore stamped with the restore
// time would read the whole restored history as predating the
// medication and drop its misses from every rate (#1028).
createdAt: m.createdAt.toISOString(),
};
}
/** What was planned, recorded and changed: schedules to dose changes. */
function serialiseMedicationHistory(
m: BackupMedicationRow,
disasterRecovery: boolean,
) {
return {
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,
// #219 — per-schedule units per dose. Decimal → string like the
// medication-level column; NULL (inherit) stays NULL.
unitsPerDose:
s.unitsPerDose == null ? null : s.unitsPerDose.toString(),
}
: {}),
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
dose: s.dose,
})),
// What the person recorded against the drug, and the reason a drug may
// have been stopped. `notes` is the same dual-column arrangement
// `Measurement` has and gets the same treatment: a portable export
// carries the DECRYPTED note and no ciphertext, a disaster-recovery
// payload carries the ciphertext verbatim plus whatever legacy plaintext
// the row still holds, and the restore re-encrypts the plaintext case.
sideEffects: m.sideEffects.map((s) => ({
...(disasterRecovery
? {
id: s.id,
notes: s.notes,
notesEncrypted: s.notesEncrypted
? Buffer.from(s.notesEncrypted).toString("base64")
: null,
createdAt: s.createdAt.toISOString(),
}
: { notes: readNote(s.notesEncrypted, s.notes) }),
occurredAt: s.occurredAt.toISOString(),
category: s.category,
entry: s.entry,
severity: s.severity,
})),
// Nested for the same reason the two above are: a pause era is bound to
// its medication by id, and a nested create binds it to whatever id the
// parent actually got.
//
// These spans are what separates "stopped taking it" from "was told to
// stop taking it". Without them the compliance recomputation counts a
// deliberate pause as missed doses, so a restored account reads as
// non-adherent for a stretch during which it did exactly what it was
// told. Nothing else in the payload can reconstruct them: an era is a
// decision, not an observation, and no intake row records its absence.
//
// `resumedAt` stays nullable end to end — an open era means the
// medication is still paused, and readers run it to `now`.
pauseEras: m.pauseEras.map((p) => ({
...(disasterRecovery ? { id: p.id } : {}),
pausedAt: p.pausedAt.toISOString(),
resumedAt: p.resumedAt ? p.resumedAt.toISOString() : null,
createdAt: p.createdAt.toISOString(),
})),
// The courses: "when did I last take this" lives here
// and nowhere else once a second course exists. The medication's own
// window above is the projection of the latest one.
// What one unit contains. Both purposes: it is something the
// person typed off a label, and without it the supplement's doses stop
// counting toward the day's micronutrients. Canonical amount plus the
// amount as entered, so the form shows what was typed.
ingredients: m.ingredients.map((i) => ({
...(disasterRecovery ? { id: i.id } : {}),
nutrient: i.nutrient,
amountPerUnit: i.amountPerUnit,
unit: i.unit,
enteredAmount: i.enteredAmount,
enteredUnit: i.enteredUnit,
source: i.source,
createdAt: i.createdAt.toISOString(),
})),
courses: m.courses.map((c) => ({
startsOn: dayKeyOfDate(c.startsOn),
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null,
createdAt: c.createdAt.toISOString(),
})),
// Titration: when the dose moved, to what, and why. The drug comes
// back at today's dose either way; without these the ramp that led to
// it is gone, and that ramp is what a prescriber asks about.
//
// The note follows the side-effect contract beside it: a portable
// export carries the DECRYPTED note and no ciphertext, a
// disaster-recovery payload carries the ciphertext verbatim plus
// whatever legacy plaintext the row still holds.
doseChanges: m.doseChanges.map((d) => ({
...(disasterRecovery
? {
id: d.id,
note: d.note,
noteEncrypted: d.noteEncrypted
? Buffer.from(d.noteEncrypted).toString("base64")
: null,
}
: { note: readNote(d.noteEncrypted, d.note) }),
effectiveFrom: d.effectiveFrom.toISOString(),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
createdAt: d.createdAt.toISOString(),
})),
};
}
/**
* The stock, the phase timings, the archived schedule eras and the targets.
* `revisionIndex` is where each era sits in this drug's own ordered list.
*/
function serialiseMedicationStock(
m: BackupMedicationRow,
disasterRecovery: boolean,
revisionIndex: ReadonlyMap<string, number> | undefined,
) {
return {
// The packs on the shelf, and the ledger behind the number on them.
//
// `unitsRemaining` is carried VERBATIM rather than recomputed from the
// events, for the same reason `nextDueAt` is: it is what the server had
// resolved and what the person was shown. Recomputing on the way back
// would silently correct a count the account may have adjusted by hand,
// and would disagree with every low-stock reminder already sent.
//
// Both Decimal columns cross the wire as strings. A pack of 0.5 mg
// halves is a real prescription, and JSON numbers would round it.
inventoryItems: m.inventoryItems.map((i) => ({
...(disasterRecovery
? {
id: i.id,
notes: i.notes,
notesEncrypted: i.notesEncrypted
? Buffer.from(i.notesEncrypted).toString("base64")
: null,
}
: { notes: readNote(i.notesEncrypted, i.notes) }),
state: i.state,
containerType: i.containerType,
unitsTotal: i.unitsTotal.toString(),
unitsRemaining: i.unitsRemaining.toString(),
firstUseAt: i.firstUseAt?.toISOString() ?? null,
expiresAt: i.expiresAt?.toISOString() ?? null,
printedExpiry: i.printedExpiry?.toISOString() ?? null,
purchasedAt: i.purchasedAt?.toISOString() ?? null,
manufacturer: i.manufacturer,
doseStrength: i.doseStrength,
createdAt: i.createdAt.toISOString(),
updatedAt: i.updatedAt.toISOString(),
})),
// The four reminder phase thresholds this drug was tuned to. One row or
// none, so it rides as an object rather than a list — and null means the
// person never tuned it, which is a different statement from "the file
// forgot to say".
phaseConfig: m.phaseConfig
? {
...(disasterRecovery ? { id: m.phaseConfig.id } : {}),
greenValue: m.phaseConfig.greenValue,
greenMode: m.phaseConfig.greenMode,
yellowValue: m.phaseConfig.yellowValue,
yellowMode: m.phaseConfig.yellowMode,
orangeValue: m.phaseConfig.orangeValue,
orangeMode: m.phaseConfig.orangeMode,
redValue: m.phaseConfig.redValue,
redMode: m.phaseConfig.redMode,
}
: null,
inventoryEvents: m.inventoryEvents.map((e) => ({
...(disasterRecovery ? { id: e.id } : {}),
delta: e.delta,
reason: e.reason,
occurredAt: e.occurredAt.toISOString(),
})),
// How the schedule got to where it is. The drug comes back on today's
// plan either way; without these the record of when the dose or the
// timing moved is gone, and the era minter reads every past day against
// the CURRENT schedule instead of the one that was live then — so a
// restored account's own compliance history quietly changes.
//
// `supersededByRevisionId` travels as a POSITION in this list rather
// than as an id. See the restore for the whole argument; the short form
// is that a portable restore mints fresh ids, so an id would point at
// nothing, and the list this array IS is the only stable thing both ends
// agree on. A pointer at a row outside this drug's own list cannot be
// expressed and is written as null — the schema allows it, no code path
// produces it, and inventing an index for it would be worse than saying
// nothing.
scheduleRevisions: m.scheduleRevisions.map((r) => ({
...(disasterRecovery ? { id: r.id } : {}),
validFrom: r.validFrom.toISOString(),
validUntil: r.validUntil.toISOString(),
payload: r.payload,
source: r.source,
supersededByIndex: r.supersededByRevisionId
? (revisionIndex?.get(r.supersededByRevisionId) ?? null)
: null,
createdAt: r.createdAt.toISOString(),
})),
// What the drug is FOR, in the person's own words rather than the
// resolver's. A row exists only where they overrode the derived
// ATC / name inference, so it is the one durable statement of intent
// the "Wirkung" view holds — and the drug restores with no statement of
// what it was for without it.
//
// The lab arm travels as a NAME, the way a lab result's does. The id is
// fresh on a portable restore, and `Biomarker` is unique on
// `(userId, name)`, so the name is the only thing that survives the
// trip. It is resolved on the way back against the biomarkers the
// restore wrote.
efficacyTargets: m.efficacyTargets.map((t) => ({
...(disasterRecovery
? {
id: t.id,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
}
: {}),
measurementType: t.measurementType,
biomarkerName: t.biomarker?.name ?? null,
primary: t.primary,
})),
};
}
type BackupIntakeEvent = Prisma.MedicationIntakeEventGetPayload<{
include: { medication: { select: { name: true } } };
}>;
/** One intake event, as it rides the wire. */
function serialiseIntakeEvent(e: BackupIntakeEvent, disasterRecovery: boolean) {
return {
...(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,
skipped: e.skipped,
source: e.source,
// Both purposes: the history labels the dose "recorded
// automatically", and a restore that dropped the marker would relabel
// it as one the person confirmed.
autoRecorded: e.autoRecorded,
};
}
/**
* Every backup intake event, one serialised row at a time.
*
* The sort is TOTAL — `scheduledFor desc` then `id desc` — where the
* materialising read used to order by the timestamp alone. A keyset cursor
* needs a total order to page correctly, and a partial one also let two
* exports of the same account emit two different files whenever two doses
* shared a scheduled minute, which every multi-dose day has.
*/
export async function* iterateBackupIntakeEvents(
prisma: PrismaClient,
userId: string,
disasterRecovery: boolean,
): AsyncGenerator<Record<string, unknown>, void, void> {
let cursorId: string | null = null;
for (;;) {
const page: BackupIntakeEvent[] =
await prisma.medicationIntakeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
take: BULK_PAGE_SIZE,
...(cursorId !== null ? { cursor: { id: cursorId }, skip: 1 } : {}),
});
if (page.length === 0) return;
for (const e of page) yield serialiseIntakeEvent(e, disasterRecovery);
if (page.length < BULK_PAGE_SIZE) return;
cursorId = page[page.length - 1]!.id;
}
}
/** Totals of the rows nested in the drugs, for the audit trail. */
function nestedCounts(medications: BackupMedicationRow[]) {
const total = (rows: (m: BackupMedicationRow) => number) =>
medications.reduce((sum, m) => sum + rows(m), 0);
return {
medicationSideEffects: total((m) => m.sideEffects.length),
medicationPauseEras: total((m) => m.pauseEras.length),
medicationDoseChanges: total((m) => m.doseChanges.length),
medicationInventoryItems: total((m) => m.inventoryItems.length),
medicationInventoryEvents: total((m) => m.inventoryEvents.length),
reminderPhaseConfigs: medications.filter((m) => m.phaseConfig).length,
medicationEfficacyTargets: total((m) => m.efficacyTargets.length),
medicationScheduleRevisions: total((m) => m.scheduleRevisions.length),
};
}
/**
* The medications slice of the payload: the account's own categories, the
* drugs, and the dose history (rows, or a marker for them).
*/
export async function buildMedicationsBackupPart(
ctx: BackupExportContext,
): Promise<BackupExportPart> {
const { prisma, userId, disasterRecovery } = ctx;
const [medications, intakeEvents] = await Promise.all([
prisma.medication.findMany({
where: { userId },
include: MEDICATION_BACKUP_INCLUDE,
}),
ctx.deferBulk
? null
: collect(iterateBackupIntakeEvents(prisma, userId, disasterRecovery)),
]);
// The clinical category lives in its own table rather than on the
// medication row and is read through the one helper that normalises it.
// Before v1.39.4 no backup carried it and every restored medication came
// back as "Other".
const categoryByMedication = await getMedicationCategories(
medications.map((m) => m.id),
prisma,
);
// The account's own categories, restored before the medications that name
// them by key. Labels travel decrypted so a file restores on an instance
// with a different key; every one is carried, hidden ones included,
// because a medication can still be filed under it.
const customMedicationCategories =
await readMedicationCategoryLabelsForBackup(userId, prisma);
// Where each archived era sits in its OWN drug's ordered list. Built once
// here rather than per row, and scoped per medication because the
// supersede pointer never crosses a drug — every route that writes it
// scopes the update to the medication it belongs to.
const revisionIndexByMedication = new Map(
medications.map((m) => [
m.id,
new Map(
m.scheduleRevisions.map((revision, index) => [revision.id, index]),
),
]),
);
return {
section: {
customMedicationCategories,
medications: medications.map((m) => ({
...serialiseMedicationRow(
m,
disasterRecovery,
categoryByMedication[m.id] ?? "OTHER",
),
...serialiseMedicationHistory(m, disasterRecovery),
...serialiseMedicationStock(
m,
disasterRecovery,
revisionIndexByMedication.get(m.id),
),
})),
intakeEvents: ctx.deferBulk
? deferredRows(() =>
iterateBackupIntakeEvents(prisma, userId, disasterRecovery),
)
: intakeEvents!,
},
counts: {
medications: medications.length,
intakeEvents: intakeEvents?.length ?? 0,
...nestedCounts(medications),
},
};
}
+391
View File
@@ -0,0 +1,391 @@
/**
* The mood domain's export half: the entries with their day context and
* their links, and the account's own categories, tags and hidden set. The
* restore half lives in `mood-restore.ts`.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { readNote } from "@/lib/crypto/note-cipher";
import {
BULK_PAGE_SIZE,
collect,
deferredRows,
} from "@/lib/export/deferred-rows";
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import { openSealedForExport } from "@/lib/export/sealed-text";
/**
* Every restorable `MoodEntry` scalar. `userId` is absent for the same reason
* it is absent above: the backup is user-scoped and the owner is re-derived
* from the restoring account.
*
* Named rather than inlined so `mood-backup-completeness.test.ts` can compare
* it field-by-field against the model in `prisma/schema.prisma`, exactly as the
* measurement guard does for the select above.
*/
const MOOD_ENTRY_BACKUP_SELECT = {
id: true,
date: true,
mood: true,
score: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
tags: true,
note: true,
noteEncrypted: true,
source: true,
externalId: true,
moodLoggedAt: true,
tz: true,
syncedAt: true,
syncVersion: true,
deletedAt: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.MoodEntrySelect;
/**
* A structured-tag link, as it rides the wire. The link is carried by the
* tag's KEY, not by its id: a restore resolves the key against the target
* instance's catalogue, where the source instance's row id may not exist at
* all. `kind` comes along so the mapper can split RATED factors from BINARY
* tags without a second query.
*/
const MOOD_ENTRY_TAG_LINK_SELECT = {
rating: true,
moodTag: { select: { key: true, kind: true } },
} as const satisfies Prisma.MoodEntryTagLinkSelect;
/**
* Every restorable `MoodContext` scalar.
*
* `moodEntryId` and `userId` are absent for the reasons the exclusion list in
* `mood-backup-completeness.test.ts` states: the context rides inside its
* entry and is re-bound to whatever id that entry got, and the owner is
* re-derived from the restoring account. The two row stamps are absent for the
* third reason there — they describe when the sub-row was written, not when
* the day happened, and the entry already carries the moment that matters.
*
* Named rather than inlined so the guard can compare it field-by-field against
* the model, exactly as it does for the two selects above. This is the whole
* point of the pair: a column added to `MoodContext` without a line here
* fails, and it fails in the release that added it.
*/
const MOOD_CONTEXT_BACKUP_SELECT = {
workStatus: true,
workMinutes: true,
overtimeMinutes: true,
workLoad: true,
workSatisfaction: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
contactQuality: true,
contactSupport: true,
leisureCategories: true,
leisureMinutes: true,
leisureJoy: true,
leisureRecovery: true,
eventType: true,
eventValence: true,
eventAt: true,
notesEncrypted: true,
} as const satisfies Prisma.MoodContextSelect;
type BackupMoodContext = Prisma.MoodContextGetPayload<{
select: typeof MOOD_CONTEXT_BACKUP_SELECT;
}>;
/**
* The mood read, select and joins together. Named because two readers issue
* it — the materialising one and the paged one — and a select that lived in
* only one of them is exactly the drift the completeness guard cannot see.
*/
const MOOD_ENTRY_QUERY_SELECT = {
...MOOD_ENTRY_BACKUP_SELECT,
// Every link, not only the rated ones. This read used to filter
// `rating: { not: null }`, and a BINARY link carries a NULL rating by
// definition — so the present/absent tags, the kind most people pick,
// were absent from the file and could not come back from it.
tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT },
// The day context, when the entry has one. Sparse by design — most
// entries carry none, and the join answers null on those.
context: { select: MOOD_CONTEXT_BACKUP_SELECT },
} as const satisfies Prisma.MoodEntrySelect;
type BackupMoodEntry = Prisma.MoodEntryGetPayload<{
select: typeof MOOD_ENTRY_QUERY_SELECT;
}>;
/**
* One context row, in whichever of the two wire shapes the caller asked for.
*
* The split is the same one `Measurement` makes: a disaster-recovery file
* carries the ciphertext verbatim, because the instance that reads it back
* holds the key; a portable export carries the plain text, because the point
* of a portable file is that somebody can read it. Every other column rides in
* both, since none of them is a storage detail — they are all answers somebody
* gave.
*
* The parameter is spelled `moodContext` rather than `c`, deliberately: the
* completeness guard proves a selected column reaches a payload by matching
* `moodContext.<column>`, and a one-letter parameter shared with another
* mapper in this file would make that matcher unable to fail.
*/
function serialiseMoodContext(
moodContext: BackupMoodContext,
disasterRecovery: boolean,
) {
return {
...(disasterRecovery
? {
notesEncrypted: moodContext.notesEncrypted
? Buffer.from(moodContext.notesEncrypted).toString("base64")
: null,
}
: { note: readNote(moodContext.notesEncrypted, null) }),
workStatus: moodContext.workStatus,
workMinutes: moodContext.workMinutes,
overtimeMinutes: moodContext.overtimeMinutes,
workLoad: moodContext.workLoad,
workSatisfaction: moodContext.workSatisfaction,
// The two multi-selects ride as the stored JSON string, unparsed. The
// restore writes the column back verbatim, so a file cannot arrive with a
// list the parser and the column disagree about.
contactCircles: moodContext.contactCircles,
contactForm: moodContext.contactForm,
contactExtent: moodContext.contactExtent,
contactQuality: moodContext.contactQuality,
contactSupport: moodContext.contactSupport,
leisureCategories: moodContext.leisureCategories,
leisureMinutes: moodContext.leisureMinutes,
leisureJoy: moodContext.leisureJoy,
leisureRecovery: moodContext.leisureRecovery,
eventType: moodContext.eventType,
eventValence: moodContext.eventValence,
eventAt: moodContext.eventAt?.toISOString() ?? null,
};
}
/**
* One mood entry, with its links partitioned and its day context folded in.
*
* The parameter is spelled out rather than the `e` every other mapper here
* uses, and deliberately: `mood-backup-completeness.test.ts` proves a
* selected column reaches a payload by matching `: moodEntry.`. A matcher
* on `: e.` would also match any other mapper's one-letter parameter and could
* therefore never fail.
*/
function serialiseMoodEntry(
moodEntry: BackupMoodEntry,
disasterRecovery: boolean,
) {
// The two link arms partition the entry's links exhaustively: a link is
// a rated factor when its tag is RATED and it actually carries a score,
// and a structured tag otherwise. Total on purpose — a malformed link
// (a RATED tag whose rating went missing) rides as a tag key and comes
// back NAMED as unresolvable on restore, rather than falling between the
// two arms and disappearing the way every BINARY link used to.
const isRatedFactor = (link: (typeof moodEntry.tagLinks)[number]) =>
link.moodTag.kind === "RATED" && link.rating !== null;
const factors = moodEntry.tagLinks.filter(isRatedFactor).map((tagLink) => ({
key: tagLink.moodTag.key,
rating: tagLink.rating as number,
}));
// BINARY link keys. Their own array rather than a nullable rating inside
// `factors`: the restore resolves `factors` against `kind: "RATED"` and
// reports what it cannot resolve, so a rating-less entry in that array
// would collide with both. A separate key leaves every backup file
// written before this parsing and restoring exactly as it did.
const structuredTags = moodEntry.tagLinks
.filter((link) => !isRatedFactor(link))
.map((tagLink) => tagLink.moodTag.key);
return {
...(disasterRecovery
? {
note: moodEntry.note,
noteEncrypted: moodEntry.noteEncrypted
? Buffer.from(moodEntry.noteEncrypted).toString("base64")
: null,
syncedAt: moodEntry.syncedAt.toISOString(),
syncVersion: moodEntry.syncVersion,
createdAt: moodEntry.createdAt.toISOString(),
updatedAt: moodEntry.updatedAt.toISOString(),
}
: { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }),
id: moodEntry.id,
date: moodEntry.date,
mood: moodEntry.mood,
score: moodEntry.score,
// The five level-A values, under the keys the write path takes. Both
// arms carry them: they are the entry's own answers, not a storage
// detail, and a portable export that dropped them would hand back a
// file describing the day less well than the row it came from.
a1: moodEntry.moodA1,
a2: moodEntry.stressA2,
a3: moodEntry.energyA3,
a4: moodEntry.connectionA4,
a5: moodEntry.stabilityA5,
tags: moodEntry.tags,
source: moodEntry.source,
loggedAt: moodEntry.moodLoggedAt.toISOString(),
externalId: moodEntry.externalId,
// The zone the `date` string is anchored to. Without it a restored row
// falls back to the legacy Europe/Berlin reading and an account that
// logged elsewhere gets its day boundaries quietly moved.
tz: moodEntry.tz,
deletedAt: moodEntry.deletedAt?.toISOString() ?? null,
factors,
structuredTags,
// The day context, or null. Null rather than an empty object, because
// "no context" and "a context whose every field is empty" are
// different facts and the restore has to keep them apart.
context: moodEntry.context
? serialiseMoodContext(moodEntry.context, disasterRecovery)
: null,
};
}
/**
* Every backup mood entry, one serialised row at a time.
*
* Same total-order argument as the intake events above: `moodLoggedAt desc`
* alone is not a cursor, and two entries logged in the same millisecond are
* exactly what a bulk import produces.
*/
export async function* iterateBackupMoodEntries(
prisma: PrismaClient,
userId: string,
disasterRecovery: boolean,
): AsyncGenerator<Record<string, unknown>, void, void> {
let cursorId: string | null = null;
for (;;) {
const page: BackupMoodEntry[] = await prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ moodLoggedAt: "desc" }, { id: "desc" }],
take: BULK_PAGE_SIZE,
...(cursorId !== null ? { cursor: { id: cursorId }, skip: 1 } : {}),
select: MOOD_ENTRY_QUERY_SELECT,
});
if (page.length === 0) return;
for (const moodEntry of page) {
yield serialiseMoodEntry(moodEntry, disasterRecovery);
}
if (page.length < BULK_PAGE_SIZE) return;
cursorId = page[page.length - 1]!.id;
}
}
/**
* The mood slice of the payload: the entries (rows, or a marker for them),
* then the account's own categories, hidden set and tags.
*/
export async function buildMoodBackupPart(
ctx: BackupExportContext,
): Promise<BackupExportPart> {
const { prisma, userId, disasterRecovery } = ctx;
const [moodEntries, customMoodTags, customMoodTagCategories, hiddenMoodTags] =
await Promise.all([
ctx.deferBulk
? null
: collect(iterateBackupMoodEntries(prisma, userId, disasterRecovery)),
// The account's OWN mood tags. The seeded catalogue is reference data every
// instance has; a tag the user made lives only here. Without it the restore
// cannot resolve the entry's factor keys — and unlike the cycle path, which
// used to drop them silently, the mood path throws, so an account with one
// custom rated tag could not be restored at all.
prisma.moodTag.findMany({
where: { userId },
orderBy: { key: "asc" },
}),
// The account's OWN categories. Seeded ones (`userId: null`) belong to the
// instance and are deliberately not carried — but a custom tag's
// `categoryId` is a real foreign key, so a category the account created
// itself has to travel or the tag above cannot be written at all. Before
// this section existed, that was not a gap in the restore: it was a
// constraint violation that took the WHOLE file down with it, so an
// account that had ever grouped its own mood factors could not be
// restored.
prisma.moodTagCategory.findMany({
where: { userId },
orderBy: { sortOrder: "asc" },
}),
// Which tags the account chose to hide. Carried by KEY rather than id:
// what people hide is almost always a SEEDED tag, whose id differs on
// every instance, so an id would resolve to nothing on the way back.
prisma.moodTagHidden.findMany({
where: { userId },
select: { moodTag: { select: { key: true } }, createdAt: true },
orderBy: { createdAt: "asc" },
}),
]);
return {
section: {
moodEntries: ctx.deferBulk
? deferredRows(() =>
iterateBackupMoodEntries(prisma, userId, disasterRecovery),
)
: moodEntries!,
customMoodTagCategories: customMoodTagCategories.map((category) => ({
id: category.id,
key: category.key,
labelKey: category.labelKey,
icon: category.icon,
sortOrder: category.sortOrder,
isActive: category.isActive,
// The person's own words: ciphertext in a disaster-recovery payload,
// readable in a portable one (see `sealed-text.ts`).
...(disasterRecovery
? { labelEncrypted: category.labelEncrypted }
: {
label: category.labelEncrypted
? openSealedForExport(
category.labelEncrypted,
"mood category label",
)
: null,
}),
})),
hiddenMoodTags: hiddenMoodTags.map((hidden) => ({
key: hidden.moodTag.key,
createdAt: hidden.createdAt.toISOString(),
})),
customMoodTags: customMoodTags.map((tag) => ({
...(disasterRecovery ? { id: tag.id } : {}),
key: tag.key,
labelKey: tag.labelKey,
categoryId: tag.categoryId,
kind: tag.kind,
isActive: tag.isActive,
icon: tag.icon,
sortOrder: tag.sortOrder,
// Ciphertext in a disaster-recovery payload (the same instance's key
// reads it back), readable in a portable one (see `sealed-text.ts`).
...(disasterRecovery
? { labelEncrypted: tag.labelEncrypted }
: {
label: tag.labelEncrypted
? openSealedForExport(tag.labelEncrypted, "mood tag label")
: null,
}),
scaleMin: tag.scaleMin,
scaleMax: tag.scaleMax,
inverse: tag.inverse,
})),
},
counts: {
moodEntries: moodEntries?.length ?? 0,
customMoodTagCategories: customMoodTagCategories.length,
hiddenMoodTags: hiddenMoodTags.length,
},
};
}
+49
View File
@@ -1,8 +1,57 @@
/**
* The nutrient day totals, with both backup ends in one file.
*/
import type {
BackupExportContext,
BackupExportPart,
} from "@/lib/export/export-context";
import type { RestoreContext } from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
/**
* Nutrient day totals were absent from every export path, which contradicted
* the schema's own reason for denormalising the unit column ("rows stay
* self-describing in exports even if the catalog ever drifts"). `source` is
* part of the composite PK, so it has to ride along or a restore cannot tell
* a manual water entry from a synced day total. The table carries no
* tombstone, so both purposes read the same shape. `externalSourceVersion`
* is the source's own version marker, stored so the sync can replay it back
* unchanged; `createdAt` is when the day first arrived. Neither can be
* rebuilt after a restore.
*/
export async function buildNutrientsBackupPart(
ctx: BackupExportContext,
): Promise<BackupExportPart> {
const nutrientDays = await ctx.prisma.nutrientIntakeDay.findMany({
where: { userId: ctx.userId },
select: {
day: true,
nutrient: true,
amount: true,
unit: true,
source: true,
externalSourceVersion: true,
createdAt: true,
},
orderBy: [{ day: "desc" }, { nutrient: "asc" }],
});
return {
section: {
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
amount: n.amount,
unit: n.unit,
source: n.source,
externalSourceVersion: n.externalSourceVersion,
createdAt: n.createdAt.toISOString(),
})),
},
counts: { nutrientDays: nutrientDays.length },
};
}
// ── Restore ──────────────────────────────────────────────────────────────────
/**
+52 -715
View File
@@ -1,5 +1,12 @@
/**
* Structured records, workouts, and document backup section.
* The structured-records section: labs, illness history, allergies, family
* history, workout summaries and the document vault, with the manifest that
* discloses what each of them leaves out.
*
* Each record kind is a domain of its own, with its export and its restore in
* one module (`labs-backup.ts`, `illness-backup.ts`, `allergies-backup.ts`,
* `family-history-backup.ts`, `workouts-backup.ts`, `documents-backup.ts`).
* This file lays their slices out in the order the file has always had.
*
* Portable exports keep the historical v1.28 metadata-only document shape.
* The weekly disaster-recovery writer opts into the canonical mode, which
@@ -10,30 +17,33 @@
* restore cannot resurrect tombstones. Other encrypted free-text fields are
* carried as plaintext and re-encrypted by the restore importer; one this
* instance cannot decrypt is carried as a visible marker rather than as
* nothing (see `readForExport` below).
* nothing.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { decryptNoteFromBytes } from "@/lib/labs/store";
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
import { decryptDocumentSummary } from "@/lib/documents/store";
import { getEvent } from "@/lib/logging/context";
import type { PrismaClient } from "@/generated/prisma/client";
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
import {
toIllnessEpisodeDTO,
toIllnessDayLogDTO,
dayLogSymptomInclude,
type IllnessEpisodeDTO,
type IllnessDayLogDTO,
} from "@/lib/illness/dto";
buildAllergiesBackupSlice,
type CanonicalAllergy,
} from "@/lib/export/allergies-backup";
import {
toAllergyDTO,
toFamilyHistoryEntryDTO,
type AllergyDTO,
type FamilyHistoryEntryDTO,
} from "@/lib/records/dto";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { streamedField, type StreamedField } from "./streamed-field";
buildDocumentsBackupSlice,
type DocumentBackupEntry,
} from "@/lib/export/documents-backup";
import { buildFamilyHistoryBackupSlice } from "@/lib/export/family-history-backup";
import {
buildIllnessBackupSlice,
type CanonicalIllnessEpisode,
} from "@/lib/export/illness-backup";
import {
buildLabsBackupSlice,
type BiomarkerBackupEntry,
type BiomarkerMergeBackupEntry,
type LabResultBackupEntry,
} from "@/lib/export/labs-backup";
import {
buildWorkoutsBackupSlice,
type WorkoutBackupEntry,
} from "@/lib/export/workouts-backup";
/** Disclosed in the payload manifest AND mirrored in the export UI copy. */
export const DOCUMENTS_MANIFEST_NOTE =
@@ -48,183 +58,6 @@ export const WORKOUTS_MANIFEST_NOTE =
"steps) are included. GPS routes and per-sample heart-rate/pace time " +
"series are not included in this export.";
/**
* Decrypt an encrypted free-text column for a portable export, or say plainly
* that this one could not be read.
*
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
* that is right — one bad row must not take the page down. In an export it is
* not: `null` is exactly what a column that was never written looks like, so
* the restore importer writes nothing and a note the person did write
* disappears without anyone being told. The Coach transcript and the doctor
* report both refuse that trade, one with a placeholder sentence and one with
* a rendered flag; this follows the same rule.
*
* The warning the soft helpers emitted is kept, so a systemic key gap still
* shows up in the wide event rather than only in the file.
*/
function readForExport(
buf: Uint8Array | null,
label: string,
decode: (bytes: Uint8Array) => string,
): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decode(buf);
} catch (err) {
getEvent()?.addWarning(
`${label} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNREADABLE_EXPORT_MARKER;
}
}
export interface LabResultBackupEntry {
/** Present in canonical DR payloads so rows keep a stable identity. */
id?: string;
/** The source row's id in a portable payload, for the relation links. */
ref?: string;
panel: string | null;
analyte: string;
value: number | null;
valueText: string | null;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** The window the reading's own source report printed, verbatim + derived. */
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
sourceReferenceText: string | null;
/**
* The value and unit the source report printed, kept when a write
* converted the reading into its marker's unit.
*/
sourceValue?: number | null;
sourceUnit?: string | null;
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 {
/** Present in canonical DR payloads; name remains the natural key. */
id?: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hidden: boolean;
context: string | null;
/** The catalogue key the marker's name and unit resolve to. */
analyteKey?: string | null;
/**
* v1.43 — the other spellings the marker answers to, with where
* each came from. Absent on files written before them.
*/
aliases?: BiomarkerAliasBackupEntry[];
createdAt?: string;
updatedAt?: string;
}
export interface BiomarkerAliasBackupEntry {
/** Present in canonical DR payloads. */
id?: string;
name: string;
source: "MERGE" | "IMPORT_CHOICE" | "MANUAL";
createdAt?: string;
}
/**
* v1.43 — one marker folded into another. The target travels by id
* in a disaster-recovery file and by name in both, the way a lab result's
* marker does; the source is gone, so its id and name are the record.
*/
export interface BiomarkerMergeBackupEntry {
id?: string;
targetBiomarkerId?: string;
targetName: string;
sourceBiomarkerId: string;
sourceName: string;
readingCount: number;
mergedAt: string;
}
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
createdAt?: string;
updatedAt?: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
source: string;
externalId: string | null;
/** Canonical DR only; portable workout summaries keep their existing shape. */
metadata?: Prisma.JsonValue;
/** Canonical DR only, beside `metadata`: the source's own version marker. */
externalSourceVersion?: string | null;
}
export interface DocumentBackupEntry {
id: string;
kind: string;
title: string | null;
filename: string | null;
mimeType: string;
byteSize: number;
status: string;
reportDate: string | null;
documentDate: string | null;
/** Portable export only; canonical DR carries summaryEncrypted instead. */
summary?: string | null;
createdAt: string;
updatedAt?: string;
/** Base64 of the already-encrypted BYTEA value; never plaintext. */
contentEncrypted?: string;
contentSha256?: string | null;
contentCodec?: string;
providerType?: string | null;
errorReason?: string | null;
/** Base64 of the already-encrypted summary BYTEA value. */
summaryEncrypted?: string | null;
summaryGeneratedAt?: string | null;
summaryState?: string;
/** Refs #776 — the index-attempt record, carried verbatim (canonical DR). */
lastIndexAttemptAt?: string | null;
lastIndexOutcome?: string | null;
/**
* v1.39.2 (#1038) — where an imported document came from, both payload
* kinds. Optional so a file written before the fields existed still parses.
*/
sourceSystem?: string | null;
sourceId?: string | null;
/** v1.39.3 — the source instance (origin); absent in older files. */
sourceInstance?: string | null;
/** Canonical DR only: the import's hold on automatic AI reading. */
aiReadDeferred?: boolean;
}
export interface RecordsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
/**
@@ -234,27 +67,6 @@ export interface RecordsBackupOptions {
deferDocuments?: boolean;
}
type CanonicalIllnessDayLog = IllnessDayLogDTO & {
noteEncrypted?: string | null;
tz?: string | null;
createdAt?: string;
deletedAt?: string | null;
};
type CanonicalIllnessEpisode = IllnessEpisodeDTO & {
noteEncrypted?: string | null;
/** The body site's ciphertext, disaster-recovery purpose only (v1.39.2). */
bodySiteEncrypted?: 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[];
@@ -284,223 +96,6 @@ export interface RecordsBackupCounts {
documents: number;
}
/** The document columns both purposes carry. */
const DOCUMENT_PORTABLE_SELECT = {
id: true,
kind: true,
title: true,
filename: true,
mimeType: true,
byteSize: true,
status: true,
reportDate: true,
documentDate: true,
summaryEncrypted: true,
sourceSystem: true,
sourceId: true,
sourceInstance: true,
createdAt: true,
} as const satisfies Prisma.InboundDocumentSelect;
/**
* A disaster-recovery document without its stored bytes. The paged reader
* reads this for every document at once and the bytes one slice at a time.
*/
const DOCUMENT_DR_METADATA_SELECT = {
...DOCUMENT_PORTABLE_SELECT,
contentSha256: true,
contentCodec: true,
providerType: true,
errorReason: true,
summaryGeneratedAt: true,
summaryState: true,
lastIndexAttemptAt: true,
lastIndexOutcome: true,
aiReadDeferred: true,
updatedAt: true,
} as const satisfies Prisma.InboundDocumentSelect;
const DOCUMENT_DR_SELECT = {
...DOCUMENT_DR_METADATA_SELECT,
contentEncrypted: true,
} as const satisfies Prisma.InboundDocumentSelect;
/** Total, so two exports of the same account list the documents alike. */
const DOCUMENT_ORDER = [
{ createdAt: "desc" },
{ id: "desc" },
] as const satisfies Prisma.InboundDocumentOrderByWithRelationInput[];
type PortableDocumentRow = Prisma.InboundDocumentGetPayload<{
select: typeof DOCUMENT_PORTABLE_SELECT;
}>;
type DisasterRecoveryDocumentRow = Prisma.InboundDocumentGetPayload<{
select: typeof DOCUMENT_DR_METADATA_SELECT;
}>;
type DocumentRow =
| PortableDocumentRow
| Prisma.InboundDocumentGetPayload<{ select: typeof DOCUMENT_DR_SELECT }>;
/**
* One document as it rides the wire. `contentEncrypted` is the base64 of the
* stored bytes for a disaster-recovery entry and null for a portable one,
* which carries the readable summary instead. Shared by the materialising
* reader and the paged one, so the two cannot drift.
*/
function serialiseDocument(
d: PortableDocumentRow | DisasterRecoveryDocumentRow,
contentEncrypted: string | null,
): DocumentBackupEntry {
const metadata = {
id: d.id,
kind: d.kind,
title: d.title,
filename: d.filename,
mimeType: d.mimeType,
byteSize: d.byteSize,
status: d.status,
reportDate: d.reportDate ? dateOnlyKey(d.reportDate) : null,
documentDate: d.documentDate ? dateOnlyKey(d.documentDate) : null,
sourceSystem: d.sourceSystem,
sourceId: d.sourceId,
sourceInstance: d.sourceInstance,
createdAt: d.createdAt.toISOString(),
};
if (contentEncrypted !== null && "contentCodec" in d) {
const dr: DisasterRecoveryDocumentRow = d;
return {
...metadata,
reportDate: dr.reportDate?.toISOString() ?? null,
documentDate: dr.documentDate?.toISOString() ?? null,
updatedAt: dr.updatedAt.toISOString(),
contentEncrypted,
contentSha256: dr.contentSha256,
contentCodec: dr.contentCodec,
providerType: dr.providerType,
errorReason: dr.errorReason,
summaryEncrypted: dr.summaryEncrypted
? Buffer.from(dr.summaryEncrypted).toString("base64")
: null,
summaryGeneratedAt: dr.summaryGeneratedAt?.toISOString() ?? null,
summaryState: dr.summaryState,
lastIndexAttemptAt: dr.lastIndexAttemptAt?.toISOString() ?? null,
lastIndexOutcome: dr.lastIndexOutcome,
aiReadDeferred: dr.aiReadDeferred,
};
}
return {
...metadata,
summary: decryptSummarySoft(d.summaryEncrypted),
};
}
/**
* How much of a document's stored bytes one read takes. A multiple of three,
* so the base64 of every slice joins into the base64 of the whole.
*/
const DOCUMENT_SLICE_BYTES = 1536 * 1024;
/**
* How much of a document's base64 the streaming writer shows its observer
* (the restore preview). The key id sits in the first characters; the cap is
* above the longest value the preview keeps as a key sample, so a shortened
* value is never kept as one. A multiple of four, so it still reads as base64.
*/
const DOCUMENT_OBSERVED_CHARS = 128 * 1024;
/**
* Every document of the account, one at a time, for the streaming backup
* writer (#1239).
*
* A document is up to a hundred megabytes, and the materialising read holds
* every document's bytes, their base64 and the section's JSON at once: about
* five times the size of the vault in heap, which a 524 MB process does not
* have for a vault of 100 MB. Here the metadata of all documents is read
* once, and each document's bytes are read a slice at a time and handed on as
* base64 pieces (`streamedField`), so what is held is one slice.
*
* A document that disappears between the metadata read and its first slice
* is left out, as a later read would not have seen it. One that disappears
* halfway fails the copy: half a document is not a document.
*/
export async function* iterateBackupDocuments(
prisma: Pick<PrismaClient, "inboundDocument" | "$queryRaw">,
userId: string,
options: RecordsBackupOptions = {},
): AsyncGenerator<DocumentBackupEntry | StreamedField, void, void> {
if (options.purpose !== "disaster-recovery") {
const rows = await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_PORTABLE_SELECT,
});
for (const row of rows) yield serialiseDocument(row, null);
return;
}
const rows = await prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_DR_METADATA_SELECT,
});
const slice = async (id: string, from: number): Promise<Buffer | null> => {
const [row] = await prisma.$queryRaw<Array<{ part: Uint8Array | null }>>`
SELECT substring(content_encrypted FROM ${from + 1}::int
FOR ${DOCUMENT_SLICE_BYTES}::int) AS part
FROM inbound_documents
WHERE id = ${id} AND user_id = ${userId}
`;
if (!row) return null;
const part = row.part ?? new Uint8Array(0);
// A view, not a copy: the slice is read once and dropped.
return Buffer.from(part.buffer, part.byteOffset, part.byteLength);
};
for (const row of rows) {
const first = await slice(row.id, 0);
if (first === null) continue;
const head = first.toString("base64");
const pieces = async function* (): AsyncGenerator<string, void, void> {
yield head;
let last = first.byteLength;
for (let at = last; last === DOCUMENT_SLICE_BYTES; at += last) {
const next = await slice(row.id, at);
if (next === null) {
throw new Error(
`Document ${row.id} was removed while it was being backed up; ` +
"the next run copies the account again.",
);
}
last = next.byteLength;
if (last > 0) yield next.toString("base64");
}
};
yield streamedField(
serialiseDocument(row, ""),
"contentEncrypted",
head.length > DOCUMENT_OBSERVED_CHARS
? head.slice(0, DOCUMENT_OBSERVED_CHARS)
: head,
pieces,
);
}
}
/** Decrypt a document's optional AI summary fail-soft (null on any error). */
function decryptSummarySoft(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptDocumentSummary(buf);
} catch (err) {
getEvent()?.addWarning(
`document summary decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
}
}
/**
* Build the structured-records slice. The default is the established portable
* export. Only the weekly DR writer passes `purpose: "disaster-recovery"`.
@@ -522,286 +117,28 @@ export async function buildRecordsBackupSection(
options: RecordsBackupOptions = {},
): Promise<RecordsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const documentQuery = options.deferDocuments
? Promise.resolve([] as DocumentRow[])
: disasterRecovery
? prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_DR_SELECT,
})
: prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
orderBy: DOCUMENT_ORDER,
select: DOCUMENT_PORTABLE_SELECT,
});
const [
labResultRows,
biomarkerRows,
biomarkerAliasRows,
biomarkerMergeRows,
episodeRows,
allergyRows,
familyRows,
workoutRows,
documentRows,
] = await Promise.all([
prisma.labResult.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { takenAt: "desc" },
include: { biomarker: { select: { name: true } } },
}),
prisma.biomarker.findMany({
where: { userId },
orderBy: { name: "asc" },
}),
prisma.biomarkerAlias.findMany({
where: { userId },
orderBy: [{ biomarkerId: "asc" }, { nameFolded: "asc" }],
select: {
id: true,
biomarkerId: true,
name: true,
source: true,
createdAt: true,
},
}),
prisma.biomarkerMerge.findMany({
where: { userId },
orderBy: { mergedAt: "asc" },
include: { target: { select: { name: true } } },
}),
prisma.illnessEpisode.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { onsetAt: "desc" },
include: {
dayLogs: {
...(disasterRecovery ? {} : { where: { deletedAt: null } }),
orderBy: { date: "asc" },
include: dayLogSymptomInclude,
},
},
}),
prisma.allergy.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.familyHistoryEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: "desc" },
}),
prisma.workout.findMany({
where: { userId },
orderBy: { startedAt: "desc" },
}),
documentQuery,
]);
const labResults: LabResultBackupEntry[] = labResultRows.map((r) => ({
...(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,
}
: {
// No id: a portable restore mints a fresh one. The relation links
// still name this row, so the file says which row they mean.
ref: r.id,
note: readForExport(
r.noteEncrypted,
"lab note",
decryptNoteFromBytes,
),
}),
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
sourceReferenceLow: r.sourceReferenceLow,
sourceReferenceHigh: r.sourceReferenceHigh,
sourceReferenceText: r.sourceReferenceText,
sourceValue: r.sourceValue,
sourceUnit: r.sourceUnit,
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
}));
const aliasesByMarker = new Map<string, BiomarkerAliasBackupEntry[]>();
for (const alias of biomarkerAliasRows) {
const list = aliasesByMarker.get(alias.biomarkerId) ?? [];
list.push({
name: alias.name,
source: alias.source as BiomarkerAliasBackupEntry["source"],
...(disasterRecovery
? { id: alias.id, createdAt: alias.createdAt.toISOString() }
: {}),
});
aliasesByMarker.set(alias.biomarkerId, list);
}
const biomarkerMerges: BiomarkerMergeBackupEntry[] = biomarkerMergeRows.map(
(m) => ({
...(disasterRecovery
? { id: m.id, targetBiomarkerId: m.targetBiomarkerId }
: {}),
targetName: m.target.name,
sourceBiomarkerId: m.sourceBiomarkerId,
sourceName: m.sourceName,
readingCount: m.readingCount,
mergedAt: m.mergedAt.toISOString(),
}),
);
const biomarkers: BiomarkerBackupEntry[] = biomarkerRows.map((b) => ({
...(disasterRecovery
? {
id: b.id,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}
: {}),
name: b.name,
unit: b.unit,
lowerBound: b.lowerBound,
upperBound: b.upperBound,
panel: b.panel,
hidden: b.hidden,
context: readForExport(
b.contextEncrypted,
"biomarker context",
decryptContextFromBytes,
),
analyteKey: b.analyteKey,
aliases: aliasesByMarker.get(b.id) ?? [],
}));
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,
// Ciphertext verbatim, like the note: the disaster-recovery file never
// carries the site in the clear.
bodySite: null,
bodySiteEncrypted: row.bodySiteEncrypted
? Buffer.from(row.bodySiteEncrypted).toString("base64")
: null,
laterality: row.laterality,
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: 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) => ({
...(disasterRecovery
? {
id: w.id,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
...(w.metadata == null ? {} : { metadata: w.metadata }),
externalSourceVersion: w.externalSourceVersion,
}
: {}),
sportType: w.sportType,
startedAt: w.startedAt.toISOString(),
endedAt: w.endedAt.toISOString(),
durationSec: w.durationSec,
totalEnergyKcal: w.totalEnergyKcal,
totalDistanceM: w.totalDistanceM,
avgHeartRate: w.avgHeartRate,
maxHeartRate: w.maxHeartRate,
minHeartRate: w.minHeartRate,
stepCount: w.stepCount,
elevationM: w.elevationM,
pauseDurationSec: w.pauseDurationSec,
source: w.source,
externalId: w.externalId,
}));
const documents: DocumentBackupEntry[] = documentRows.map((d) =>
serialiseDocument(
d,
disasterRecovery && "contentEncrypted" in d
? Buffer.from(d.contentEncrypted).toString("base64")
: null,
),
);
const [labs, illness, allergies, family, workouts, documents] =
await Promise.all([
buildLabsBackupSlice(prisma, userId, disasterRecovery),
buildIllnessBackupSlice(prisma, userId, disasterRecovery),
buildAllergiesBackupSlice(prisma, userId, disasterRecovery),
buildFamilyHistoryBackupSlice(prisma, userId),
buildWorkoutsBackupSlice(prisma, userId, disasterRecovery),
buildDocumentsBackupSlice(
prisma,
userId,
disasterRecovery,
options.deferDocuments === true,
),
]);
return {
labResults,
biomarkers,
biomarkerMerges,
illnessEpisodes,
allergies,
familyHistory,
workouts,
documents,
...labs,
...illness,
...allergies,
...family,
...workouts,
...documents,
manifest: {
documents: disasterRecovery
? {
+2 -4
View File
@@ -35,14 +35,12 @@ import {
RESTORE_CLEAR_STEPS,
RESTORE_STEPS,
} from "@/lib/export/backup-domains";
import {
createMeasurementRestorePrepass,
type MeasurementRestorePrepass,
} from "@/lib/export/measurements-backup";
import { createMeasurementRestorePrepass } from "@/lib/export/measurements-restore";
import {
emptyRestoreLinks,
orderedCleared,
RESTORE_SECTION_STEPS,
type MeasurementRestorePrepass,
type RestoreCleared,
type RestoreContext,
type RestoreSectionStep,
+19 -2
View File
@@ -12,11 +12,13 @@
import { Buffer } from "node:buffer";
import type { Prisma } from "@/generated/prisma/client";
import type { BackupPayload } from "@/lib/validations/backup";
import type {
BackupMeasurement,
BackupPayload,
} from "@/lib/validations/backup";
import type { StreamedBackup } from "@/lib/export/streamed-backup";
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
import type { AccountSettingsRestoreResult } from "@/lib/export/account-settings-restore";
import type { MeasurementRestorePrepass } from "@/lib/export/measurements-backup";
/**
* The steps after the measurements that report as sections, in order. The
@@ -144,6 +146,21 @@ export interface RestoreLinks {
episodeIds: Set<string>;
}
/**
* What the file's first read noted about its readings as a whole, for the
* measurement step (`createMeasurementRestorePrepass` in
* `measurements-restore.ts` builds it). Declared here so a module that only
* names the restore context does not import the restore itself.
*/
export interface MeasurementRestorePrepass {
/** Called for every reading as the first read checks it. */
note(row: BackupMeasurement): void;
/** The live `stats:` ids of the folded types, keyed by source and type. */
readonly liveFoldStatsIds: ReadonlySet<string>;
/** A row the migration's rule leaves out under either type. */
isShadowedLegacyRmssd(row: BackupMeasurement): boolean;
}
/** What the steps report back to the orchestrator once they are done. */
export interface RestoreOutputs {
/** Measurement tombstones left out (expired or folded). */
+1 -1
View File
@@ -15,7 +15,7 @@ import {
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import { findIncompleteDocument } from "@/lib/export/documents-backup";
import type { MeasurementRestorePrepass } from "@/lib/export/measurements-backup";
import type { MeasurementRestorePrepass } from "@/lib/export/restore-context";
import type {
RestoreBackupInput,
RestoreBackupOutcome,
+67
View File
@@ -2,9 +2,76 @@
* The workout summaries, with both backup ends in one file. GPS routes and
* per-sample series do not travel, and the file's manifest says so.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { toJson } from "@/lib/db";
import type { RestoreContext } from "@/lib/export/restore-context";
// ── Export ───────────────────────────────────────────────────────────────────
export interface WorkoutBackupEntry {
/** Present in canonical DR payloads for manual workouts without externalId. */
id?: string;
createdAt?: string;
updatedAt?: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
minHeartRate: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
source: string;
externalId: string | null;
/** Canonical DR only; portable workout summaries keep their existing shape. */
metadata?: Prisma.JsonValue;
/** Canonical DR only, beside `metadata`: the source's own version marker. */
externalSourceVersion?: string | null;
}
/** The workout slice of the records section: the summaries. */
export async function buildWorkoutsBackupSlice(
prisma: Pick<PrismaClient, "workout">,
userId: string,
disasterRecovery: boolean,
): Promise<{ workouts: WorkoutBackupEntry[] }> {
const workoutRows = await prisma.workout.findMany({
where: { userId },
orderBy: { startedAt: "desc" },
});
const workouts: WorkoutBackupEntry[] = workoutRows.map((w) => ({
...(disasterRecovery
? {
id: w.id,
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
...(w.metadata == null ? {} : { metadata: w.metadata }),
externalSourceVersion: w.externalSourceVersion,
}
: {}),
sportType: w.sportType,
startedAt: w.startedAt.toISOString(),
endedAt: w.endedAt.toISOString(),
durationSec: w.durationSec,
totalEnergyKcal: w.totalEnergyKcal,
totalDistanceM: w.totalDistanceM,
avgHeartRate: w.avgHeartRate,
maxHeartRate: w.maxHeartRate,
minHeartRate: w.minHeartRate,
stepCount: w.stepCount,
elevationM: w.elevationM,
pauseDurationSec: w.pauseDurationSec,
source: w.source,
externalId: w.externalId,
}));
return { workouts };
}
// ── Restore ──────────────────────────────────────────────────────────────────
export async function clearWorkouts(ctx: RestoreContext): Promise<void> {