diff --git a/src/__tests__/backup-schema-lazy-guard.test.ts b/src/__tests__/backup-schema-lazy-guard.test.ts new file mode 100644 index 000000000..7edfd07fb --- /dev/null +++ b/src/__tests__/backup-schema-lazy-guard.test.ts @@ -0,0 +1,75 @@ +/** + * `backupPayloadSchema` is built when `src/lib/validations/backup.ts` is + * evaluated and holds about 12 MB of heap. The restore job, the stored-backup + * readers and the export writer sit on the boot path, so a static value import + * of that module from any of them puts the schema in every server process, + * whether or not it ever touches a backup. Runtime code reaches it with a + * dynamic `import()` at the moment a file is validated, takes only types + * statically, and takes the version and the summary from `backup-summary.ts`. + * + * The one static importer allowed is the OpenAPI registry, which only the + * spec generator loads. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, ".."); +const ALLOWED_STATIC = new Set(["lib/openapi/routes/export.ts"]); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (name === "__tests__" || name === "generated") continue; + if (statSync(path).isDirectory()) out.push(...sourceFiles(path)); + else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name)) { + out.push(path); + } + } + return out; +} + +// `import … from "@/lib/validations/backup"`, across lines, capturing what +// precedes the specifier. Every importer uses the alias (a relative +// `./backup` also names `src/lib/cycle/backup.ts`, so it is not matched). +const STATIC_IMPORT = + /^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/validations\/backup["']/gm; +const DYNAMIC_IMPORT = /import\(\s*["']@\/lib\/validations\/backup["']\s*\)/g; + +describe("the backup payload schema loads on first use", () => { + const files = sourceFiles(ROOT); + const staticValueImporters: string[] = []; + let dynamicImports = 0; + let typeImports = 0; + + for (const file of files) { + const rel = relative(ROOT, file); + if (rel === "lib/validations/backup.ts") continue; + const text = readFileSync(file, "utf8"); + for (const match of text.matchAll(STATIC_IMPORT)) { + const typeOnly = + match[1] !== undefined || + (/^\{[^}]*\}$/.test(match[2]) && + match[2] + .slice(1, -1) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .every((s) => s.startsWith("type "))); + if (typeOnly) typeImports++; + else if (!ALLOWED_STATIC.has(rel)) staticValueImporters.push(rel); + } + dynamicImports += [...text.matchAll(DYNAMIC_IMPORT)].length; + } + + it("has no static value import outside the OpenAPI registry", () => { + expect(staticValueImporters).toEqual([]); + }); + + it("finds the loaders it guards (an empty match set would pass vacuously)", () => { + // upload, restore, preview (store + summary route), download, streamed reader + expect(dynamicImports).toBeGreaterThanOrEqual(5); + expect(typeImports).toBeGreaterThan(0); + }); +}); diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts index d79aca81d..bf5e3c096 100644 --- a/src/app/api/admin/backups/[id]/download/route.ts +++ b/src/app/api/admin/backups/[id]/download/route.ts @@ -38,7 +38,6 @@ import { type StreamedBackup, } from "@/lib/export/streamed-backup"; import { annotate } from "@/lib/logging/context"; -import { parseBackupPayload } from "@/lib/validations/backup"; export const dynamic = "force-dynamic"; @@ -108,6 +107,8 @@ export const GET = apiHandler( let streamed: StreamedBackup; try { streamed = await readStreamedBackup(source); + // Loaded on first use, not at import (see `backup-summary.ts`). + const { parseBackupPayload } = await import("@/lib/validations/backup"); parseBackupPayload(streamed.raw); } catch (err) { const readFailure = isStoredBackupReadError(err); diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts index 9c95d3f8b..417ab2277 100644 --- a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -67,14 +67,16 @@ const summarizeMock = vi.fn(); const compatibleMock = vi.fn((version: string) => version !== "99"); vi.mock("@/lib/validations/backup", () => ({ parseBackupPayload: (...a: unknown[]) => parseMock(...a), - summarizeBackup: (...a: unknown[]) => summarizeMock(...a), - isCompatibleSchemaVersion: (version: string) => compatibleMock(version), // The measurements are read as a stream and counted one by one. - BACKUP_SCHEMA_VERSION: "2", backupMeasurementSchema: { safeParse: (value: unknown) => ({ success: true, data: value }), }, })); +vi.mock("@/lib/validations/backup-summary", () => ({ + summarizeBackup: (...a: unknown[]) => summarizeMock(...a), + isCompatibleSchemaVersion: (version: string) => compatibleMock(version), + BACKUP_SCHEMA_VERSION: "2", +})); import { GET } from "../route"; import { storedCopyIdentity } from "@/lib/export/backup-preview"; diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts index 36862e792..bc8eacfc3 100644 --- a/src/app/api/admin/backups/[id]/summary/route.ts +++ b/src/app/api/admin/backups/[id]/summary/route.ts @@ -59,9 +59,8 @@ import { import { annotate } from "@/lib/logging/context"; import { isCompatibleSchemaVersion, - parseBackupPayload, summarizeBackup, -} from "@/lib/validations/backup"; +} from "@/lib/validations/backup-summary"; export const dynamic = "force-dynamic"; @@ -123,6 +122,8 @@ async function readPreview( try { const streamed = await readStreamedBackup(source); + // Loaded on first use, not at import (see `backup-summary.ts`). + const { parseBackupPayload } = await import("@/lib/validations/backup"); const payload = parseBackupPayload(streamed.raw); const summary = { ...summarizeBackup(payload), diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts index 063dbf978..b0d14a5ea 100644 --- a/src/app/api/admin/backups/upload/route.ts +++ b/src/app/api/admin/backups/upload/route.ts @@ -61,15 +61,13 @@ import { storeBackupBlob } from "@/lib/export/store-backup-blob"; import { StreamedBackupInvalidError } from "@/lib/export/streamed-backup"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; +import type { BackupPayload } from "@/lib/validations/backup"; import { BACKUP_SCHEMA_VERSION, - backupMeasurementSchema, - backupPayloadSchema, isCompatibleSchemaVersion, summarizeBackup, - type BackupPayload, type BackupSummary, -} from "@/lib/validations/backup"; +} from "@/lib/validations/backup-summary"; export const dynamic = "force-dynamic"; @@ -252,6 +250,9 @@ export const POST = apiHandler(async (request: NextRequest) => { yield chunk; } } + // Loaded on first use, not at import (see `backup-summary.ts`). + const { backupMeasurementSchema, backupPayloadSchema } = + await import("@/lib/validations/backup"); let firstWithoutId: number | null = null; let scanned; try { diff --git a/src/lib/export/__tests__/backup-preview.test.ts b/src/lib/export/__tests__/backup-preview.test.ts index 688087675..8016c6c4c 100644 --- a/src/lib/export/__tests__/backup-preview.test.ts +++ b/src/lib/export/__tests__/backup-preview.test.ts @@ -20,7 +20,8 @@ import { storedPreviewFor, } from "@/lib/export/backup-preview"; import type { FullBackupCounts } from "@/lib/export/full-backup-payload"; -import { backupPayloadSchema, summarizeBackup } from "@/lib/validations/backup"; +import { backupPayloadSchema } from "@/lib/validations/backup"; +import { summarizeBackup } from "@/lib/validations/backup-summary"; const ts = "2026-07-19T07:00:00.000Z"; /** A bytes-column value holding the string codec under key id `v3`. */ diff --git a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts index 457fcb7d9..f23c35dea 100644 --- a/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts +++ b/src/lib/export/__tests__/restore-backup-owner-mismatch.test.ts @@ -31,11 +31,13 @@ vi.mock("@/lib/crypto", () => ({ vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() })); vi.mock("@/lib/validations/backup", () => ({ parseBackupPayload: vi.fn(), + // The streamed reader checks measurements one at a time; this file has none. + backupMeasurementSchema: { safeParse: vi.fn() }, +})); +vi.mock("@/lib/validations/backup-summary", () => ({ isCompatibleSchemaVersion: vi.fn(() => true), summarizeBackup: vi.fn(() => ({})), - // The streamed reader checks measurements one at a time; this file has none. BACKUP_SCHEMA_VERSION: "2", - backupMeasurementSchema: { safeParse: vi.fn() }, })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts index c96be0554..91eebab21 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -35,7 +35,7 @@ import { } from "@/lib/export/backup-key-ids"; import type { FullBackupCounts } from "@/lib/export/full-backup-payload"; import type { StoredBackupRef } from "@/lib/export/stored-backup"; -import type { BackupSummary } from "@/lib/validations/backup"; +import type { BackupSummary } from "@/lib/validations/backup-summary"; export interface BackupPreview { version: 1; diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index f96f79935..802f492c0 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -27,7 +27,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { readNote } from "@/lib/crypto/note-cipher"; import { openSealedForExport } from "@/lib/export/sealed-text"; import { iterateMeasurementPages } from "@/lib/export/paged-measurements"; -import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup"; +import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary"; import { buildCycleBackupSection, type CycleBackupSection, diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index e0bd34eef..21c702f21 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -71,13 +71,12 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; +import type { BackupMeasurement } from "@/lib/validations/backup"; import { - parseBackupPayload, isCompatibleSchemaVersion, summarizeBackup, - type BackupMeasurement, type BackupSummary, -} from "@/lib/validations/backup"; +} from "@/lib/validations/backup-summary"; import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups"; import { recomputeUserMedicationCompliance, @@ -431,6 +430,9 @@ export async function restoreBackup( progress.measurementsTotal = streamed.measurementCount; report("validating", true); raw = streamed.raw; + // Loaded here, not at import: the schema is ~12 MB of heap and a server + // that never validates a backup should not hold it (`backup-summary.ts`). + const { parseBackupPayload } = await import("@/lib/validations/backup"); payload = parseBackupPayload(raw); } catch (err) { const readFailure = isStoredBackupReadError(err); diff --git a/src/lib/export/store-backup-blob.ts b/src/lib/export/store-backup-blob.ts index 2b9fe6a11..ec5a0543f 100644 --- a/src/lib/export/store-backup-blob.ts +++ b/src/lib/export/store-backup-blob.ts @@ -48,7 +48,7 @@ import { newChunkStreamId, } from "@/lib/export/backup-chunks"; import { BackupFormsConflictError } from "@/lib/export/stored-backup"; -import type { BackupSummary } from "@/lib/validations/backup"; +import type { BackupSummary } from "@/lib/validations/backup-summary"; /** * How long the storing transaction may stay open. The job around it expires diff --git a/src/lib/export/streamed-backup.ts b/src/lib/export/streamed-backup.ts index b87c1b848..15bece054 100644 --- a/src/lib/export/streamed-backup.ts +++ b/src/lib/export/streamed-backup.ts @@ -23,11 +23,22 @@ */ import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids"; import { scanBackupJson } from "@/lib/export/backup-json-scan"; -import { - BACKUP_SCHEMA_VERSION, +import type { + BackupMeasurement, backupMeasurementSchema, - type BackupMeasurement, } from "@/lib/validations/backup"; +import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary"; + +type MeasurementSchema = typeof backupMeasurementSchema; + +/** + * The element schema, loaded with the rest of `backup.ts` on the first read + * rather than at import: the module sits on the boot path of the restore job + * and the schema is ~12 MB of heap (`backup-summary.ts`). + */ +async function loadMeasurementSchema(): Promise { + return (await import("@/lib/validations/backup")).backupMeasurementSchema; +} /** The file's bytes, from the start, every time it is called. */ export type BackupSource = () => AsyncIterable; @@ -69,8 +80,12 @@ export interface StreamedBackup { ): Promise; } -function parseMeasurement(element: unknown, index: number): BackupMeasurement { - const parsed = backupMeasurementSchema.safeParse(element); +function parseMeasurement( + schema: MeasurementSchema, + element: unknown, + index: number, +): BackupMeasurement { + const parsed = schema.safeParse(element); if (!parsed.success) { const issue = parsed.error.issues[0]; throw new StreamedBackupInvalidError( @@ -98,6 +113,7 @@ export async function readStreamedBackup( source: BackupSource, options: ReadStreamedBackupOptions = {}, ): Promise { + const schema = await loadMeasurementSchema(); let firstWithoutId: number | null = null; let bytes = 0; const keys = new BackupKeyIdCollector(); @@ -110,7 +126,7 @@ export async function readStreamedBackup( const { document, streamedCounts } = await scanBackupJson(counted(), { streamKeys: STREAMED, onElement: (_key, element, index) => { - const row = parseMeasurement(element, index); + const row = parseMeasurement(schema, element, index); if (!row.id && firstWithoutId === null) firstWithoutId = index; keys.visit(element, MEASUREMENTS); options.onMeasurementChecked?.(index + 1); @@ -146,7 +162,7 @@ export async function readStreamedBackup( streamKeys: STREAMED, skipKeys, onElement: async (_key, element, index) => { - batch.push(parseMeasurement(element, index)); + batch.push(parseMeasurement(schema, element, index)); if (batch.length >= size) { const full = batch; batch = []; diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 95702c8c7..368b4c2d5 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -2,10 +2,8 @@ import { createCipheriv, randomBytes } from "node:crypto"; import type { Readable } from "node:stream"; import { describe, it, expect, beforeEach, vi } from "vitest"; -import { - BACKUP_SCHEMA_VERSION, - backupPayloadSchema, -} from "@/lib/validations/backup"; +import { backupPayloadSchema } from "@/lib/validations/backup"; +import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary"; const mocks = vi.hoisted(() => ({ buildFullBackupPayload: vi.fn(), diff --git a/src/lib/validations/__tests__/backup.test.ts b/src/lib/validations/__tests__/backup.test.ts index c10d46e65..fd037786f 100644 --- a/src/lib/validations/__tests__/backup.test.ts +++ b/src/lib/validations/__tests__/backup.test.ts @@ -18,12 +18,8 @@ * of bubbling out of `prisma.createMany` with a useless message. */ import { describe, expect, it } from "vitest"; -import { - backupPayloadSchema, - isCompatibleSchemaVersion, - parseBackupPayload, - summarizeBackup, -} from "../backup"; +import { backupPayloadSchema, parseBackupPayload } from "../backup"; +import { isCompatibleSchemaVersion, summarizeBackup } from "../backup-summary"; const baseEntry = { date: "2026-05-08", diff --git a/src/lib/validations/backup-summary.ts b/src/lib/validations/backup-summary.ts new file mode 100644 index 000000000..cd04d0eae --- /dev/null +++ b/src/lib/validations/backup-summary.ts @@ -0,0 +1,216 @@ +/** + * What a backup is, without the schema that checks one. + * + * `backupPayloadSchema` (`backup.ts`) is the largest Zod tree in the server: + * built at import, it holds about 12 MB of heap. Everything here is what the + * backup paths need besides that check: the schema version, whether a file's + * version is one this server reads, and the per-section counts of a payload + * that has already been parsed. Importing this module never builds the + * schema, so a module on the boot path (the restore job, the stored-backup + * readers, the export writer) can import it freely and load `backup.ts` with + * a dynamic `import()` at the moment a file is actually validated. + */ +import type { BackupPayload } from "@/lib/validations/backup"; + +export const BACKUP_SCHEMA_VERSION = "2" as const; +export const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; + +/** + * Numeric counts of each backed-up record kind. Returned in the + * upload + restore API responses so the admin sees what they + * uploaded/restored without having to download the file again. + */ +export interface BackupSummary { + schemaVersion: string; + userId: string; + exportedAt: string; + measurements: number; + medications: number; + intakeEvents: number; + /** Side effects recorded against a drug, across every medication. */ + medicationSideEffects: number; + /** Pinned efficacy targets, across every medication. */ + medicationEfficacyTargets: number; + /** Archived schedule eras, across every medication. */ + medicationScheduleRevisions: number; + moodEntries: number; + /** v1.15.0 — observed cycle spans in the backup. */ + cycles: number; + /** v1.15.0 — cycle day-logs in the backup. */ + cycleDayLogs: number; + /** Lab results in the backup. */ + labResults: number; + nutrientDays: number; + /** User-scoped biomarker catalog entries in the backup. */ + biomarkers: number; + /** Illness episodes, including flares/exacerbations. */ + illnessEpisodes: number; + /** Illness day-logs across every episode. */ + illnessDayLogs: number; + /** Allergy/intolerance records in the backup. */ + allergies: number; + /** Family-history entries in the backup. */ + familyHistory: number; + /** Workout summary records in the backup. */ + workouts: number; + /** Document records (ciphertext included in canonical DR payloads). */ + documents: number; + /** Which documents were filed against which condition. */ + documentConditionLinks: number; + /** Facts staged out of a document, with their review decision. */ + extractedFacts: number; + /** 1 when the account's durable self-context rides the file, 0 otherwise. */ + healthProfile: number; + /** Effective-dated structured health-profile revisions. */ + healthProfileFactRevisions: number; + /** Metrics the account defined itself. */ + customMetrics: number; + /** Readings across every user-defined metric. */ + customMetricEntries: number; + /** Persisted accepted correlation identities and dismissal decisions. */ + correlationPatterns: number; + /** Stored day curves for the cumulative metrics, across every metric. */ + intradayProfiles: number; + /** Local days whose health score was written down as it was shown. */ + healthScoreRecords: number; + /** 1 when the record's setup answers ride the file, 0 otherwise. */ + onboardingRecords: number; + /** v1.37.19 (A6-8) — the visit address book. */ + practitioners: number; + /** v1.37.19 (A6-8) — doctor visits, planned and past. */ + encounters: number; + /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ + encounterLinks: number; + /** v1.37.19 (A6-8) — immunization log entries. */ + vaccinations: number; + /** v1.37.19 (A6-8) — vaccination↔document links. */ + vaccinationLinks: number; + /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ + measurementReminders: number; + /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ + measurementReminderEvents: number; + /** Coach conversation threads. */ + coachConversations: number; + /** Coach turns across every thread. */ + coachMessages: number; + /** Durable facts, agreed plans and things to bring back up. */ + coachFacts: number; + coachPlans: number; + coachReminders: number; + /** Completed screener administrations. Disaster-recovery payloads only. */ + mentalHealthAssessments: number; + /** Consent records. Disaster-recovery payloads only. */ + consentReceipts: number; + /** Personal bests across every metric and sport slot. */ + personalRecords: number; + /** Badges the account has unlocked, each with the day it was earned. */ + userAchievements: number; + /** Local days with an environmental reading. */ + environmentContexts: number; + /** Declared stretches spent away from home. */ + environmentTravelLocations: number; + /** ECG strips, with their rhythm verdict and their trace. */ + ecgRecordings: number; +} + +export function summarizeBackup(payload: BackupPayload): BackupSummary { + return { + schemaVersion: payload.schemaVersion, + userId: payload.userId, + exportedAt: payload.exportedAt, + measurements: payload.measurements.length, + medications: payload.medications.length, + intakeEvents: payload.intakeEvents.length, + medicationSideEffects: payload.medications.reduce( + (sum, medication) => sum + medication.sideEffects.length, + 0, + ), + medicationEfficacyTargets: payload.medications.reduce( + (sum, medication) => sum + medication.efficacyTargets.length, + 0, + ), + medicationScheduleRevisions: payload.medications.reduce( + (sum, medication) => sum + medication.scheduleRevisions.length, + 0, + ), + moodEntries: payload.moodEntries.length, + cycles: payload.cycles.length, + cycleDayLogs: payload.cycleDayLogs.length, + nutrientDays: payload.nutrientDays.length, + labResults: payload.labResults.length, + biomarkers: payload.biomarkers.length, + illnessEpisodes: payload.illnessEpisodes.length, + illnessDayLogs: payload.illnessEpisodes.reduce( + (sum, e) => sum + e.dayLogs.length, + 0, + ), + allergies: payload.allergies.length, + familyHistory: payload.familyHistory.length, + workouts: payload.workouts.length, + documents: payload.documents.length, + // Counted from the release that carries them, so the admin's "what did I + // just restore" answer never under-counts a filed vault the way it once + // did for visits. + documentConditionLinks: payload.documentConditionLinks.length, + extractedFacts: payload.extractedFacts.length, + healthProfile: payload.healthProfile ? 1 : 0, + healthProfileFactRevisions: payload.healthProfileFacts.length, + customMetrics: payload.customMetrics.length, + customMetricEntries: payload.customMetrics.reduce( + (sum, metric) => sum + metric.entries.length, + 0, + ), + correlationPatterns: payload.correlationPatterns.length, + intradayProfiles: payload.intradayProfiles.length, + healthScoreRecords: payload.healthScoreRecords.length, + onboardingRecords: payload.onboardingRecord ? 1 : 0, + // v1.37.19 (A6-8) — the sections restored since 08-01 were written and + // restored but absent from this report, so the admin's "what did I just + // restore" answer silently under-counted a file that carried visits or + // an Impfpass. + practitioners: payload.practitioners.length, + encounters: payload.encounters.length, + encounterLinks: + payload.encounterDocumentLinks.length + + payload.encounterLabLinks.length + + payload.encounterConditionLinks.length, + vaccinations: payload.vaccinations.length, + vaccinationLinks: payload.vaccinationDocumentLinks.length, + // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, + // so the admin's "what did I just restore" answer never under-counts a + // file with reminders the way it once did for visits. + measurementReminders: payload.measurementReminders.length, + measurementReminderEvents: payload.measurementReminderEvents.length, + coachConversations: payload.coachConversations.length, + coachMessages: payload.coachConversations.reduce( + (total, conversation) => total + conversation.messages.length, + 0, + ), + coachFacts: payload.coachFacts.length, + coachPlans: payload.coachPlans.length, + coachReminders: payload.coachReminders.length, + mentalHealthAssessments: payload.mentalHealthAssessments.length, + consentReceipts: payload.consentReceipts.length, + // Counted from the release that carries them, so the admin's "what did I + // just restore" answer never under-counts a file with bests, badges or an + // environmental history the way it once did for visits. + personalRecords: payload.personalRecords.length, + userAchievements: payload.userAchievements.length, + environmentContexts: payload.environmentContexts.length, + environmentTravelLocations: payload.environmentTravelLocations.length, + ecgRecordings: payload.ecgRecordings.length, + }; +} + +/** + * The schemaVersion the system understands today. Used by the upload + * route to reject inbound files written by a *future* HealthLog instance + * — restoring them under the current code might silently drop data the + * new shape carried. + */ +export function isCompatibleSchemaVersion(version: string): boolean { + return ( + version === LEGACY_BACKUP_SCHEMA_VERSION || + version === BACKUP_SCHEMA_VERSION + ); +} diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index f0a31c31f..e47df648e 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -88,14 +88,15 @@ import { DOCUMENT_SOURCE_SYSTEMS, } from "@/lib/validations/inbound-documents"; import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; +import { + BACKUP_SCHEMA_VERSION, + LEGACY_BACKUP_SCHEMA_VERSION, +} from "@/lib/validations/backup-summary"; import { UNITS_PER_DOSE_MESSAGE, isSupportedUnitsPerDose, } from "@/lib/medications/units-per-dose"; -export const BACKUP_SCHEMA_VERSION = "2" as const; -const LEGACY_BACKUP_SCHEMA_VERSION = "1" as const; - const isoDateTime = z .string() .min(1) @@ -2078,193 +2079,6 @@ export type BackupPayload = z.infer; export const backupMeasurementSchema = measurementSchema; export type BackupMeasurement = z.infer; -/** - * Numeric counts of each backed-up record kind. Returned in the - * upload + restore API responses so the admin sees what they - * uploaded/restored without having to download the file again. - */ -export interface BackupSummary { - schemaVersion: string; - userId: string; - exportedAt: string; - measurements: number; - medications: number; - intakeEvents: number; - /** Side effects recorded against a drug, across every medication. */ - medicationSideEffects: number; - /** Pinned efficacy targets, across every medication. */ - medicationEfficacyTargets: number; - /** Archived schedule eras, across every medication. */ - medicationScheduleRevisions: number; - moodEntries: number; - /** v1.15.0 — observed cycle spans in the backup. */ - cycles: number; - /** v1.15.0 — cycle day-logs in the backup. */ - cycleDayLogs: number; - /** Lab results in the backup. */ - labResults: number; - nutrientDays: number; - /** User-scoped biomarker catalog entries in the backup. */ - biomarkers: number; - /** Illness episodes, including flares/exacerbations. */ - illnessEpisodes: number; - /** Illness day-logs across every episode. */ - illnessDayLogs: number; - /** Allergy/intolerance records in the backup. */ - allergies: number; - /** Family-history entries in the backup. */ - familyHistory: number; - /** Workout summary records in the backup. */ - workouts: number; - /** Document records (ciphertext included in canonical DR payloads). */ - documents: number; - /** Which documents were filed against which condition. */ - documentConditionLinks: number; - /** Facts staged out of a document, with their review decision. */ - extractedFacts: number; - /** 1 when the account's durable self-context rides the file, 0 otherwise. */ - healthProfile: number; - /** Effective-dated structured health-profile revisions. */ - healthProfileFactRevisions: number; - /** Metrics the account defined itself. */ - customMetrics: number; - /** Readings across every user-defined metric. */ - customMetricEntries: number; - /** Persisted accepted correlation identities and dismissal decisions. */ - correlationPatterns: number; - /** Stored day curves for the cumulative metrics, across every metric. */ - intradayProfiles: number; - /** Local days whose health score was written down as it was shown. */ - healthScoreRecords: number; - /** 1 when the record's setup answers ride the file, 0 otherwise. */ - onboardingRecords: number; - /** v1.37.19 (A6-8) — the visit address book. */ - practitioners: number; - /** v1.37.19 (A6-8) — doctor visits, planned and past. */ - encounters: number; - /** v1.37.19 (A6-8) — the three encounter link tables, summed. */ - encounterLinks: number; - /** v1.37.19 (A6-8) — immunization log entries. */ - vaccinations: number; - /** v1.37.19 (A6-8) — vaccination↔document links. */ - vaccinationLinks: number; - /** v1.37.20 (#223 / iOS #68) — Vorsorge reminder cadences. */ - measurementReminders: number; - /** v1.37.20 (#223 / iOS #68) — completion-ledger rows across every reminder. */ - measurementReminderEvents: number; - /** Coach conversation threads. */ - coachConversations: number; - /** Coach turns across every thread. */ - coachMessages: number; - /** Durable facts, agreed plans and things to bring back up. */ - coachFacts: number; - coachPlans: number; - coachReminders: number; - /** Completed screener administrations. Disaster-recovery payloads only. */ - mentalHealthAssessments: number; - /** Consent records. Disaster-recovery payloads only. */ - consentReceipts: number; - /** Personal bests across every metric and sport slot. */ - personalRecords: number; - /** Badges the account has unlocked, each with the day it was earned. */ - userAchievements: number; - /** Local days with an environmental reading. */ - environmentContexts: number; - /** Declared stretches spent away from home. */ - environmentTravelLocations: number; - /** ECG strips, with their rhythm verdict and their trace. */ - ecgRecordings: number; -} - -export function summarizeBackup(payload: BackupPayload): BackupSummary { - return { - schemaVersion: payload.schemaVersion, - userId: payload.userId, - exportedAt: payload.exportedAt, - measurements: payload.measurements.length, - medications: payload.medications.length, - intakeEvents: payload.intakeEvents.length, - medicationSideEffects: payload.medications.reduce( - (sum, medication) => sum + medication.sideEffects.length, - 0, - ), - medicationEfficacyTargets: payload.medications.reduce( - (sum, medication) => sum + medication.efficacyTargets.length, - 0, - ), - medicationScheduleRevisions: payload.medications.reduce( - (sum, medication) => sum + medication.scheduleRevisions.length, - 0, - ), - moodEntries: payload.moodEntries.length, - cycles: payload.cycles.length, - cycleDayLogs: payload.cycleDayLogs.length, - nutrientDays: payload.nutrientDays.length, - labResults: payload.labResults.length, - biomarkers: payload.biomarkers.length, - illnessEpisodes: payload.illnessEpisodes.length, - illnessDayLogs: payload.illnessEpisodes.reduce( - (sum, e) => sum + e.dayLogs.length, - 0, - ), - allergies: payload.allergies.length, - familyHistory: payload.familyHistory.length, - workouts: payload.workouts.length, - documents: payload.documents.length, - // Counted from the release that carries them, so the admin's "what did I - // just restore" answer never under-counts a filed vault the way it once - // did for visits. - documentConditionLinks: payload.documentConditionLinks.length, - extractedFacts: payload.extractedFacts.length, - healthProfile: payload.healthProfile ? 1 : 0, - healthProfileFactRevisions: payload.healthProfileFacts.length, - customMetrics: payload.customMetrics.length, - customMetricEntries: payload.customMetrics.reduce( - (sum, metric) => sum + metric.entries.length, - 0, - ), - correlationPatterns: payload.correlationPatterns.length, - intradayProfiles: payload.intradayProfiles.length, - healthScoreRecords: payload.healthScoreRecords.length, - onboardingRecords: payload.onboardingRecord ? 1 : 0, - // v1.37.19 (A6-8) — the sections restored since 08-01 were written and - // restored but absent from this report, so the admin's "what did I just - // restore" answer silently under-counted a file that carried visits or - // an Impfpass. - practitioners: payload.practitioners.length, - encounters: payload.encounters.length, - encounterLinks: - payload.encounterDocumentLinks.length + - payload.encounterLabLinks.length + - payload.encounterConditionLinks.length, - vaccinations: payload.vaccinations.length, - vaccinationLinks: payload.vaccinationDocumentLinks.length, - // v1.37.20 (#223 / iOS #68) — counted from the release that carries them, - // so the admin's "what did I just restore" answer never under-counts a - // file with reminders the way it once did for visits. - measurementReminders: payload.measurementReminders.length, - measurementReminderEvents: payload.measurementReminderEvents.length, - coachConversations: payload.coachConversations.length, - coachMessages: payload.coachConversations.reduce( - (total, conversation) => total + conversation.messages.length, - 0, - ), - coachFacts: payload.coachFacts.length, - coachPlans: payload.coachPlans.length, - coachReminders: payload.coachReminders.length, - mentalHealthAssessments: payload.mentalHealthAssessments.length, - consentReceipts: payload.consentReceipts.length, - // Counted from the release that carries them, so the admin's "what did I - // just restore" answer never under-counts a file with bests, badges or an - // environmental history the way it once did for visits. - personalRecords: payload.personalRecords.length, - userAchievements: payload.userAchievements.length, - environmentContexts: payload.environmentContexts.length, - environmentTravelLocations: payload.environmentTravelLocations.length, - ecgRecordings: payload.ecgRecordings.length, - }; -} - /** * Parse a JSON blob (string or already-parsed object) against * `backupPayloadSchema`, returning a typed payload. Throws ZodError on @@ -2280,16 +2094,3 @@ export function parseBackupPayload(input: string | unknown): BackupPayload { const raw = typeof input === "string" ? JSON.parse(input) : input; return backupPayloadSchema.parse(raw); } - -/** - * The schemaVersion the system understands today. Used by the upload - * route to reject inbound files written by a *future* HealthLog instance - * — restoring them under the current code might silently drop data the - * new shape carried. - */ -export function isCompatibleSchemaVersion(version: string): boolean { - return ( - version === LEGACY_BACKUP_SCHEMA_VERSION || - version === BACKUP_SCHEMA_VERSION - ); -} diff --git a/tests/integration/backup-preview.test.ts b/tests/integration/backup-preview.test.ts index f7d984af0..81757c6c7 100644 --- a/tests/integration/backup-preview.test.ts +++ b/tests/integration/backup-preview.test.ts @@ -126,8 +126,8 @@ async function preview(id: string) { /** The counts a full read of the stored copy gives: the old preview. */ async function fullReadSummary(id: string) { - const { parseBackupPayload, summarizeBackup } = - await import("@/lib/validations/backup"); + const { parseBackupPayload } = await import("@/lib/validations/backup"); + const { summarizeBackup } = await import("@/lib/validations/backup-summary"); const opensBefore = opened.count; const payload = parseBackupPayload( JSON.parse(await readStoredBackup(getPrismaClient(), id)), diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6314593f5..045447121 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -79,7 +79,8 @@ import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids"; import { createBackupPreviewCollector } from "@/lib/export/backup-preview"; -import { parseBackupPayload, summarizeBackup } from "@/lib/validations/backup"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { summarizeBackup } from "@/lib/validations/backup-summary"; import { TWO_ENDED_MODELS, USER_COLUMN_BACKUP_CLASS,