From 12749f683943e257189b8e14767f57086d82806d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:02:14 +0200 Subject: [PATCH] refactor(backup): give each domain its own slice of the payload schema The element schemas move out of validations/backup.ts into one -backup-schema.ts per domain, beside its export and restore, with the shared primitives in backup-schema-kit.ts. validations/backup.ts now composes the slices into backupPayloadSchema in the same key order, with the same defaults and the checks that span sections, so the parsed payload, every validation message and the OpenAPI contract are unchanged. The lazy-schema guard now covers the slices too: a slice is reached only through the payload schema or another slice, never from a domain's export or restore. The external-id guard carries the payload's exemption to its slices, and mutation testing scores the slices with the payload schema. --- .../backup-schema-lazy-guard.test.ts | 65 +- .../baselines/source-file-lines.json | 5 +- .../baselines/version-stamp-comments.json | 4 +- .../external-id-stability-guard.test.ts | 22 +- src/lib/cycle/backup-schema.ts | 169 ++ .../export/account-settings-backup-schema.ts | 100 + src/lib/export/allergies-backup-schema.ts | 35 + src/lib/export/app-settings-backup-schema.ts | 61 + src/lib/export/awards-backup-schema.ts | 59 + src/lib/export/backup-schema-kit.ts | 23 + src/lib/export/coach-backup-schema.ts | 166 ++ .../export/document-filing-backup-schema.ts | 56 + src/lib/export/documents-backup-schema.ts | 66 + src/lib/export/ecg-backup-schema.ts | 44 + src/lib/export/environment-backup-schema.ts | 122 + .../export/family-history-backup-schema.ts | 23 + src/lib/export/health-score-backup-schema.ts | 34 + src/lib/export/illness-backup-schema.ts | 64 + .../export/intraday-profile-backup-schema.ts | 45 + src/lib/export/labs-backup-schema.ts | 113 + src/lib/export/life-events-backup-schema.ts | 44 + src/lib/export/measurements-backup-schema.ts | 49 + src/lib/export/medications-backup-schema.ts | 361 +++ src/lib/export/mood-backup-schema.ts | 183 ++ src/lib/export/nutrients-backup-schema.ts | 33 + src/lib/export/onboarding-backup-schema.ts | 37 + src/lib/export/profile-backup-schema.ts | 181 ++ .../export/relation-links-backup-schema.ts | 27 + src/lib/export/reminders-backup-schema.ts | 65 + src/lib/export/sensitive-backup-schema.ts | 50 + src/lib/export/symptoms-backup-schema.ts | 47 + src/lib/export/vaccinations-backup-schema.ts | 70 + src/lib/export/visits-backup-schema.ts | 76 + src/lib/export/workouts-backup-schema.ts | 35 + src/lib/validations/backup.ts | 2243 +---------------- stryker.config.mjs | 4 + stryker.floors.json | 34 +- 37 files changed, 2637 insertions(+), 2178 deletions(-) create mode 100644 src/lib/cycle/backup-schema.ts create mode 100644 src/lib/export/account-settings-backup-schema.ts create mode 100644 src/lib/export/allergies-backup-schema.ts create mode 100644 src/lib/export/app-settings-backup-schema.ts create mode 100644 src/lib/export/awards-backup-schema.ts create mode 100644 src/lib/export/backup-schema-kit.ts create mode 100644 src/lib/export/coach-backup-schema.ts create mode 100644 src/lib/export/document-filing-backup-schema.ts create mode 100644 src/lib/export/documents-backup-schema.ts create mode 100644 src/lib/export/ecg-backup-schema.ts create mode 100644 src/lib/export/environment-backup-schema.ts create mode 100644 src/lib/export/family-history-backup-schema.ts create mode 100644 src/lib/export/health-score-backup-schema.ts create mode 100644 src/lib/export/illness-backup-schema.ts create mode 100644 src/lib/export/intraday-profile-backup-schema.ts create mode 100644 src/lib/export/labs-backup-schema.ts create mode 100644 src/lib/export/life-events-backup-schema.ts create mode 100644 src/lib/export/measurements-backup-schema.ts create mode 100644 src/lib/export/medications-backup-schema.ts create mode 100644 src/lib/export/mood-backup-schema.ts create mode 100644 src/lib/export/nutrients-backup-schema.ts create mode 100644 src/lib/export/onboarding-backup-schema.ts create mode 100644 src/lib/export/profile-backup-schema.ts create mode 100644 src/lib/export/relation-links-backup-schema.ts create mode 100644 src/lib/export/reminders-backup-schema.ts create mode 100644 src/lib/export/sensitive-backup-schema.ts create mode 100644 src/lib/export/symptoms-backup-schema.ts create mode 100644 src/lib/export/vaccinations-backup-schema.ts create mode 100644 src/lib/export/visits-backup-schema.ts create mode 100644 src/lib/export/workouts-backup-schema.ts diff --git a/src/__tests__/backup-schema-lazy-guard.test.ts b/src/__tests__/backup-schema-lazy-guard.test.ts index 7edfd07fb..064fc4e74 100644 --- a/src/__tests__/backup-schema-lazy-guard.test.ts +++ b/src/__tests__/backup-schema-lazy-guard.test.ts @@ -9,6 +9,13 @@ * * The one static importer allowed is the OpenAPI registry, which only the * spec generator loads. + * + * The schema is composed from one slice per domain (`-backup-schema.ts` + * beside the domain's export and restore, the shared primitives in + * `backup-schema-kit.ts`). A slice builds its part of the 12 MB the moment it + * is evaluated, so the same rule holds for every slice: it is reached through + * `src/lib/validations/backup.ts` and from other slices, and from nothing + * else, however its domain module is used. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; @@ -37,27 +44,54 @@ const STATIC_IMPORT = /^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/validations\/backup["']/gm; const DYNAMIC_IMPORT = /import\(\s*["']@\/lib\/validations\/backup["']\s*\)/g; +/** `import … from` a schema slice or the kit, capturing what precedes it. */ +const SLICE_IMPORT = + /^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/(?:export\/[\w-]+-backup-schema|export\/backup-schema-kit|cycle\/backup-schema)["']/gm; + +/** A slice module itself, by its path under `src/`. */ +function isSlice(rel: string): boolean { + return ( + /^lib\/export\/[\w-]+-backup-schema\.ts$/.test(rel) || + rel === "lib/export/backup-schema-kit.ts" || + rel === "lib/cycle/backup-schema.ts" + ); +} + +function isTypeOnly(match: RegExpMatchArray): boolean { + return ( + match[1] !== undefined || + (/^\{[^}]*\}$/.test(match[2]) && + match[2] + .slice(1, -1) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .every((s) => s.startsWith("type "))) + ); +} + describe("the backup payload schema loads on first use", () => { const files = sourceFiles(ROOT); const staticValueImporters: string[] = []; let dynamicImports = 0; let typeImports = 0; + const sliceFiles: string[] = []; + const sliceValueImporters: string[] = []; + let slicesComposed = 0; + for (const file of files) { const rel = relative(ROOT, file); - if (rel === "lib/validations/backup.ts") continue; const text = readFileSync(file, "utf8"); + if (isSlice(rel)) sliceFiles.push(rel); + for (const match of text.matchAll(SLICE_IMPORT)) { + if (isTypeOnly(match)) continue; + if (rel === "lib/validations/backup.ts") slicesComposed++; + else if (!isSlice(rel)) sliceValueImporters.push(rel); + } + if (rel === "lib/validations/backup.ts") continue; 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++; + if (isTypeOnly(match)) typeImports++; else if (!ALLOWED_STATIC.has(rel)) staticValueImporters.push(rel); } dynamicImports += [...text.matchAll(DYNAMIC_IMPORT)].length; @@ -67,6 +101,15 @@ describe("the backup payload schema loads on first use", () => { expect(staticValueImporters).toEqual([]); }); + it("reaches no schema slice except through the payload schema", () => { + expect(sliceValueImporters).toEqual([]); + }); + + it("finds the slices and their composition (no vacuous pass)", () => { + expect(sliceFiles.length).toBeGreaterThanOrEqual(29); + expect(slicesComposed).toBeGreaterThanOrEqual(28); + }); + 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); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 21e023bf0..2f782b501 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 126, - "total": 150668, + "count": 125, + "total": 148250, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -123,7 +123,6 @@ "lib/sharing/grants.ts": 1046, "lib/support/report.ts": 905, "lib/telegram-webhook-handlers.ts": 1384, - "lib/validations/backup.ts": 2418, "lib/validations/inbound-documents.ts": 1130, "lib/whoop/client.ts": 917, "lib/withings/sync-sleep.ts": 903, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index deed4fffb..08685cf83 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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": 2959, - "total": 10559, + "total": 10525, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2884,7 +2884,7 @@ "lib/validations/ai-usage.ts": 1, "lib/validations/auth.ts": 5, "lib/validations/backup-summary.ts": 13, - "lib/validations/backup.ts": 40, + "lib/validations/backup.ts": 6, "lib/validations/biomarker-merge.ts": 1, "lib/validations/biomarkers.ts": 3, "lib/validations/claim-sso.ts": 1, diff --git a/src/__tests__/external-id-stability-guard.test.ts b/src/__tests__/external-id-stability-guard.test.ts index c21c1ead7..0cffe0109 100644 --- a/src/__tests__/external-id-stability-guard.test.ts +++ b/src/__tests__/external-id-stability-guard.test.ts @@ -90,6 +90,21 @@ const EXEMPT: Record = { "restore payload is a server-written backup; refusing a stored id would block a restore", }; +/** + * The backup payload schema is composed from one slice per domain, each a + * part of the same server-written file, so every slice carries the payload's + * exemption and its reason. + */ +const BACKUP_SCHEMA_SLICE = + /^lib\/(?:export\/[\w-]+-backup-schema|cycle\/backup-schema)\.ts$/; + +function exemptionFor(rel: string): string | undefined { + if (rel in EXEMPT) return EXEMPT[rel]; + return BACKUP_SCHEMA_SLICE.test(rel) + ? EXEMPT["lib/validations/backup.ts"] + : undefined; +} + /** * Trees where a stored identity is derived from input that never passes * through a Zod request schema — an uploaded file's row, or an MCP host's @@ -174,8 +189,9 @@ describe("every client-supplied external id routes through the stability floor", it.each(declaringFiles())( "%s either applies the field check or names the per-entry checker", (rel) => { - if (rel in EXEMPT) { - expect(EXEMPT[rel].length).toBeGreaterThan(20); + const exemption = exemptionFor(rel); + if (exemption !== undefined) { + expect(exemption.length).toBeGreaterThan(20); return; } @@ -197,7 +213,7 @@ describe("every client-supplied external id routes through the stability floor", it.each(declaringFiles())( "%s — every per-entry marker points at a file that really checks per entry", (rel) => { - if (rel in EXEMPT) return; + if (exemptionFor(rel) !== undefined) return; const source = read(rel); MARKER.lastIndex = 0; const targets = [...source.matchAll(new RegExp(MARKER, "g"))].map( diff --git a/src/lib/cycle/backup-schema.ts b/src/lib/cycle/backup-schema.ts new file mode 100644 index 000000000..4dae4e9ca --- /dev/null +++ b/src/lib/cycle/backup-schema.ts @@ -0,0 +1,169 @@ +/** + * The cycle slice of the backup payload schema. + * + * Part of the cycle backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, + ContraceptiveKind, + CycleTrackingGoal, + FlowLevel, + HomeTestResult, + MeasurementSource, + OvulationTest, + SecondarySymptom, +} from "@/generated/prisma/enums"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +/* ── v1.15.0 cycle-tracking backup shapes ──────────────────────────── */ + +/** + * One menstrual-cycle span. `startDate` is the natural per-user key + * (matching the `(userId, startDate)` unique), so a restore upserts on it. + * Predicted (forecast) rows are excluded from the backup — only observed + * history round-trips. + */ +export const cycleSpanSchema = z + .object({ + id: z.string().min(1).optional(), + startDate: z.string().min(1), + endDate: z.string().nullable().optional(), + periodEndDate: z.string().nullable().optional(), + lengthDays: z.number().int().nullable().optional(), + ovulationDate: z.string().nullable().optional(), + ovulationConfirmed: z.boolean().optional(), + isPredicted: z.boolean().optional(), + tz: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + absorbedIntoId: z.string().min(1).nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One cycle day-log. `notesEncrypted` is carried as the AES-256-GCM + * ciphertext envelope verbatim — the backup never decrypts it, so the + * owner's free-text note round-trips encrypted (and a wrong-surface leak is + * impossible). `symptomKeys` carries the seeded catalogue keys so the + * restore can re-link without exporting internal join ids, and + * `symptomSeverities` carries how hard each of them hit. + */ +/** + * The intensity recorded against one of a day's symptoms. + * + * A sparse annotation over `symptomKeys`, not a replacement for it: the keys + * still say which symptoms the day had, this says how hard the ones that were + * rated hit. Only rated links appear, so a missing entry means the person never + * put a number on it. `severity` is left as a plain integer here on purpose — + * the 1-4 range is checked when the row is written, so a file carrying a value + * outside it loses that one intensity instead of failing to restore at all. + */ +export const cycleSymptomSeveritySchema = z + .object({ + key: z.string().min(1), + severity: z.number().int().nullable().optional(), + }) + .passthrough(); +/** The sensitive-category envelope's five fields, as a portable file carries them. */ +export const cycleSensitiveFieldsSchema = z.object({ + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: z.enum(HomeTestResult).nullable().optional(), + progesteroneTest: z.enum(HomeTestResult).nullable().optional(), + contraceptive: z.enum(ContraceptiveKind).nullable().optional(), +}); +export const cycleDayLogSchema = z + .object({ + id: z.string().min(1).optional(), + date: z.string().min(1), + cycleId: z.string().nullable().optional(), + flow: z.enum(FlowLevel).nullable().optional(), + intermenstrualBleeding: z.boolean().optional(), + basalBodyTempC: z.number().nullable().optional(), + temperatureExcluded: z.boolean().optional(), + ovulationTest: z.enum(OvulationTest).nullable().optional(), + cervicalMucus: z.enum(CervicalMucus).nullable().optional(), + cervixPosition: z.enum(CervixPosition).nullable().optional(), + cervixFirmness: z.enum(CervixFirmness).nullable().optional(), + cervixOpening: z.enum(CervixOpening).nullable().optional(), + sexualActivity: z.boolean().optional(), + protectedSex: z.boolean().nullable().optional(), + pregnancyTest: z.enum(HomeTestResult).nullable().optional(), + progesteroneTest: z.enum(HomeTestResult).nullable().optional(), + contraceptive: z.enum(ContraceptiveKind).nullable().optional(), + // Free text travels like every other note: a disaster-recovery file + // carries the ciphertext (`*Encrypted`), a portable file the readable + // value (`note`, `sensitive`), which the restore seals under the + // receiving host's key. A portable file written before v1.40 carries the + // ciphertext too; the restore keeps only what this host can open. + sensitiveEncrypted: z.string().nullable().optional(), + notesEncrypted: z.string().nullable().optional(), + note: z.string().nullable().optional(), + // The five intent fields the sensitive-category envelope holds, or the + // unreadable marker when the writing host could not open the envelope. + sensitive: z + .union([cycleSensitiveFieldsSchema, z.string()]) + .nullable() + .optional(), + source: z.enum(MeasurementSource).optional(), + externalId: z.string().nullable().optional(), + tz: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + symptomKeys: z.array(z.string()).default([]), + symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), + }) + .passthrough(); +/** Cycle-tracking preferences (one row per user). */ +export const cycleProfileSchema = z + .object({ + id: z.string().min(1).optional(), + goal: z.enum(CycleTrackingGoal).optional(), + cycleTrackingEnabled: z.boolean().nullable().optional(), + typicalCycleLength: z.number().int().nullable().optional(), + typicalPeriodLength: z.number().int().nullable().optional(), + lutealPhaseLength: z.number().int().nullable().optional(), + secondarySymptom: z.enum(SecondarySymptom).nullable().optional(), + predictionEnabled: z.boolean().optional(), + rawChartMode: z.boolean().optional(), + discreetNotifications: z.boolean().optional(), + sensitiveCategoryEncryption: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * A symptom the account created, as opposed to the seeded catalogue. + * + * Carried because a day-log's symptom link resolves by key: without the + * definition the key resolves to nothing on the restoring instance, and the + * link is lost. The seeded rows are deliberately NOT carried — every instance + * already has them, and shipping them would mean a restore could rewrite + * another instance's reference data. + */ +export const customCycleSymptomSchema = z + .object({ + id: z.string().min(1).optional(), + key: z.string().min(1), + labelKey: z.string().min(1), + categoryId: z.string().min(1), + icon: z.string().nullable().optional(), + sortOrder: z.number().int().default(0), + isActive: z.boolean().default(true), + // Same reasoning as the mood tag's: the user's own words live here. + // Ciphertext on a disaster-recovery file, readable `label` on a portable + // one (the day-log note's split). + labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), + }) + .passthrough(); diff --git a/src/lib/export/account-settings-backup-schema.ts b/src/lib/export/account-settings-backup-schema.ts new file mode 100644 index 000000000..ea418f938 --- /dev/null +++ b/src/lib/export/account-settings-backup-schema.ts @@ -0,0 +1,100 @@ +/** + * The account settings slice of the backup payload schema. + * + * Part of the account settings backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * The account's own settings: the `User` columns `USER_COLUMN_BACKUP_CLASS` + * calls `SETTING`, by column name. Never a credential, an identity column or + * the host's own bookkeeping on the row. + * + * Every field is optional: a column a file does not carry leaves the account + * as it is, so a file from a release with fewer setting columns restores + * what it has. Types are checked here; whether a value is one this host will + * write (a known language, an allowed AI endpoint, an image the upload would + * accept) is decided by the restore, which drops and names what it refuses + * rather than refusing the whole account over one preference. + * + * The insurance number arrives as `insuranceNumberEncrypted` (ciphertext) in + * a disaster-recovery file and as `insuranceNumber` (readable) in a portable + * one, the split every other sealed column uses. + */ +export const accountSettingsBackupSchema = z + .object({ + heightCm: z.number().nullable().optional(), + dateOfBirth: isoDateTime.nullable().optional(), + gender: z.string().nullable().optional(), + hasDiabetes: z.boolean().optional(), + displayName: z.string().nullable().optional(), + fullName: z.string().nullable().optional(), + insurerName: z.string().nullable().optional(), + insuranceNumber: z.string().nullable().optional(), + insuranceNumberEncrypted: z.string().nullable().optional(), + insurerIkNumber: z.string().nullable().optional(), + lastReportPracticeName: z.string().nullable().optional(), + avatarBytes: base64BytesSchema.nullable().optional(), + avatarContentType: z.string().nullable().optional(), + avatarUpdatedAt: isoDateTime.nullable().optional(), + homeLat: z.number().nullable().optional(), + homeLon: z.number().nullable().optional(), + homeLabel: z.string().nullable().optional(), + homeTimezone: z.string().nullable().optional(), + homeSince: isoDateTime.nullable().optional(), + // The sealed home location rides a disaster-recovery file + // as ciphertext; a portable file carries the readable home above. + homeLocationEncrypted: base64BytesSchema.nullable().optional(), + environmentAirQualityEnabled: z.boolean().optional(), + timezone: z.string().optional(), + locale: z.string().nullable().optional(), + unitPreference: z.string().nullable().optional(), + glucoseUnit: z.string().nullable().optional(), + timeFormat: z.string().optional(), + dateFormat: z.string().optional(), + modulePreferencesJson: z.unknown().optional(), + thresholdsJson: z.unknown().optional(), + healthScoreConfigJson: z.unknown().optional(), + sourcePriorityJson: z.unknown().optional(), + dashboardWidgetsJson: z.unknown().optional(), + insightsLayoutJson: z.unknown().optional(), + medicationListLayoutJson: z.unknown().optional(), + moodTagLayoutJson: z.unknown().optional(), + documentsLayoutJson: z.unknown().optional(), + moduleListLayoutJson: z.unknown().optional(), + previewFeaturesJson: z.unknown().optional(), + activityGoalsJson: z.unknown().optional(), + reportSelectionJson: z.unknown().optional(), + globalExcludedInjectionSites: z.array(z.string()).optional(), + healthKitConfigJson: z.unknown().optional(), + notificationPrefs: z.unknown().optional(), + moodReminderEnabled: z.boolean().optional(), + aiProvider: z.string().nullable().optional(), + aiModel: z.string().nullable().optional(), + aiBaseUrl: z.string().nullable().optional(), + aiCompatBaseUrl: z.string().nullable().optional(), + aiCompatModel: z.string().nullable().optional(), + aiProviderChain: z.unknown().optional(), + aiResponseTimeoutSeconds: z.number().int().nullable().optional(), + useCentralCodex: z.boolean().optional(), + insightsPrivacyMode: z.string().optional(), + insightsExcludeMetrics: z.array(z.string()).optional(), + disableCoach: z.boolean().optional(), + // The account's AI usage level; the codec checks the value. + aiUsageLevel: z.string().nullable().optional(), + coachPrefsJson: z.unknown().optional(), + documentsAutoAiRead: z.boolean().optional(), + labsLocalOcrEnabled: z.boolean().optional(), + storeWorkoutRoutes: z.boolean().optional(), + onboardingCompletedAt: isoDateTime.nullable().optional(), + onboardingTourCompleted: z.boolean().optional(), + onboardingTourProgressJson: z.unknown().optional(), + disclaimerAcknowledgedAt: isoDateTime.nullable().optional(), + disclaimerAcknowledgedVersion: z.string().nullable().optional(), + passkeyUpgradeNudgeDismissed: z.boolean().optional(), + }) + .passthrough(); diff --git a/src/lib/export/allergies-backup-schema.ts b/src/lib/export/allergies-backup-schema.ts new file mode 100644 index 000000000..67cb55942 --- /dev/null +++ b/src/lib/export/allergies-backup-schema.ts @@ -0,0 +1,35 @@ +/** + * The allergies slice of the backup payload schema. + * + * Part of the allergies backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + AllergyCategory, + AllergySeverity, + AllergyStatus, + AllergyType, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const allergyBackupSchema = z + .object({ + id: z.string().min(1), + substance: z.string().min(1), + category: z.enum(AllergyCategory), + type: z.enum(AllergyType), + severity: z.enum(AllergySeverity).nullable().optional(), + status: z.enum(AllergyStatus), + onsetAt: isoDateTime.nullable().optional(), + reaction: z.string().nullable().optional(), + note: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + reactionEncrypted: base64BytesSchema.nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/app-settings-backup-schema.ts b/src/lib/export/app-settings-backup-schema.ts new file mode 100644 index 000000000..84e81429f --- /dev/null +++ b/src/lib/export/app-settings-backup-schema.ts @@ -0,0 +1,61 @@ +/** + * The app settings slice of the backup payload schema. + * + * Part of the app settings backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const appSettingsBackupSchema = z + .object({ + id: z.string().min(1), + registrationEnabled: z.boolean(), + mfaRequired: z.boolean(), + defaultLocale: z.string(), + telegramGlobal: z.boolean(), + ntfyGlobal: z.boolean(), + webPushGlobal: z.boolean(), + webPushVapidPublicKey: z.string().nullable(), + webPushVapidPrivateKeyEncrypted: z.string().nullable(), + webPushVapidSubject: z.string().nullable(), + apiGlobal: z.boolean(), + umamiEnabled: z.boolean(), + umamiScriptUrl: z.string().nullable(), + umamiWebsiteId: z.string().nullable(), + glitchtipEnabled: z.boolean(), + glitchtipDsn: z.string().nullable(), + glitchtipEnvironment: z.string().nullable(), + reminderLateMinutes: z.number().int(), + reminderMissedMinutes: z.number().int(), + adminAiKeyEncrypted: z.string().nullable(), + adminAiModel: z.string(), + adminAiBaseUrl: z.string(), + adminCodexAccessTokenEncrypted: z.string().nullable(), + adminCodexRefreshTokenEncrypted: z.string().nullable(), + adminCodexAccountIdEncrypted: z.string().nullable(), + adminCodexTokenExpiresAt: isoDateTime.nullable(), + adminCodexConnectedAt: isoDateTime.nullable(), + adminCodexConnectionStatus: z.string(), + adminAiInsightsFeedbackSummary: z.unknown().nullable(), + defaultUserTimezone: z.string().nullable(), + assistantEnabled: z.boolean(), + assistantCoachEnabled: z.boolean(), + assistantBriefingEnabled: z.boolean(), + assistantInsightStatusEnabled: z.boolean(), + // Optional: a backup written before the switch existed carries none, and + // restores it at its default. A backup from before the Correlations switch + // was retired still carries that key; `passthrough` keeps it harmless and + // the restore writes nothing for it. + assistantDocumentAiEnabled: z.boolean().optional(), + // Optional for the same reason. + aiReasoningEnabled: z.boolean().optional(), + aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), + moduleAvailabilityJson: z.unknown().nullable(), + documentMaxFileBytes: z.number().int(), + documentQuotaBytes: z.string().regex(/^\d+$/), + }) + .passthrough(); diff --git a/src/lib/export/awards-backup-schema.ts b/src/lib/export/awards-backup-schema.ts new file mode 100644 index 000000000..631867083 --- /dev/null +++ b/src/lib/export/awards-backup-schema.ts @@ -0,0 +1,59 @@ +/** + * The awards slice of the backup payload schema. + * + * Part of the awards backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + MeasurementSource, + MeasurementType, + PersonalRecordDirection, +} from "@/generated/prisma/enums"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * One personal best. + * + * `direction` is required rather than defaulted: it says whether higher or + * lower wins, the read path orders by it, and a best time defaulted to MAX + * would present the account's worst time as its record. `sourceMeasurementId` + * is the provenance pointer the restore resolves, and a real foreign key in the + * database, whatever `prisma/schema.prisma` says, so the restore nulls what it + * cannot resolve rather than letting Postgres refuse the whole file. + */ +export const personalRecordBackupSchema = z + .object({ + metricType: z.enum(MeasurementType), + metricSlot: z.string().nullable().optional(), + direction: z.enum(PersonalRecordDirection), + value: z.number(), + unit: z.string().min(1), + achievedAt: isoDateTime, + sourceMeasurementId: z.string().nullable().optional(), + source: z.enum(MeasurementSource).optional(), + externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One unlocked badge. + * + * `achievementId` is a free string on purpose: it names a definition in the + * code catalogue, which drifts between the release that wrote the file and the + * one that reads it, and a row is evidence that a person earned something on a + * day rather than a claim about what this build ships. + * + * `unlockedAt` is REQUIRED, unlike almost every other stamp in this file. It + * is the field that cannot be recovered from anywhere else, and a file that + * does not state it must fail rather than restore every badge to today. + */ +export const userAchievementBackupSchema = z + .object({ + achievementId: z.string().min(1), + unlockedAt: isoDateTime, + createdAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/backup-schema-kit.ts b/src/lib/export/backup-schema-kit.ts new file mode 100644 index 000000000..66c19cb69 --- /dev/null +++ b/src/lib/export/backup-schema-kit.ts @@ -0,0 +1,23 @@ +/** + * The Zod primitives every backup schema slice shares: an ISO-8601 instant and + * base64-encoded stored ciphertext. + * + * Loaded only through `src/lib/validations/backup.ts`, which builds the + * payload schema on first use; see `backup-schema-lazy-guard.test.ts`. + */ +import { z } from "zod/v4"; + +export const isoDateTime = z + .string() + .min(1) + .refine((s) => !Number.isNaN(Date.parse(s)), { + message: "Expected ISO-8601 date-time string", + }); + +export const base64BytesSchema = z + .string() + .min(1) + .refine( + (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), + { message: "Expected base64-encoded encrypted bytes" }, + ); diff --git a/src/lib/export/coach-backup-schema.ts b/src/lib/export/coach-backup-schema.ts new file mode 100644 index 000000000..b4cf4ef6c --- /dev/null +++ b/src/lib/export/coach-backup-schema.ts @@ -0,0 +1,166 @@ +/** + * The coach slice of the backup payload schema. + * + * Part of the coach backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { TRAIL_MAX_BYTES } from "@/lib/ai/coach/activity/contract"; +import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * A turn's stored tables, bounded at restore. The live path never stores more + * than `RESULTS_MAX_BYTES` of table JSON per turn (`fitResultsToStorage`), so + * a file this release writes always fits; a larger value is a damaged or + * crafted file, refused with the rest of it like any other malformed field + * rather than sealed into the database unbounded. The sealed form is the + * ciphertext of at most that much JSON, base64-encoded in the file: about + * 1.8 times the plaintext, so twice the plaintext bound caps it. + */ +export const COACH_RESULTS_SEALED_MAX_CHARS = 2 * RESULTS_MAX_BYTES; +export const coachResultsJsonSchema = z + .string() + .refine( + (value) => new TextEncoder().encode(value).byteLength <= RESULTS_MAX_BYTES, + { message: `Coach result tables exceed ${RESULTS_MAX_BYTES} bytes` }, + ); +export const coachResultsSealedSchema = base64BytesSchema.refine( + (value) => value.length <= COACH_RESULTS_SEALED_MAX_CHARS, + { message: "Coach result tables exceed the stored size" }, +); +/** The trail's model text, bounded at restore like the tables above. */ +export const COACH_TRAIL_SEALED_MAX_CHARS = 2 * TRAIL_MAX_BYTES; +export const coachTrailJsonSchema = z + .string() + .refine( + (value) => new TextEncoder().encode(value).byteLength <= TRAIL_MAX_BYTES, + { message: `Coach trail exceeds ${TRAIL_MAX_BYTES} bytes` }, + ); +export const coachTrailSealedSchema = base64BytesSchema.refine( + (value) => value.length <= COACH_TRAIL_SEALED_MAX_CHARS, + { message: "Coach trail exceeds the stored size" }, +); +/** + * One Coach turn. + * + * `contentEncrypted` and `content` are the two ends of the same contract, so + * both are optional here and exactly one arrives: a disaster-recovery file + * carries the ciphertext, a portable file carries the prose. Requiring either + * would refuse half the valid files; requiring both would refuse all of them. + * The restore picks whichever it was handed. + */ +export const coachMessageBackupSchema = z + .object({ + id: z.string().min(1), + role: z.string().min(1), + contentEncrypted: base64BytesSchema.optional(), + content: z.string().optional(), + metricSourceJson: z.string().nullable().optional(), + providerType: z.string().nullable().optional(), + promptVersion: z.string().nullable().optional(), + tokensUsed: z.number().int().nullable().optional(), + model: z.string().nullable().optional(), + // The turn's tables: ciphertext on a disaster-recovery file, + // readable JSON on a portable one, absent on every file written before. + resultsEncrypted: coachResultsSealedSchema.nullable().optional(), + resultsJson: coachResultsJsonSchema.nullable().optional(), + // The trail's model text, the same two ends as the tables. + trailEncrypted: coachTrailSealedSchema.nullable().optional(), + trailJson: coachTrailJsonSchema.nullable().optional(), + createdAt: isoDateTime, + }) + .passthrough(); +export const coachConversationDocumentBackupSchema = z + .object({ + documentId: z.string().min(1), + addedAt: isoDateTime, + }) + .passthrough(); +export const coachConversationBackupSchema = z + .object({ + id: z.string().min(1), + // A portable file (and every file written before) carries the + // readable title; a disaster-recovery file carries only the ciphertext. + title: z.string().optional(), + titleEncrypted: base64BytesSchema.optional(), + // NOT optional with a default. The fence is permanent and a file that does + // not state it is a file that cannot be trusted to re-fence the + // conversation, so the absence has to be visible rather than defaulted to + // the permissive value. + documentScoped: z.boolean(), + summaryEncrypted: base64BytesSchema.nullable().optional(), + summary: z.string().nullable().optional(), + summaryUpdatedAt: isoDateTime.nullable().optional(), + summaryTurnCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + messages: z.array(coachMessageBackupSchema).default([]), + attachments: z.array(coachConversationDocumentBackupSchema).default([]), + }) + .passthrough(); +/** + * The Coach's memory. `deletedAt` is optional rather than nullable-required: + * a portable file omits tombstoned rows entirely and never states the field, + * while a disaster-recovery file always does. + */ +export const coachFactBackupSchema = z + .object({ + id: z.string().min(1), + factEncrypted: base64BytesSchema.optional(), + fact: z.string().optional(), + category: z.string().min(1), + confidence: z.number().int().min(0).max(100).optional(), + sourceConversationId: z.string().nullable().optional(), + // Absent on every file written before; restored at the column + // defaults then. + source: z.string().min(1).max(32).optional(), + lastUsedAt: isoDateTime.nullable().optional(), + sourceMessageId: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const coachPlanBackupSchema = z + .object({ + id: z.string().min(1), + metric: z.string().min(1), + ifCueEncrypted: base64BytesSchema.optional(), + ifCue: z.string().optional(), + thenActionEncrypted: base64BytesSchema.optional(), + thenAction: z.string().optional(), + targetEncrypted: base64BytesSchema.nullable().optional(), + target: z.string().nullable().optional(), + outcomeEncrypted: base64BytesSchema.nullable().optional(), + outcome: z.string().nullable().optional(), + status: z.string().optional(), + reviewDate: isoDateTime.nullable().optional(), + sourceConversationId: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const coachReminderBackupSchema = z + .object({ + id: z.string().min(1), + noteEncrypted: base64BytesSchema.optional(), + note: z.string().optional(), + metric: z.string().nullable().optional(), + relatedPlanId: z.string().nullable().optional(), + triggerKind: z.string().optional(), + dueAt: isoDateTime.nullable().optional(), + contextCue: z.string().nullable().optional(), + status: z.string().optional(), + source: z.string().min(1), + sourceConversationId: z.string().nullable().optional(), + lastSurfacedAt: isoDateTime.nullable().optional(), + surfaceCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); diff --git a/src/lib/export/document-filing-backup-schema.ts b/src/lib/export/document-filing-backup-schema.ts new file mode 100644 index 000000000..fc4f87f7b --- /dev/null +++ b/src/lib/export/document-filing-backup-schema.ts @@ -0,0 +1,56 @@ +/** + * The document filing slice of the backup payload schema. + * + * Part of the document filing backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + ExtractedFactStatus, + ExtractedFactType, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** One document filed against one condition episode. */ +export const documentConditionLinkBackupSchema = z + .object({ + documentId: z.string().min(1), + episodeId: z.string().min(1), + createdAt: isoDateTime, + }) + .passthrough(); +/** + * One fact the extraction pass staged against a document. + * + * `status`, `confidence` and `needsReview` are REQUIRED rather than defaulted, + * unlike most optional-looking fields in this file. Each has a schema default + * that means "nobody has looked at this yet", and the confirm endpoint acts on + * a PENDING fact by committing it into the structured store — so a file that + * does not state the review decision must fail to parse rather than have one + * invented for it and let an already-committed reading be approved twice. + * + * `dataEncrypted` / `data` and their provenance siblings are the two ends of + * the same contract, so all four are optional and exactly one pair arrives: a + * disaster-recovery file carries the ciphertext, a portable file carries the + * decrypted JSON. + */ +export const extractedFactBackupSchema = z + .object({ + id: z.string().min(1), + documentId: z.string().min(1), + factType: z.enum(ExtractedFactType), + status: z.enum(ExtractedFactStatus), + confidence: z.number(), + needsReview: z.boolean(), + committedRecordId: z.string().nullable().optional(), + committedRecordType: z.string().nullable().optional(), + dataEncrypted: base64BytesSchema.optional(), + provenanceEncrypted: base64BytesSchema.optional(), + data: z.json().optional(), + provenance: z.json().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); diff --git a/src/lib/export/documents-backup-schema.ts b/src/lib/export/documents-backup-schema.ts new file mode 100644 index 000000000..5b33adb2b --- /dev/null +++ b/src/lib/export/documents-backup-schema.ts @@ -0,0 +1,66 @@ +/** + * The documents slice of the backup payload schema. + * + * Part of the documents backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + DocumentSummaryState, + InboundDocumentKind, + InboundDocumentStatus, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; +import { + DOCUMENT_SOURCE_ID_MAX, + DOCUMENT_SOURCE_INSTANCE_MAX, + DOCUMENT_SOURCE_SYSTEMS, +} from "@/lib/validations/inbound-documents"; + +export const documentBackupSchema = z + .object({ + id: z.string().min(1), + kind: z.enum(InboundDocumentKind), + title: z.string().nullable().optional(), + filename: z.string().nullable().optional(), + mimeType: z.string().min(1), + byteSize: z.number().int(), + status: z.enum(InboundDocumentStatus), + reportDate: z.string().nullable().optional(), + documentDate: z.string().nullable().optional(), + contentEncrypted: base64BytesSchema.optional(), + contentSha256: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable() + .optional(), + contentCodec: z.string().min(1).optional(), + providerType: z.string().nullable().optional(), + errorReason: z.string().nullable().optional(), + summaryEncrypted: base64BytesSchema.nullable().optional(), + summaryGeneratedAt: isoDateTime.nullable().optional(), + summaryState: z.enum(DocumentSummaryState).optional(), + // Refs #776 — the index-attempt record (canonical DR payloads only). + // Free string on purpose: a future outcome value must not fail a restore. + lastIndexAttemptAt: isoDateTime.nullable().optional(), + lastIndexOutcome: z.string().nullable().optional(), + // Import provenance. Closed like the upload field, so a + // restore cannot write a system the detail sheet has no name for. + sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).nullable().optional(), + sourceId: z.string().max(DOCUMENT_SOURCE_ID_MAX).nullable().optional(), + // The source instance (an origin). Length-bounded here and + // normalised on restore; a value that is not an origin restores as null + // (matches any instance) rather than failing the restore. + sourceInstance: z + .string() + .max(DOCUMENT_SOURCE_INSTANCE_MAX) + .nullable() + .optional(), + aiReadDeferred: z.boolean().optional(), + summary: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/ecg-backup-schema.ts b/src/lib/export/ecg-backup-schema.ts new file mode 100644 index 000000000..28421f05b --- /dev/null +++ b/src/lib/export/ecg-backup-schema.ts @@ -0,0 +1,44 @@ +/** + * The ecg slice of the backup payload schema. + * + * Part of the ecg backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + MeasurementSource, + RhythmClassification, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * One ECG strip. + * + * `waveformEncrypted` and `waveform` are the two ends of the same contract, so + * both are optional here and exactly one arrives: a disaster-recovery file + * carries the ciphertext, a portable file carries the micro-volt samples. + * Requiring either would refuse half the valid files. `samplingFrequency` and + * `sampleCount` stay required in both, because without them the samples are a + * list of numbers with no time axis. + */ +export const ecgRecordingBackupSchema = z + .object({ + id: z.string().min(1).optional(), + source: z.enum(MeasurementSource), + externalRecordingId: z.string().min(1), + recordedAt: isoDateTime, + waveformEncrypted: base64BytesSchema.optional(), + waveform: z.array(z.number()).optional(), + samplingFrequency: z.number().int(), + sampleCount: z.number().int(), + durationSeconds: z.number().nullable().optional(), + lead: z.string().nullable().optional(), + averageHeartRate: z.number().int().nullable().optional(), + rhythmClassification: z.enum(RhythmClassification).nullable().optional(), + measurementId: z.string().min(1).nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); diff --git a/src/lib/export/environment-backup-schema.ts b/src/lib/export/environment-backup-schema.ts new file mode 100644 index 000000000..d8eebf1cd --- /dev/null +++ b/src/lib/export/environment-backup-schema.ts @@ -0,0 +1,122 @@ +/** + * The environment slice of the backup payload schema. + * + * Part of the environment backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { EnvironmentLocationSource } from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** `YYYY-MM-DD`, the day key both environment tables are addressed by. */ +export const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { + message: "Expected a YYYY-MM-DD day key", +}); +/** + * One day's environmental reading. + * + * `source` is required: it is the only record that the day resolved somewhere + * other than the home city, and a defaulted value would re-attribute a trip to + * home. Every weather column is optional, because the feed is allowed to be partial + * and a file written before a column existed says nothing about it. + */ +export const environmentContextBackupSchema = z + .object({ + date: environmentDayKey, + // Nullable since v1.42 (migration 0382): once the encryption backfill has + // run, the location travels sealed in `locationEncrypted` instead. + lat: z.number().nullable(), + lon: z.number().nullable(), + locationLabel: z.string().nullable(), + locationEncrypted: base64BytesSchema.nullable().optional(), + source: z.enum(EnvironmentLocationSource), + tempMin: z.number().nullable().optional(), + tempMax: z.number().nullable().optional(), + tempMean: z.number().nullable().optional(), + apparentMean: z.number().nullable().optional(), + sunshineSec: z.number().int().nullable().optional(), + daylightSec: z.number().int().nullable().optional(), + precipSum: z.number().nullable().optional(), + pressureMean: z.number().nullable().optional(), + pressureDelta: z.number().nullable().optional(), + humidityMean: z.number().nullable().optional(), + cloudMean: z.number().nullable().optional(), + weatherCode: z.number().int().nullable().optional(), + // Air quality, pollen and UV. Optional throughout. + apparentMax: z.number().nullable().optional(), + pm25Mean: z.number().nullable().optional(), + pm25Max: z.number().nullable().optional(), + pm10Mean: z.number().nullable().optional(), + no2Mean: z.number().nullable().optional(), + so2Mean: z.number().nullable().optional(), + coMean: z.number().nullable().optional(), + o3Max8h: z.number().nullable().optional(), + eaqiMax: z.number().nullable().optional(), + usaqiMax: z.number().nullable().optional(), + uvIndexMax: z.number().nullable().optional(), + dustMax: z.number().nullable().optional(), + aodMax: z.number().nullable().optional(), + pollenAlderMax: z.number().nullable().optional(), + pollenBirchMax: z.number().nullable().optional(), + pollenGrassMax: z.number().nullable().optional(), + pollenMugwortMax: z.number().nullable().optional(), + pollenOliveMax: z.number().nullable().optional(), + pollenRagweedMax: z.number().nullable().optional(), + aqDomain: z.string().nullable().optional(), + aqHours: z.number().int().nullable().optional(), + aqFetchedAt: isoDateTime.nullable().optional(), + fetchedAt: isoDateTime.optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One declared stretch spent away from home. Both bounds are day keys rather + * than instants: the resolver compares them lexicographically against other + * day keys, and routing one through a `Date` is how a day key loses a day. + */ +export const environmentTravelLocationBackupSchema = z + .object({ + startDate: environmentDayKey, + endDate: environmentDayKey, + // Nullable since v1.42 (migration 0382), as above. + lat: z.number().nullable(), + lon: z.number().nullable(), + label: z.string().nullable(), + locationEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One local day of Dawarich movement values. Daily values only; the + * country rides readable (portable) or sealed (disaster recovery). + */ +export const dawarichDayBackupSchema = z + .object({ + date: environmentDayKey, + totalDistanceM: z.number().int().nonnegative().nullable().optional(), + drivingDistanceM: z.number().int().nonnegative().nullable().optional(), + trainDistanceM: z.number().int().nonnegative().nullable().optional(), + placesVisited: z.number().int().nonnegative().nullable().optional(), + awayFromHomeMinutes: z + .number() + .int() + .min(0) + .max(25 * 60) + .nullable() + .optional(), + countryCode: z + .string() + .regex(/^[A-Z]{2}$/) + .nullable() + .optional(), + countryCodeEncrypted: base64BytesSchema.nullable().optional(), + isTravelDay: z.boolean().nullable().optional(), + fetchedAt: isoDateTime.optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/family-history-backup-schema.ts b/src/lib/export/family-history-backup-schema.ts new file mode 100644 index 000000000..2ee8f32ff --- /dev/null +++ b/src/lib/export/family-history-backup-schema.ts @@ -0,0 +1,23 @@ +/** + * The family history slice of the backup payload schema. + * + * Part of the family history backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { FamilyRelationship } from "@/generated/prisma/enums"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const familyHistoryBackupSchema = z + .object({ + id: z.string().min(1), + relationship: z.enum(FamilyRelationship), + condition: z.string().min(1), + ageAtOnset: z.number().int().nullable().optional(), + note: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/health-score-backup-schema.ts b/src/lib/export/health-score-backup-schema.ts new file mode 100644 index 000000000..e90135c42 --- /dev/null +++ b/src/lib/export/health-score-backup-schema.ts @@ -0,0 +1,34 @@ +/** + * The health score slice of the backup payload schema. + * + * Part of the health score backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const healthScoreRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + // The zone the day was cut on. An account that moved zones has days of + // different lengths behind it, and a restore that guessed would make them + // look comparable. + timezone: z.string().min(1), + composite: z.number().int().min(0).max(100), + // The same closed set the column's CHECK constraint carries. A band the + // schema let through and the database refused would fail the whole + // restore transaction on one bad row. + band: z.enum(["green", "yellow", "red"]), + scoreVersion: z.number().int(), + composition: z.array(z.string().min(1)).min(1), + pillarScores: z.record(z.string(), z.number()), + inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/), + configVersion: z.number().int().nullable().default(null), + configChangedAt: isoDateTime.nullable().default(null), + computedAt: isoDateTime, + createdAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/illness-backup-schema.ts b/src/lib/export/illness-backup-schema.ts new file mode 100644 index 000000000..09f6de025 --- /dev/null +++ b/src/lib/export/illness-backup-schema.ts @@ -0,0 +1,64 @@ +/** + * The illness slice of the backup payload schema. + * + * Part of the illness backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + IllnessLifecycle, + IllnessType, + Laterality, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const illnessSymptomBackupSchema = z + .object({ + key: z.string().min(1), + severity: z.number().int().nullable().optional(), + }) + .passthrough(); +export const illnessDayLogBackupSchema = z + .object({ + id: z.string().min(1).optional(), + episodeId: z.string().min(1).optional(), + date: z.string().min(1), + functionalImpact: z.number().int().nullable().optional(), + feverC: z.number().nullable().optional(), + symptoms: z.array(illnessSymptomBackupSchema).default([]), + note: z.string().nullable().optional(), + updatedAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + tz: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const illnessEpisodeBackupSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + type: z.enum(IllnessType), + lifecycle: z.enum(IllnessLifecycle), + onsetAt: isoDateTime, + resolvedAt: isoDateTime.nullable().optional(), + // Self-referencing flare/exacerbation link, carried as the exported + // episode's own id — never resolved against another user's rows. + parentConditionId: z.string().nullable().optional(), + note: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + // The body site and side. Optional so a file written before + // they existed still parses; the condition restores with neither. The + // site rides as ciphertext in a disaster-recovery file and as plaintext + // in a portable one, exactly like the note beside it. + bodySite: z.string().max(200).nullable().optional(), + bodySiteEncrypted: base64BytesSchema.nullable().optional(), + laterality: z.enum(Laterality).nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), + updatedAt: isoDateTime.optional(), + dayLogs: z.array(illnessDayLogBackupSchema).default([]), + }) + .passthrough(); diff --git a/src/lib/export/intraday-profile-backup-schema.ts b/src/lib/export/intraday-profile-backup-schema.ts new file mode 100644 index 000000000..f00447f89 --- /dev/null +++ b/src/lib/export/intraday-profile-backup-schema.ts @@ -0,0 +1,45 @@ +/** + * The intraday profile slice of the backup payload schema. + * + * Part of the intraday profile backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { MeasurementType } from "@/generated/prisma/enums"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * One local day's cumulative curve for one metric. + * + * The drain writes this row and deletes the per-sample rows it was folded from + * in the same transaction, so past the grace window the file is the only copy. + * `hourlyCumulative` is left unconstrained in length here on purpose: the + * restore checks it against the slot count it shares with the reader and + * throws naming the day, which says what is wrong with one row instead of + * making a whole file unparseable. + */ +export const intradayProfileBackupSchema = z + .object({ + id: z.string().min(1).optional(), + type: z.enum(MeasurementType), + dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + hourlyCumulative: z.array(z.number()), + dayTotal: z.number(), + sampleCount: z.number().int(), + // The hours the phone posted, what the curve was built + // from, and how many hours there were. Defaulted so a file written before + // the hours existed restores as a fold of single samples, which it was. + hourlyIncrements: z.array(z.number()).default([]), + basis: z.enum(["PER_SAMPLE", "HOUR_BUCKETS"]).default("PER_SAMPLE"), + bucketCount: z.number().int().nonnegative().nullable().default(null), + // The zone the day was cut on. The reader compares it against the + // account's current zone and drops the mismatched days, so a restore that + // dropped this column would make old curves look comparable when their + // hours mean something else. + timezone: z.string().min(1), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/labs-backup-schema.ts b/src/lib/export/labs-backup-schema.ts new file mode 100644 index 000000000..b28577007 --- /dev/null +++ b/src/lib/export/labs-backup-schema.ts @@ -0,0 +1,113 @@ +/** + * The labs slice of the backup payload schema. + * + * Part of the labs backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/* ── Structured-record disaster-recovery shapes ───────────────────── + * + * These shapes serve both the historical portable export and the canonical + * weekly/off-host disaster-recovery payload. Portable document entries remain + * metadata-only. Canonical entries additionally carry encrypted BYTEA values + * as base64 plus the codec/hash fields required to recreate InboundDocument + * without decrypting or fabricating content. + */ + +export const labResultBackupSchema = z + .object({ + id: z.string().min(1).optional(), + // The source row's id in a portable file, as on a medication. + ref: z.string().min(1).optional(), + panel: z.string().nullable().optional(), + analyte: z.string().min(1), + value: z.number().nullable().optional(), + valueText: z.string().nullable().optional(), + // A qualitative reading ("positive", "trace") has no unit and is stored + // under its marker's empty one (`POST /api/labs`); the refine below keeps + // the unit required for every numeric reading. + unit: z.string(), + referenceLow: z.number().nullable().optional(), + referenceHigh: z.number().nullable().optional(), + // Optional so a backup written before these columns existed still + // restores; absent reads as "this reading had no source window on file". + sourceReferenceLow: z.number().nullable().optional(), + sourceReferenceHigh: z.number().nullable().optional(), + sourceReferenceText: z.string().nullable().optional(), + // The value and unit as printed, kept beside a converted + // reading. Optional: absent on every file written before the columns. + sourceValue: z.number().nullable().optional(), + sourceUnit: z.string().nullable().optional(), + takenAt: isoDateTime, + source: z.string().min(1), + biomarkerName: z.string().nullable().optional(), + biomarkerId: z.string().nullable().optional(), + note: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + deletedAt: isoDateTime.nullable().optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough() + .superRefine((reading, ctx) => { + const qualitative = + (reading.value === null || reading.value === undefined) && + typeof reading.valueText === "string" && + reading.valueText.length > 0; + if (reading.unit.length === 0 && !qualitative) { + ctx.addIssue({ + code: "custom", + path: ["unit"], + message: "A numeric lab reading needs its unit", + }); + } + }); +export const biomarkerBackupSchema = z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1), + // Empty for a qualitative marker, minted from a reading with no unit. The + // payload refine below refuses an empty unit on a marker a numeric + // reading is filed under. + unit: z.string(), + lowerBound: z.number().nullable().optional(), + upperBound: z.number().nullable().optional(), + panel: z.string().nullable().optional(), + hidden: z.boolean().optional(), + // The catalogue analyte the marker resolves to. + analyteKey: z.string().nullable().optional(), + context: z.string().nullable().optional(), + // The other spellings the marker answers to. Absent on + // files written before them. + aliases: z + .array( + z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1).max(120), + source: z.enum(["MERGE", "IMPORT_CHOICE", "MANUAL"]), + createdAt: isoDateTime.optional(), + }) + .passthrough(), + ) + .optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +// The ledger of markers merged into others. +export const biomarkerMergeBackupSchema = z + .object({ + id: z.string().min(1).optional(), + targetBiomarkerId: z.string().min(1).optional(), + targetName: z.string().min(1), + sourceBiomarkerId: z.string().min(1), + sourceName: z.string().min(1).max(120), + readingCount: z.number().int().min(0), + mergedAt: isoDateTime, + }) + .passthrough(); diff --git a/src/lib/export/life-events-backup-schema.ts b/src/lib/export/life-events-backup-schema.ts new file mode 100644 index 000000000..a9fab422e --- /dev/null +++ b/src/lib/export/life-events-backup-schema.ts @@ -0,0 +1,44 @@ +/** + * The life events slice of the backup payload schema. + * + * Part of the life events backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + LifeEventCategory, + LifeEventPrecision, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * A life event (v1.42, #613). `title` / `titleEncrypted` and `note` / + * `noteEncrypted` are the two ends of the free-text contract: a portable file + * carries the plaintext, a disaster-recovery file the ciphertext and the + * tombstone. Dates are calendar strings. + */ +export const lifeEventBackupSchema = z + .object({ + id: z.string().min(1).optional(), + category: z.enum(LifeEventCategory), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + endDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .nullable() + .optional(), + precision: z.enum(LifeEventPrecision), + title: z.string().min(1).optional(), + titleEncrypted: base64BytesSchema.optional(), + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough() + .refine((e) => e.title !== undefined || e.titleEncrypted !== undefined, { + message: "A life event needs a title or its ciphertext", + }); diff --git a/src/lib/export/measurements-backup-schema.ts b/src/lib/export/measurements-backup-schema.ts new file mode 100644 index 000000000..1a5335b0d --- /dev/null +++ b/src/lib/export/measurements-backup-schema.ts @@ -0,0 +1,49 @@ +/** + * The measurements slice of the backup payload schema. + * + * Part of the measurements backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + GlucoseContext, + MeasurementAggregationProvenance, + MeasurementSource, + MeasurementType, + RhythmClassification, + SleepStage, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const measurementSchema = z + .object({ + id: z.string().min(1).optional(), + type: z.enum(MeasurementType), + value: z.number(), + valueMin: z.number().nullable().optional(), + valueMax: z.number().nullable().optional(), + unit: z.string().min(1), + measuredAt: isoDateTime, + source: z.enum(MeasurementSource).optional(), + notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + externalId: z.string().nullable().optional(), + externalSourceVersion: z.string().nullable().optional(), + // Authority of a `stats:` aggregate. `.optional()` keeps every backup + // written before this field rode the payload parseable. + aggregationProvenance: z + .enum(MeasurementAggregationProvenance) + .nullable() + .optional(), + glucoseContext: z.enum(GlucoseContext).nullable().optional(), + sleepStage: z.enum(SleepStage).nullable().optional(), + rhythmClassification: z.enum(RhythmClassification).nullable().optional(), + deviceType: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts new file mode 100644 index 000000000..eade10b55 --- /dev/null +++ b/src/lib/export/medications-backup-schema.ts @@ -0,0 +1,361 @@ +/** + * The medications slice of the backup payload schema. + * + * Part of the medications backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + InjectionSite, + IntakeAttributionSource, + IntakeSource, + MeasurementType, + MedicationCategory, + MedicationContainerType, + MedicationDeliveryForm, + MedicationInventoryState, + MedicationScheduleType, + MedicationSideEffectCategory, + MedicationSideEffectEntry, + PhaseMode, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; +import { + UNITS_PER_DOSE_MESSAGE, + isSupportedUnitsPerDose, +} from "@/lib/medications/units-per-dose"; + +// #1034 — the restore applies the write routes' units-per-dose rule. A +// backup carries the Decimal column as a string ("1.5"); a hand-authored +// file may carry a number. `Number("")` is 0, so an empty string fails too. +export const unitsPerDoseValue = z + .union([z.string(), z.number()]) + .refine((v) => isSupportedUnitsPerDose(Number(v)), { + message: UNITS_PER_DOSE_MESSAGE, + }); +export const medicationScheduleSchema = z + .object({ + id: z.string().min(1).optional(), + windowStart: z.string().min(1), + windowEnd: z.string().min(1), + label: z.string().nullable().optional(), + dose: z.string().nullable().optional(), + // #219 — per-schedule units per dose. Serialised as a Decimal string in a + // DR file (or a number in a hand-authored one); NULL means inherit. + unitsPerDose: unitsPerDoseValue.nullable().optional(), + daysOfWeek: z.string().nullable().optional(), + timesOfDay: z.array(z.string()).optional(), + reminderGraceMinutes: z.number().int().nullable().optional(), + rrule: z.string().nullable().optional(), + rollingIntervalDays: z.number().int().nullable().optional(), + scheduleType: z.enum(MedicationScheduleType).optional(), + cyclicOnWeeks: z.number().int().nullable().optional(), + cyclicOffWeeks: z.number().int().nullable().optional(), + doseWindows: z.unknown().nullable().optional(), + }) + .passthrough(); +/** + * One recorded side effect, carried inside its medication. + * + * `notes` is the decrypted note in a portable export and the row's legacy + * plaintext column in a canonical DR file; `notesEncrypted` is the base64 + * ciphertext and rides only in the DR case. The restore prefers the ciphertext + * and encrypts the plaintext when that is all the file has, so neither shape + * loses the note and neither writes plaintext back into the column. + */ +export const medicationSideEffectSchema = z + .object({ + id: z.string().min(1).optional(), + occurredAt: isoDateTime, + category: z.enum(MedicationSideEffectCategory), + entry: z.enum(MedicationSideEffectEntry), + severity: z.number().int().min(1).max(5), + notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * A span during which the drug was deliberately not taken. + * + * `resumedAt` is nullable rather than optional, and the writer always emits + * it. An absent key and an explicit `null` would otherwise both have to mean + * "still paused", and a file that simply forgot the field would assert an open + * era that never existed. Readers run an open era to `now`. + */ +export const medicationPauseEraSchema = z + .object({ + id: z.string().min(1).optional(), + pausedAt: isoDateTime, + resumedAt: isoDateTime.nullable(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One ingredient of a medication's composition per unit, in the + * catalogue's canonical unit. The code stays a string: a file from a newer + * server may name a nutrient this one does not know, and the row is still + * the person's. + */ +export const medicationIngredientSchema = z + .object({ + id: z.string().min(1).optional(), + nutrient: z.string().min(1).max(40), + amountPerUnit: z.number().finite().positive(), + unit: z.string().min(1).max(8), + enteredAmount: z.number().finite().nullable().optional(), + enteredUnit: z.string().max(8).nullable().optional(), + source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One course of a medication: a calendar span (both ends + * inclusive, `YYYY-MM-DD`). The note travels decrypted in both purposes and + * is sealed again on restore, like the custom category label. + */ +export const medicationCourseSchema = z + .object({ + startsOn: z.iso.date(), + endsOn: z.iso.date().nullable(), + note: z.string().max(280).nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * One step of a titration: when the dose moved and to what. + * + * The note follows the side-effect contract — decrypted prose in a portable + * file, ciphertext plus any legacy plaintext in a recovery one. + */ +export const medicationDoseChangeSchema = z + .object({ + id: z.string().min(1).optional(), + effectiveFrom: isoDateTime, + doseValue: z.number(), + doseUnit: z.string().min(1), + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * A pack on the shelf. `unitsTotal` / `unitsRemaining` cross the wire as + * STRINGS: the columns are `Decimal(12,4)` because half tablets are a real + * prescription, and a JSON number would round them on the way through. + */ +export const medicationInventoryItemSchema = z + .object({ + id: z.string().min(1).optional(), + state: z.enum(MedicationInventoryState).optional(), + containerType: z.enum(MedicationContainerType).optional(), + unitsTotal: z.string().min(1), + unitsRemaining: z.string().min(1), + firstUseAt: isoDateTime.nullable().optional(), + expiresAt: isoDateTime.nullable().optional(), + printedExpiry: isoDateTime.nullable().optional(), + purchasedAt: isoDateTime.nullable().optional(), + manufacturer: z.string().nullable().optional(), + doseStrength: z.string().nullable().optional(), + notes: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +export const medicationInventoryEventSchema = z + .object({ + id: z.string().min(1).optional(), + delta: z.number().int(), + reason: z.string().min(1), + occurredAt: isoDateTime, + }) + .passthrough(); +/** + * The four reminder thresholds a drug was tuned to. Every field optional, so a + * file written before a threshold existed restores the schema default rather + * than refusing — but the OBJECT is nullable rather than absent, because "no + * tuning" and "this file does not say" are different claims. + */ +export const reminderPhaseConfigSchema = z + .object({ + id: z.string().min(1).optional(), + greenValue: z.number().int().optional(), + greenMode: z.enum(PhaseMode).optional(), + yellowValue: z.number().int().optional(), + yellowMode: z.enum(PhaseMode).optional(), + orangeValue: z.number().int().optional(), + orangeMode: z.enum(PhaseMode).optional(), + redValue: z.number().int().optional(), + redMode: z.enum(PhaseMode).optional(), + }) + .passthrough(); +/** + * One archived schedule era. + * + * `supersededByIndex` is the self-reference, and it is a POSITION in the + * drug's own `scheduleRevisions` array rather than an id: a portable restore + * mints fresh ids, so an id would address nothing. It stays a plain integer + * with no range check here on purpose — the file is the wrong place to learn + * that a position is out of range, because the array it indexes into is the + * array being parsed. The restore does that check, against the rows it + * actually wrote, and reports what it cannot resolve. + * + * `payload` is the snapshot of the superseded schedule rows, carried as + * written. Unknown on purpose rather than typed: it is an era's frozen copy of + * a shape that has changed several times, and a restore must not refuse a file + * because a five-release-old snapshot spells a field the current form does not. + */ +export const medicationScheduleRevisionSchema = z + .object({ + id: z.string().min(1).optional(), + validFrom: isoDateTime, + validUntil: isoDateTime, + payload: z.unknown(), + source: z.string().min(1).optional(), + supersededByIndex: z.number().int().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * What a medication was supposed to move. + * + * The lab arm is a biomarker NAME rather than an id, the way a lab result's + * cross-reference is: the id is fresh on a portable restore, and `Biomarker` + * is unique on `(userId, name)`. Both arms are nullable because the live + * schema produces a row with neither — deleting a biomarker sets the column + * NULL and leaves the override behind as an orphan the resolver reads as "no + * override". + */ +export const medicationEfficacyTargetSchema = z + .object({ + id: z.string().min(1).optional(), + measurementType: z.enum(MeasurementType).nullable().optional(), + biomarkerName: z.string().min(1).nullable().optional(), + primary: z.boolean().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +/** + * A medication category the account named itself. Carried + * with its decrypted label (the label is re-encrypted on restore under the + * restoring instance's key) and restored BEFORE the medications, whose + * `category` names it by `key`. + */ +export const customMedicationCategorySchema = z + .object({ + key: z.string().regex(/^custom:[0-9a-f-]{36}$/), + label: z.string().trim().min(1).max(40), + sortOrder: z.number().int().min(0).default(0), + isActive: z.boolean().default(true), + createdAt: isoDateTime.optional(), + }) + .passthrough(); +export const medicationSchema = z + .object({ + id: z.string().min(1).optional(), + // The source row's id in a portable file, which carries no `id`: the + // relation links name the medication by it, and the restore re-points + // them at the row it writes (`relation-links-backup.ts`). + ref: z.string().min(1).optional(), + name: z.string().min(1), + dose: z.string(), + treatmentClass: z.enum(MedicationCategory).optional(), + dosesPerUnit: z.number().int().nullable().optional(), + unitsPerDose: z + .string() + .min(1) + .refine((v) => isSupportedUnitsPerDose(Number(v)), { + message: UNITS_PER_DOSE_MESSAGE, + }) + .optional(), + active: z.boolean().optional(), + notificationsEnabled: z.boolean().optional(), + pausedAt: isoDateTime.nullable().optional(), + snoozedUntil: isoDateTime.nullable().optional(), + startsOn: isoDateTime.nullable().optional(), + endsOn: isoDateTime.nullable().optional(), + oneShot: z.boolean().optional(), + asNeeded: z.boolean().optional(), + // Absent in a backup written before the field existed; + // the restore defaults it to tracking on, the behaviour those had. + trackIntake: z.boolean().optional(), + // Absent in a backup written before the option existed; the + // restore defaults it off, the behaviour those had. + autoMarkTaken: z.boolean().optional(), + autoMarkTakenSince: isoDateTime.nullable().optional(), + // The clinical category. Absent in older files. A string, not + // the enum: a file from a newer server may name a category this one does + // not know, and the restore reads that as OTHER rather than refusing the + // whole file. Since v1.40 it may be `custom:`, the key of an entry + // in `customMedicationCategories`. + category: z.string().max(64).optional(), + deliveryForm: z.enum(MedicationDeliveryForm).optional(), + trackInjectionSites: z.boolean().optional(), + allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), + liveActivityEnabled: z.boolean().optional(), + criticalAlarmEnabled: z.boolean().optional(), + atcCode: z.string().nullable().optional(), + rxNormCode: z.string().nullable().optional(), + lowStockNotifiedAt: isoDateTime.nullable().optional(), + lowStockNotifiedThresholdDays: z.number().int().nullable().optional(), + reorderLeadDays: z.number().int().nullable().optional(), + externalSource: z.enum(IntakeSource).nullable().optional(), + externalId: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + schedules: z.array(medicationScheduleSchema).default([]), + // Defaulted so a file written before side effects rode the wire still + // parses, and a drug with none writes []. + sideEffects: z.array(medicationSideEffectSchema).default([]), + // Defaulted for the same reason: a file written before pause eras rode the + // wire still parses, and a drug that was never paused writes []. + pauseEras: z.array(medicationPauseEraSchema).default([]), + // Defaulted: a file written before compositions travelled, or a + // drug without one, writes []. + ingredients: z.array(medicationIngredientSchema).default([]), + // Same default, same reason: an older file parses, a drug never titrated + // writes []. + doseChanges: z.array(medicationDoseChangeSchema).default([]), + inventoryItems: z.array(medicationInventoryItemSchema).default([]), + inventoryEvents: z.array(medicationInventoryEventSchema).default([]), + phaseConfig: reminderPhaseConfigSchema.nullable().optional(), + // Same default, same reason: an older file parses, and a drug whose + // target the resolver derives rather than the person pinning it writes []. + efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), + // Same default again. A drug whose plan has never been replaced has no + // archived era, and a file written before the eras travelled has no key. + scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), + // NOT defaulted: absent means a file written before + // courses existed, and the restore then derives the one course the + // medication's own window describes, exactly as the migration did. + courses: z.array(medicationCourseSchema).optional(), + }) + .passthrough(); +export const intakeEventSchema = z + .object({ + id: z.string().min(1).optional(), + medicationId: z.string().min(1).optional(), + medication: z.string().min(1), + scheduledFor: isoDateTime, + takenAt: isoDateTime.nullable().optional(), + skipped: z.boolean().optional(), + autoMissed: z.boolean().optional(), + // Absent in older files: every dose there was confirmed. + autoRecorded: z.boolean().optional(), + attributionSource: z.enum(IntakeAttributionSource).optional(), + source: z.enum(IntakeSource).optional(), + idempotencyKey: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + injectionSite: z.enum(InjectionSite).nullable().optional(), + doseTaken: z.string().nullable().optional(), + inventoryConsumption: z.unknown().nullable().optional(), + externalId: z.string().nullable().optional(), + updatedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); diff --git a/src/lib/export/mood-backup-schema.ts b/src/lib/export/mood-backup-schema.ts new file mode 100644 index 000000000..4f571f411 --- /dev/null +++ b/src/lib/export/mood-backup-schema.ts @@ -0,0 +1,183 @@ +/** + * The mood slice of the backup payload schema. + * + * Part of the mood backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * `MoodEntry.tags` is stored as a JSON-array-as-string in the + * `mood_entries.tags` column ("[\"work\",\"sleep\"]"). The previous + * schema accepted any `string` here, so a malformed blob in a backup + * (or one tampered with mid-restore) would land in the DB and crash + * downstream readers that `JSON.parse` it. We now refine to one of + * `null` / empty-string (legacy null wire format) / a JSON string + * that parses to a `string[]`. v1.4.15 H2. + */ +export const moodEntryTagsSchema = z + .union([z.null(), z.string()]) + .nullable() + .optional() + .refine( + (v) => { + if (v == null || v === "") return true; + try { + const parsed = JSON.parse(v) as unknown; + return ( + Array.isArray(parsed) && parsed.every((x) => typeof x === "string") + ); + } catch { + return false; + } + }, + { message: "tags must be null, empty, or a JSON array of strings" }, + ); +export const moodFactorSchema = z + .object({ + key: z.string().min(1), + rating: z.number().int(), + }) + .passthrough(); +export const moodEntrySchema = z + .object({ + id: z.string().min(1).optional(), + date: z.string().min(1), + mood: z.string().min(1), + score: z.number().int().min(0).max(10), + // The five level-A values. Optional and nullable: a file written before + // they existed carries none, and an entry whose sliders were never touched + // carries nulls. Absent restores as NULL, never as a defaulted midpoint — + // a restore that invents an answer is worse than one that admits none. + a1: z.number().int().min(0).max(10).nullable().optional(), + a2: z.number().int().min(0).max(10).nullable().optional(), + a3: z.number().int().min(0).max(10).nullable().optional(), + a4: z.number().int().min(0).max(10).nullable().optional(), + a5: z.number().int().min(0).max(10).nullable().optional(), + tags: moodEntryTagsSchema, + // The free text, in both storage shapes. A portable export carries the + // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus + // whatever legacy plaintext the row still holds. Same pair, and same + // handling on restore, as `Measurement.notes` / `notesEncrypted`. + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + source: z.string().min(1).optional(), + externalId: z.string().nullable().optional(), + loggedAt: isoDateTime, + // The IANA zone the `date` string is anchored to. Absent means the legacy + // Europe/Berlin reading, which is what a row written before v1.4.25 means + // by a NULL here — so an old file keeps its old meaning exactly. + tz: z.string().nullable().optional(), + syncedAt: isoDateTime.optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + factors: z.array(moodFactorSchema).default([]), + // BINARY structured-tag keys. Separate from `factors`, which keeps its + // RATED-only meaning, so every file written before this one parses + // unchanged and restores identically: absent reads as none. + structuredTags: z.array(z.string().min(1)).default([]), + // The day context, when the entry had one. Optional and nullable at the + // top: a file written before this existed carries no `context` key at all + // and must parse and restore exactly as it did. Every field inside is + // optional for the same reason a context row's columns are nullable — + // only the sections somebody opened say anything. + // + // The two multi-selects ride as the stored JSON string rather than as + // arrays, so the restore writes the column back verbatim and a file cannot + // arrive with a list the parser and the column disagree about. Nothing is + // re-validated against the vocabulary here: a backup restores what the + // account had, and a key retired between the export and the restore is + // still that person's answer. + context: z + .object({ + workStatus: z.string().nullable().optional(), + workMinutes: z.number().int().nullable().optional(), + overtimeMinutes: z.number().int().nullable().optional(), + workLoad: z.number().int().nullable().optional(), + workSatisfaction: z.number().int().nullable().optional(), + contactCircles: z.string().nullable().optional(), + contactForm: z.string().nullable().optional(), + contactExtent: z.string().nullable().optional(), + contactQuality: z.number().int().nullable().optional(), + contactSupport: z.number().int().nullable().optional(), + leisureCategories: z.string().nullable().optional(), + leisureMinutes: z.number().int().nullable().optional(), + leisureJoy: z.number().int().nullable().optional(), + leisureRecovery: z.number().int().nullable().optional(), + eventType: z.string().nullable().optional(), + eventValence: z.number().int().nullable().optional(), + eventAt: isoDateTime.nullable().optional(), + // Same pair as the entry's own note: a portable file carries the + // plain text, a disaster-recovery file the ciphertext. + note: z.string().nullable().optional(), + notesEncrypted: base64BytesSchema.nullable().optional(), + }) + .passthrough() + .nullable() + .optional(), + }) + .passthrough(); +/** + * A mood tag the account created, as opposed to the seeded catalogue. + * + * Carried for the same reason as a custom cycle symptom, but the failure it + * prevents is louder: the restore resolves an entry's rated factors by key and + * throws on one it cannot find, so an account with a single custom rated tag + * could not be restored at all. + */ +export const customMoodTagSchema = z + .object({ + id: z.string().min(1).optional(), + key: z.string().min(1), + labelKey: z.string().min(1), + categoryId: z.string().min(1), + // "BINARY" | "RATED", enforced in app code rather than a DB enum. + kind: z.string().min(1), + isActive: z.boolean().default(true), + icon: z.string().nullable().optional(), + sortOrder: z.number().int().default(0), + // The user's own words for the tag. Without it the tag comes back as a + // bare key and the person who named it "Migräne" gets `custom:cm3x9…` + // instead. Ciphertext on a disaster-recovery file, readable `label` on a + // portable one; the restore seals the readable value under its own key. + labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), + // The scale a RATED factor was recorded on. `inverse` marks a factor + // where a HIGH value is bad (stress, conflict). Dropping it does not lose + // a label, it silently reverses the meaning of every rating already + // stored against that tag. + scaleMin: z.number().int().default(1), + scaleMax: z.number().int().default(5), + inverse: z.boolean().default(false), + }) + .passthrough(); +/** + * A grouping the account created for its own mood factors. `id` is REQUIRED, + * unlike everywhere else in this file, because a custom tag addresses its + * category by that id and nothing else can resolve the pairing. + */ +export const customMoodTagCategorySchema = z + .object({ + id: z.string().min(1), + key: z.string().min(1), + labelKey: z.string().min(1), + icon: z.string().nullable().optional(), + sortOrder: z.number().int().optional(), + isActive: z.boolean().optional(), + // Same split as the custom tag's label. + labelEncrypted: z.string().nullable().optional(), + label: z.string().nullable().optional(), + }) + .passthrough(); +/** A tag the account hid, named by key so a seeded tag resolves anywhere. */ +export const hiddenMoodTagSchema = z + .object({ + key: z.string().min(1), + createdAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/nutrients-backup-schema.ts b/src/lib/export/nutrients-backup-schema.ts new file mode 100644 index 000000000..edd766e67 --- /dev/null +++ b/src/lib/export/nutrients-backup-schema.ts @@ -0,0 +1,33 @@ +/** + * The nutrients slice of the backup payload schema. + * + * Part of the nutrients backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * A day's total for one nutrient, from one source. + * + * The export has written these since v1.29 and the canonical schema never + * declared them, so `.passthrough()` carried the key through parsing and no + * reader ever looked at it. The restore therefore dropped every water and + * vitamin total on the floor while reporting success. + */ +export const nutrientDaySchema = z + .object({ + day: z.string().min(1), + nutrient: z.string().min(1), + amount: z.number(), + unit: z.string().min(1), + // Closed set in app code rather than a DB enum; kept as a string here so a + // future source does not make an old file unparseable. + source: z.string().min(1), + // Absent from files written before v1.39.7. + externalSourceVersion: z.string().nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/onboarding-backup-schema.ts b/src/lib/export/onboarding-backup-schema.ts new file mode 100644 index 000000000..f00b36030 --- /dev/null +++ b/src/lib/export/onboarding-backup-schema.ts @@ -0,0 +1,37 @@ +/** + * The onboarding slice of the backup payload schema. + * + * Part of the onboarding backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * The needs-based setup state. One row per record. + * + * The three answer objects are validated loosely on purpose: the closed + * vocabularies live in `src/lib/onboarding/needs.ts`, and the restore reads + * every one of them back through the same fail-soft parsers the account + * payload uses. Duplicating the enums here would give the file a second, + * quietly divergent opinion about what an answer is, and the failure mode of + * being stricter here is refusing a whole account's restore over one retired + * chip. + */ +export const onboardingRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + needs: z.record(z.string(), z.unknown()).default({}), + steps: z.array(z.record(z.string(), z.unknown())).default([]), + firstResult: z.record(z.string(), z.unknown()).nullable().default(null), + // The once-only latch on the module derivation. Carried rather than + // defaulted: a null here would hand the next confirm permission to + // re-apply the questionnaire over every module decision made since. + modulesDerivedAt: isoDateTime.nullable().default(null), + completedAt: isoDateTime.nullable().default(null), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/profile-backup-schema.ts b/src/lib/export/profile-backup-schema.ts new file mode 100644 index 000000000..620d6074e --- /dev/null +++ b/src/lib/export/profile-backup-schema.ts @@ -0,0 +1,181 @@ +/** + * The profile slice of the backup payload schema. + * + * Part of the profile backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; +import { + advanceDirectiveStatusSchema, + emergencyBloodTypeSchema, + organDonorStatusSchema, +} from "@/lib/validations/emergency-profile"; +import { + DEFAULT_HEALTH_PROFILE_AI_SECTIONS, + storedHealthProfileAiSectionSchema, +} from "@/lib/validations/health-profile-facts"; +import { + STORED_FACT_KINDS, + isStoredFactPlaintext, +} from "@/lib/validations/life-circumstances"; + +/** + * The account's durable self-context — one row per user. + * + * Portable exports carry the decrypted free text; disaster-recovery payloads + * carry the AES-256-GCM envelopes as base64 and leave the plaintext fields + * null. The restore prefers ciphertext when the file has it and re-encrypts + * the plaintext otherwise, which is the same contract the lab note uses. + */ +export const healthProfileBackupSchema = z + .object({ + id: z.string().min(1).optional(), + aboutMe: z.string().nullable().default(null), + conditions: z.string().nullable().default(null), + allergies: z.string().nullable().default(null), + coachFocus: z.string().nullable().default(null), + // Every stored section, the four life-circumstance ones included (v1.43): + // a file written by an account that opted one in must restore it opted in. + aiIncludedSections: z + .array(storedHealthProfileAiSectionSchema) + .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), + // Section confirmations, section key to ISO instant. + // Carried as stored; the read route judges each stamp, so an unreadable + // one costs a "still true?" prompt rather than the whole restore. + lifeConfirmedJson: z + .record(z.string().max(64), z.string().max(64)) + .nullable() + .optional(), + // Emergency profile: three plaintext enums (carried by value in both + // purposes), three free-text columns following the ciphertext-or-plaintext + // split of the self-context fields above. + emergencyBloodType: emergencyBloodTypeSchema.nullable().default(null), + organDonorStatus: organDonorStatusSchema.nullable().default(null), + advanceDirectiveStatus: advanceDirectiveStatusSchema + .nullable() + .default(null), + emergencyContacts: z.string().nullable().default(null), + emergencyImplants: z.string().nullable().default(null), + emergencyNote: z.string().nullable().default(null), + emergencyContactsEncrypted: base64BytesSchema.nullable().optional(), + emergencyImplantsEncrypted: base64BytesSchema.nullable().optional(), + emergencyNoteEncrypted: base64BytesSchema.nullable().optional(), + aboutMeEncrypted: base64BytesSchema.nullable().optional(), + conditionsEncrypted: base64BytesSchema.nullable().optional(), + allergiesEncrypted: base64BytesSchema.nullable().optional(), + coachFocusEncrypted: base64BytesSchema.nullable().optional(), + pendingQuestionsEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); +export const healthProfileFactBackupSchema = z + .object({ + id: z.string().min(1), + kind: z.enum(STORED_FACT_KINDS), + value: z.string().nullable().default(null), + valueEncrypted: base64BytesSchema.optional(), + validFrom: isoDateTime, + validUntil: isoDateTime.nullable(), + provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), + supersededByRevisionId: z.string().min(1).nullable(), + createdAt: isoDateTime, + }) + .passthrough() + .superRefine((fact, ctx) => { + if (fact.valueEncrypted === undefined) { + if ( + fact.value === null || + !isStoredFactPlaintext(fact.kind, fact.value) + ) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: "A portable profile fact requires a valid readable value", + }); + } + } + if ( + fact.validUntil !== null && + Date.parse(fact.validUntil) <= Date.parse(fact.validFrom) + ) { + ctx.addIssue({ + code: "custom", + path: ["validUntil"], + message: "validUntil must be later than validFrom", + }); + } + }); +/** One reading of a user-defined metric, nested under the metric that owns it. */ +export const customMetricEntryBackupSchema = z + .object({ + id: z.string().min(1).optional(), + value: z.number(), + unit: z.string().min(1), + measuredAt: isoDateTime, + note: z.string().nullable().default(null), + /// The note's ciphertext, DR payloads only. + noteEncrypted: base64BytesSchema.nullable().optional(), + createdAt: isoDateTime.optional(), + /// The entry tombstone rides DR payloads so a restore + /// brings the account back exactly as it stood, undo affordance included. + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +/** + * A metric the account defined itself. + * + * The one class of series no integration can ever re-sync: nobody else has the + * definition and nobody else has the readings. It was classified as carried + * from the day the backup plan was written and carried by nothing, so a + * restore rebuilt the account without it and reported success. + * + * Readings are nested rather than flat-with-a-name-reference so the restore + * has no parent to look up and therefore no lookup to miss. + */ +export const customMetricBackupSchema = z + .object({ + id: z.string().min(1).optional(), + name: z.string().min(1), + unit: z.string().min(1), + targetLow: z.number().nullable().default(null), + targetHigh: z.number().nullable().default(null), + decimals: z.number().int().nullable().default(null), + description: z.string().nullable().default(null), + correlationEnabled: z.boolean().default(false), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + deletedAt: isoDateTime.nullable().optional(), + entries: z.array(customMetricEntryBackupSchema).default([]), + }) + .passthrough(); +export const correlationPatternBackupSchema = z + .object({ + id: z.string().min(1).optional(), + canonicalKey: z.string().regex(/^p1:[a-f0-9]{64}$/), + family: z.string().min(1), + factorKey: z.string().min(1), + outcomeKey: z.string().min(1), + lagDays: z.number().int().min(0), + sampleSize: z.number().int().positive(), + effectSize: z.number().finite(), + pValue: z.number().min(0).max(1), + qValue: z.number().min(0).max(1).nullable().default(null), + evidenceHash: z.string().regex(/^[a-f0-9]{64}$/), + isCurrent: z.boolean(), + lastComputedAt: isoDateTime, + dismissedAt: isoDateTime.nullable().default(null), + dismissedEvidenceHash: z + .string() + .regex(/^[a-f0-9]{64}$/) + .nullable() + .default(null), + dismissedEffectSize: z.number().finite().nullable().default(null), + dismissedSampleSize: z.number().int().positive().nullable().default(null), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/relation-links-backup-schema.ts b/src/lib/export/relation-links-backup-schema.ts new file mode 100644 index 000000000..eeccb18e6 --- /dev/null +++ b/src/lib/export/relation-links-backup-schema.ts @@ -0,0 +1,27 @@ +/** + * The relation links slice of the backup payload schema. + * + * Part of the relation links backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * One row of a relation link table: its two ends, by the column + * names of the table, and the instant it was filed. + */ +export function relationLinkBackupSchema( + a: A, + b: B, +) { + return z + .object({ + [a]: z.string().min(1), + [b]: z.string().min(1), + createdAt: isoDateTime, + } as { [K in A | B]: z.ZodString } & { createdAt: typeof isoDateTime }) + .passthrough(); +} diff --git a/src/lib/export/reminders-backup-schema.ts b/src/lib/export/reminders-backup-schema.ts new file mode 100644 index 000000000..7faa027c6 --- /dev/null +++ b/src/lib/export/reminders-backup-schema.ts @@ -0,0 +1,65 @@ +/** + * The reminders slice of the backup payload schema. + * + * Part of the reminders backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + MeasurementReminderEventKind, + MeasurementType, + ReminderOrigin, +} from "@/generated/prisma/enums"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; +import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; + +/** + * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, + * #223 / iOS #68). + * + * `id` is required on both: an encounter, a vaccination record and every + * ledger row address a reminder by it, and a ledger row is addressed by its + * own id on re-insert. The enums are exactly the API's own — `origin` and + * `kind` are the Prisma enums, and the ledger `source` is validated against + * the engine's closed call-site set rather than accepted as free text, so a + * file cannot smuggle a value the engine itself would never write. + */ +export const measurementReminderBackupSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + measurementType: z.enum(MeasurementType).nullable().optional(), + intervalDays: z.number().int().nullable().optional(), + rrule: z.string().nullable().optional(), + anchorDate: isoDateTime.nullable().optional(), + endsOn: isoDateTime.nullable().optional(), + origin: z.enum(ReminderOrigin).optional(), + notifyHour: z.number().int().min(0).max(23).optional(), + location: z.string().nullable().optional(), + // Server-computed and restored verbatim — recomputing on restore would + // move a due date the person had already been shown. + nextDueAt: isoDateTime.nullable().optional(), + lastSatisfiedAt: isoDateTime.nullable().optional(), + enabled: z.boolean().optional(), + vaccinationAntigen: z.string().nullable().optional(), + snoozedUntil: isoDateTime.nullable().optional(), + lastSkippedAt: isoDateTime.nullable().optional(), + skipCount: z.number().int().min(0).optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const measurementReminderEventBackupSchema = z + .object({ + id: z.string().min(1), + reminderId: z.string().min(1), + kind: z.enum(MeasurementReminderEventKind), + occurredAt: isoDateTime, + onTime: z.boolean(), + source: z.enum(REMINDER_EVENT_SOURCES), + createdAt: isoDateTime, + }) + .passthrough(); diff --git a/src/lib/export/sensitive-backup-schema.ts b/src/lib/export/sensitive-backup-schema.ts new file mode 100644 index 000000000..bf7ea7f17 --- /dev/null +++ b/src/lib/export/sensitive-backup-schema.ts @@ -0,0 +1,50 @@ +/** + * The sensitive slice of the backup payload schema. + * + * Part of the sensitive backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { AssessmentInstrument } from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * A completed screener administration. `responsesEncrypted` is REQUIRED rather + * than optional, unlike every other ciphertext field in this file: the column + * is NOT NULL, so a file that omits it describes a row that cannot be written, + * and saying so at the schema is better than discovering it inside the + * transaction that has already wiped the account. + */ +export const mentalHealthAssessmentBackupSchema = z + .object({ + id: z.string().min(1), + instrument: z.enum(AssessmentInstrument), + locale: z.string().min(1), + version: z.string().optional(), + responsesEncrypted: base64BytesSchema, + totalScore: z.number().int(), + severityBand: z.string().min(1), + item9Flagged: z.boolean().optional(), + crisisShownAt: isoDateTime.nullable().optional(), + takenAt: isoDateTime, + tz: z.string().nullable().optional(), + source: z.string().optional(), + externalId: z.string().nullable().optional(), + syncVersion: z.number().int().optional(), + deletedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); +export const consentReceiptBackupSchema = z + .object({ + id: z.string().min(1), + kind: z.string().min(1), + artefact: z.string().min(1), + signedAt: isoDateTime, + revokedAt: isoDateTime.nullable().optional(), + createdAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/export/symptoms-backup-schema.ts b/src/lib/export/symptoms-backup-schema.ts new file mode 100644 index 000000000..79581a54d --- /dev/null +++ b/src/lib/export/symptoms-backup-schema.ts @@ -0,0 +1,47 @@ +/** + * The symptoms slice of the backup payload schema. + * + * Part of the symptoms backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * A person-defined symptom (v1.40) with every occurrence of it nested inside, + * the way a medication carries its side effects. `label` / `labelEncrypted` + * and `note` / `noteEncrypted` are the two ends of the free-text contract: a + * portable file carries the plaintext, a disaster-recovery file the ciphertext. + * An occurrence's `episodeId` is the exported episode's own id; the restore + * nulls one that names an episode the file does not carry. + */ +export const symptomEventBackupSchema = z + .object({ + id: z.string().min(1).optional(), + occurredAt: isoDateTime, + intensity: z.number().int().min(0).max(10), + note: z.string().nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + episodeId: z.string().min(1).nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + }) + .passthrough(); +export const symptomDefinitionBackupSchema = z + .object({ + id: z.string().min(1).optional(), + label: z.string().min(1).optional(), + labelEncrypted: base64BytesSchema.optional(), + icon: z.string().nullable().optional(), + sortOrder: z.number().int(), + isActive: z.boolean(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + events: z.array(symptomEventBackupSchema).default([]), + }) + .passthrough() + .refine((d) => d.label !== undefined || d.labelEncrypted !== undefined, { + message: "A symptom definition needs a label or its ciphertext", + }); diff --git a/src/lib/export/vaccinations-backup-schema.ts b/src/lib/export/vaccinations-backup-schema.ts new file mode 100644 index 000000000..f9c21e470 --- /dev/null +++ b/src/lib/export/vaccinations-backup-schema.ts @@ -0,0 +1,70 @@ +/** + * The vaccinations slice of the backup payload schema. + * + * Part of the vaccinations backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { VaccinationSite } from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * One administered dose. + * + * `antigenSlug` is a free string here rather than an enum of the catalogue + * this release ships, deliberately: a slug the catalogue has since dropped + * must still restore, and the renderer degrades to `vaccineName`. Validating + * it at the restore boundary would refuse a file the app itself wrote. + */ +export const vaccinationBackupSchema = z + .object({ + id: z.string().min(1), + occurredAt: isoDateTime, + antigenSlug: z.string().nullable().optional(), + vaccineName: z.string().nullable().optional(), + doseNumber: z.number().int().nullable().optional(), + seriesDoses: z.number().int().nullable().optional(), + lotNumber: z.string().nullable().optional(), + site: z.enum(VaccinationSite).nullable().optional(), + practitionerId: z.string().nullable().optional(), + encounterId: z.string().nullable().optional(), + // Remapped against the restored reminders (they travel since v1.37.20); + // dropped to NULL, with the drop named, only when the file lacks the row. + reminderId: z.string().nullable().optional(), + // The person's own vaccine definition the dose names; + // remapped against the restored definitions like `reminderId`. + customVaccineId: z.string().nullable().optional(), + // Ciphertext on a disaster-recovery file, readable on a portable one. + noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +/** + * One of the person's own vaccine definitions (v1.42, #1005). `components` + * is a list of free strings rather than the antigen enum, for the reason + * `antigenSlug` is: a file the app wrote must restore. + */ +export const customVaccineBackupSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + components: z.array(z.string()).optional(), + typicalSeriesDoses: z.number().int().nullable().optional(), + boosterIntervalMonths: z.number().int().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const vaccinationLinkBackupSchema = z + .object({ + vaccinationId: z.string().min(1), + targetId: z.string().min(1), + createdAt: isoDateTime, + }) + .passthrough(); diff --git a/src/lib/export/visits-backup-schema.ts b/src/lib/export/visits-backup-schema.ts new file mode 100644 index 000000000..442b4e3e5 --- /dev/null +++ b/src/lib/export/visits-backup-schema.ts @@ -0,0 +1,76 @@ +/** + * The visits slice of the backup payload schema. + * + * Part of the visits backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { + EncounterKind, + EncounterStatus, + Laterality, +} from "@/generated/prisma/enums"; +import { base64BytesSchema, isoDateTime } from "@/lib/export/backup-schema-kit"; + +/** + * A practitioner, an encounter, and the edges between an encounter and the + * things it produced. + * + * `id` is required on both records, unlike the lab result above: a link + * addresses an encounter and its far side by id and nothing else on either row + * is unique enough to rebuild the reference from. Free text follows the note + * contract: ciphertext as base64 on a disaster-recovery file, the readable + * value on a portable one (sealed under the receiving host's key on restore). + */ +export const practitionerBackupSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + specialty: z.string().nullable().optional(), + practice: z.string().nullable().optional(), + location: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + /// The address and phone ciphertext, DR payloads only. + locationEncrypted: base64BytesSchema.nullable().optional(), + phoneEncrypted: base64BytesSchema.nullable().optional(), + noteEncrypted: base64BytesSchema.nullable().optional(), + note: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const encounterBackupSchema = z + .object({ + id: z.string().min(1), + occurredAt: isoDateTime, + status: z.enum(EncounterStatus), + kind: z.enum(EncounterKind), + practitionerId: z.string().nullable().optional(), + reasonEncrypted: base64BytesSchema.nullable().optional(), + outcomeEncrypted: base64BytesSchema.nullable().optional(), + // The procedure's body site and side. Optional so a file written + // before they existed still parses; the visit restores with neither. + bodySiteEncrypted: base64BytesSchema.nullable().optional(), + // The readable reason, outcome and body site of a portable file. + reason: z.string().nullable().optional(), + outcome: z.string().nullable().optional(), + bodySite: z.string().nullable().optional(), + laterality: z.enum(Laterality).nullable().optional(), + // Remapped against the restored reminders (they travel since v1.37.20); + // dropped to NULL, with the drop named, only when the file lacks the row. + reminderId: z.string().nullable().optional(), + createdAt: isoDateTime, + updatedAt: isoDateTime, + deletedAt: isoDateTime.nullable().optional(), + }) + .passthrough(); +export const encounterLinkBackupSchema = z + .object({ + encounterId: z.string().min(1), + targetId: z.string().min(1), + createdAt: isoDateTime, + }) + .passthrough(); diff --git a/src/lib/export/workouts-backup-schema.ts b/src/lib/export/workouts-backup-schema.ts new file mode 100644 index 000000000..3067c9859 --- /dev/null +++ b/src/lib/export/workouts-backup-schema.ts @@ -0,0 +1,35 @@ +/** + * The workouts slice of the backup payload schema. + * + * Part of the workouts backup domain, beside its export and restore. + * Loaded only through `src/lib/validations/backup.ts`, which composes every + * slice into `backupPayloadSchema` on first use (the schema is ~12 MB of + * heap; see `backup-schema-lazy-guard.test.ts`). + */ +import { z } from "zod/v4"; +import { MeasurementSource } from "@/generated/prisma/enums"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; + +export const workoutBackupSchema = z + .object({ + id: z.string().min(1).optional(), + sportType: z.string().min(1), + startedAt: isoDateTime, + endedAt: isoDateTime, + durationSec: z.number().int(), + totalEnergyKcal: z.number().nullable().optional(), + totalDistanceM: z.number().nullable().optional(), + avgHeartRate: z.number().int().nullable().optional(), + maxHeartRate: z.number().int().nullable().optional(), + minHeartRate: z.number().int().nullable().optional(), + stepCount: z.number().int().nullable().optional(), + elevationM: z.number().nullable().optional(), + pauseDurationSec: z.number().int().nullable().optional(), + source: z.enum(MeasurementSource), + externalId: z.string().nullable().optional(), + externalSourceVersion: z.string().nullable().optional(), + metadata: z.json().optional(), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b08727ca5..4cb5ca368 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -18,2175 +18,102 @@ * include this field; `parseBackupPayload()` defaults it to "1" so legacy * blobs continue to round-trip and the upload validator can still produce * a useful summary for them. + * + * Each section's element schema lives with its domain, in + * `-backup-schema.ts` beside the domain's export and restore + * (`src/lib/export/`, and `src/lib/cycle/backup-schema.ts` for the cycle). + * This file composes them into the payload, in the file's key order, with + * each section's default and the checks that span sections. Every slice is + * loaded through here and nowhere else, so the whole schema is still built + * only when a backup is first validated. */ import { z } from "zod/v4"; -import { RESULTS_MAX_BYTES } from "@/lib/ai/coach/results/refs"; -import { TRAIL_MAX_BYTES } from "@/lib/ai/coach/activity/contract"; -import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; import { - AllergyCategory, - AllergySeverity, - AllergyStatus, - AllergyType, - CervicalMucus, - CervixFirmness, - CervixOpening, - CervixPosition, - ContraceptiveKind, - CycleTrackingGoal, - DocumentSummaryState, - EncounterKind, - EncounterStatus, - ExtractedFactStatus, - ExtractedFactType, - EnvironmentLocationSource, - FamilyRelationship, - FlowLevel, - GlucoseContext, - HomeTestResult, - IllnessLifecycle, - IllnessType, - InboundDocumentKind, - InboundDocumentStatus, - InjectionSite, - IntakeAttributionSource, - IntakeSource, - Laterality, - MeasurementAggregationProvenance, - MeasurementReminderEventKind, - MeasurementSource, - MeasurementType, - AssessmentInstrument, - MedicationCategory, - PhaseMode, - MedicationContainerType, - MedicationInventoryState, - MedicationDeliveryForm, - MedicationScheduleType, - MedicationSideEffectCategory, - MedicationSideEffectEntry, - OvulationTest, - PersonalRecordDirection, - ReminderOrigin, - RhythmClassification, - SecondarySymptom, - SleepStage, - VaccinationSite, - LifeEventCategory, - LifeEventPrecision, -} from "@/generated/prisma/enums"; + customCycleSymptomSchema, + cycleDayLogSchema, + cycleProfileSchema, + cycleSpanSchema, +} from "@/lib/cycle/backup-schema"; +import { accountSettingsBackupSchema } from "@/lib/export/account-settings-backup-schema"; +import { allergyBackupSchema } from "@/lib/export/allergies-backup-schema"; +import { appSettingsBackupSchema } from "@/lib/export/app-settings-backup-schema"; import { - DEFAULT_HEALTH_PROFILE_AI_SECTIONS, - storedHealthProfileAiSectionSchema, -} from "@/lib/validations/health-profile-facts"; + personalRecordBackupSchema, + userAchievementBackupSchema, +} from "@/lib/export/awards-backup-schema"; +import { isoDateTime } from "@/lib/export/backup-schema-kit"; import { - STORED_FACT_KINDS, - isStoredFactPlaintext, -} from "@/lib/validations/life-circumstances"; + coachConversationBackupSchema, + coachFactBackupSchema, + coachPlanBackupSchema, + coachReminderBackupSchema, +} from "@/lib/export/coach-backup-schema"; import { - advanceDirectiveStatusSchema, - emergencyBloodTypeSchema, - organDonorStatusSchema, -} from "@/lib/validations/emergency-profile"; + documentConditionLinkBackupSchema, + extractedFactBackupSchema, +} from "@/lib/export/document-filing-backup-schema"; +import { documentBackupSchema } from "@/lib/export/documents-backup-schema"; +import { ecgRecordingBackupSchema } from "@/lib/export/ecg-backup-schema"; import { - DOCUMENT_SOURCE_ID_MAX, - DOCUMENT_SOURCE_INSTANCE_MAX, - DOCUMENT_SOURCE_SYSTEMS, -} from "@/lib/validations/inbound-documents"; -import { REMINDER_EVENT_SOURCES } from "@/lib/measurement-reminders/satisfy"; + dawarichDayBackupSchema, + environmentContextBackupSchema, + environmentTravelLocationBackupSchema, +} from "@/lib/export/environment-backup-schema"; +import { familyHistoryBackupSchema } from "@/lib/export/family-history-backup-schema"; +import { healthScoreRecordBackupSchema } from "@/lib/export/health-score-backup-schema"; +import { illnessEpisodeBackupSchema } from "@/lib/export/illness-backup-schema"; +import { intradayProfileBackupSchema } from "@/lib/export/intraday-profile-backup-schema"; +import { + biomarkerBackupSchema, + biomarkerMergeBackupSchema, + labResultBackupSchema, +} from "@/lib/export/labs-backup-schema"; +import { lifeEventBackupSchema } from "@/lib/export/life-events-backup-schema"; +import { measurementSchema } from "@/lib/export/measurements-backup-schema"; +import { + customMedicationCategorySchema, + intakeEventSchema, + medicationSchema, +} from "@/lib/export/medications-backup-schema"; +import { + customMoodTagCategorySchema, + customMoodTagSchema, + hiddenMoodTagSchema, + moodEntrySchema, +} from "@/lib/export/mood-backup-schema"; +import { nutrientDaySchema } from "@/lib/export/nutrients-backup-schema"; +import { onboardingRecordBackupSchema } from "@/lib/export/onboarding-backup-schema"; +import { + correlationPatternBackupSchema, + customMetricBackupSchema, + healthProfileBackupSchema, + healthProfileFactBackupSchema, +} from "@/lib/export/profile-backup-schema"; +import { relationLinkBackupSchema } from "@/lib/export/relation-links-backup-schema"; +import { + measurementReminderBackupSchema, + measurementReminderEventBackupSchema, +} from "@/lib/export/reminders-backup-schema"; +import { + consentReceiptBackupSchema, + mentalHealthAssessmentBackupSchema, +} from "@/lib/export/sensitive-backup-schema"; +import { symptomDefinitionBackupSchema } from "@/lib/export/symptoms-backup-schema"; +import { + customVaccineBackupSchema, + vaccinationBackupSchema, + vaccinationLinkBackupSchema, +} from "@/lib/export/vaccinations-backup-schema"; +import { + encounterBackupSchema, + encounterLinkBackupSchema, + practitionerBackupSchema, +} from "@/lib/export/visits-backup-schema"; +import { workoutBackupSchema } from "@/lib/export/workouts-backup-schema"; 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"; - -const isoDateTime = z - .string() - .min(1) - .refine((s) => !Number.isNaN(Date.parse(s)), { - message: "Expected ISO-8601 date-time string", - }); - -const base64BytesSchema = z - .string() - .min(1) - .refine( - (value) => value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value), - { message: "Expected base64-encoded encrypted bytes" }, - ); - -const measurementSchema = z - .object({ - id: z.string().min(1).optional(), - type: z.enum(MeasurementType), - value: z.number(), - valueMin: z.number().nullable().optional(), - valueMax: z.number().nullable().optional(), - unit: z.string().min(1), - measuredAt: isoDateTime, - source: z.enum(MeasurementSource).optional(), - notes: z.string().nullable().optional(), - notesEncrypted: base64BytesSchema.nullable().optional(), - externalId: z.string().nullable().optional(), - externalSourceVersion: z.string().nullable().optional(), - // Authority of a `stats:` aggregate. `.optional()` keeps every backup - // written before this field rode the payload parseable. - aggregationProvenance: z - .enum(MeasurementAggregationProvenance) - .nullable() - .optional(), - glucoseContext: z.enum(GlucoseContext).nullable().optional(), - sleepStage: z.enum(SleepStage).nullable().optional(), - rhythmClassification: z.enum(RhythmClassification).nullable().optional(), - deviceType: z.string().nullable().optional(), - syncVersion: z.number().int().optional(), - deletedAt: isoDateTime.nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -// #1034 — the restore applies the write routes' units-per-dose rule. A -// backup carries the Decimal column as a string ("1.5"); a hand-authored -// file may carry a number. `Number("")` is 0, so an empty string fails too. -const unitsPerDoseValue = z - .union([z.string(), z.number()]) - .refine((v) => isSupportedUnitsPerDose(Number(v)), { - message: UNITS_PER_DOSE_MESSAGE, - }); - -const medicationScheduleSchema = z - .object({ - id: z.string().min(1).optional(), - windowStart: z.string().min(1), - windowEnd: z.string().min(1), - label: z.string().nullable().optional(), - dose: z.string().nullable().optional(), - // #219 — per-schedule units per dose. Serialised as a Decimal string in a - // DR file (or a number in a hand-authored one); NULL means inherit. - unitsPerDose: unitsPerDoseValue.nullable().optional(), - daysOfWeek: z.string().nullable().optional(), - timesOfDay: z.array(z.string()).optional(), - reminderGraceMinutes: z.number().int().nullable().optional(), - rrule: z.string().nullable().optional(), - rollingIntervalDays: z.number().int().nullable().optional(), - scheduleType: z.enum(MedicationScheduleType).optional(), - cyclicOnWeeks: z.number().int().nullable().optional(), - cyclicOffWeeks: z.number().int().nullable().optional(), - doseWindows: z.unknown().nullable().optional(), - }) - .passthrough(); - -/** - * One recorded side effect, carried inside its medication. - * - * `notes` is the decrypted note in a portable export and the row's legacy - * plaintext column in a canonical DR file; `notesEncrypted` is the base64 - * ciphertext and rides only in the DR case. The restore prefers the ciphertext - * and encrypts the plaintext when that is all the file has, so neither shape - * loses the note and neither writes plaintext back into the column. - */ -const medicationSideEffectSchema = z - .object({ - id: z.string().min(1).optional(), - occurredAt: isoDateTime, - category: z.enum(MedicationSideEffectCategory), - entry: z.enum(MedicationSideEffectEntry), - severity: z.number().int().min(1).max(5), - notes: z.string().nullable().optional(), - notesEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * A span during which the drug was deliberately not taken. - * - * `resumedAt` is nullable rather than optional, and the writer always emits - * it. An absent key and an explicit `null` would otherwise both have to mean - * "still paused", and a file that simply forgot the field would assert an open - * era that never existed. Readers run an open era to `now`. - */ -const medicationPauseEraSchema = z - .object({ - id: z.string().min(1).optional(), - pausedAt: isoDateTime, - resumedAt: isoDateTime.nullable(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * v1.43 — one ingredient of a medication's composition per unit, in the - * catalogue's canonical unit. The code stays a string: a file from a newer - * server may name a nutrient this one does not know, and the row is still - * the person's. - */ -const medicationIngredientSchema = z - .object({ - id: z.string().min(1).optional(), - nutrient: z.string().min(1).max(40), - amountPerUnit: z.number().finite().positive(), - unit: z.string().min(1).max(8), - enteredAmount: z.number().finite().nullable().optional(), - enteredUnit: z.string().max(8).nullable().optional(), - source: z.enum(["MANUAL", "LABEL_AI"]).default("MANUAL"), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * v1.40 (#1024) — one course of a medication: a calendar span (both ends - * inclusive, `YYYY-MM-DD`). The note travels decrypted in both purposes and - * is sealed again on restore, like the custom category label. - */ -const medicationCourseSchema = z - .object({ - startsOn: z.iso.date(), - endsOn: z.iso.date().nullable(), - note: z.string().max(280).nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * One step of a titration: when the dose moved and to what. - * - * The note follows the side-effect contract — decrypted prose in a portable - * file, ciphertext plus any legacy plaintext in a recovery one. - */ -const medicationDoseChangeSchema = z - .object({ - id: z.string().min(1).optional(), - effectiveFrom: isoDateTime, - doseValue: z.number(), - doseUnit: z.string().min(1), - note: z.string().nullable().optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * A pack on the shelf. `unitsTotal` / `unitsRemaining` cross the wire as - * STRINGS: the columns are `Decimal(12,4)` because half tablets are a real - * prescription, and a JSON number would round them on the way through. - */ -const medicationInventoryItemSchema = z - .object({ - id: z.string().min(1).optional(), - state: z.enum(MedicationInventoryState).optional(), - containerType: z.enum(MedicationContainerType).optional(), - unitsTotal: z.string().min(1), - unitsRemaining: z.string().min(1), - firstUseAt: isoDateTime.nullable().optional(), - expiresAt: isoDateTime.nullable().optional(), - printedExpiry: isoDateTime.nullable().optional(), - purchasedAt: isoDateTime.nullable().optional(), - manufacturer: z.string().nullable().optional(), - doseStrength: z.string().nullable().optional(), - notes: z.string().nullable().optional(), - notesEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const medicationInventoryEventSchema = z - .object({ - id: z.string().min(1).optional(), - delta: z.number().int(), - reason: z.string().min(1), - occurredAt: isoDateTime, - }) - .passthrough(); - -/** - * The four reminder thresholds a drug was tuned to. Every field optional, so a - * file written before a threshold existed restores the schema default rather - * than refusing — but the OBJECT is nullable rather than absent, because "no - * tuning" and "this file does not say" are different claims. - */ -const reminderPhaseConfigSchema = z - .object({ - id: z.string().min(1).optional(), - greenValue: z.number().int().optional(), - greenMode: z.enum(PhaseMode).optional(), - yellowValue: z.number().int().optional(), - yellowMode: z.enum(PhaseMode).optional(), - orangeValue: z.number().int().optional(), - orangeMode: z.enum(PhaseMode).optional(), - redValue: z.number().int().optional(), - redMode: z.enum(PhaseMode).optional(), - }) - .passthrough(); - -/** - * One archived schedule era. - * - * `supersededByIndex` is the self-reference, and it is a POSITION in the - * drug's own `scheduleRevisions` array rather than an id: a portable restore - * mints fresh ids, so an id would address nothing. It stays a plain integer - * with no range check here on purpose — the file is the wrong place to learn - * that a position is out of range, because the array it indexes into is the - * array being parsed. The restore does that check, against the rows it - * actually wrote, and reports what it cannot resolve. - * - * `payload` is the snapshot of the superseded schedule rows, carried as - * written. Unknown on purpose rather than typed: it is an era's frozen copy of - * a shape that has changed several times, and a restore must not refuse a file - * because a five-release-old snapshot spells a field the current form does not. - */ -const medicationScheduleRevisionSchema = z - .object({ - id: z.string().min(1).optional(), - validFrom: isoDateTime, - validUntil: isoDateTime, - payload: z.unknown(), - source: z.string().min(1).optional(), - supersededByIndex: z.number().int().nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * What a medication was supposed to move. - * - * The lab arm is a biomarker NAME rather than an id, the way a lab result's - * cross-reference is: the id is fresh on a portable restore, and `Biomarker` - * is unique on `(userId, name)`. Both arms are nullable because the live - * schema produces a row with neither — deleting a biomarker sets the column - * NULL and leaves the override behind as an orphan the resolver reads as "no - * override". - */ -const medicationEfficacyTargetSchema = z - .object({ - id: z.string().min(1).optional(), - measurementType: z.enum(MeasurementType).nullable().optional(), - biomarkerName: z.string().min(1).nullable().optional(), - primary: z.boolean().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * v1.40 (#1041) — a medication category the account named itself. Carried - * with its decrypted label (the label is re-encrypted on restore under the - * restoring instance's key) and restored BEFORE the medications, whose - * `category` names it by `key`. - */ -const customMedicationCategorySchema = z - .object({ - key: z.string().regex(/^custom:[0-9a-f-]{36}$/), - label: z.string().trim().min(1).max(40), - sortOrder: z.number().int().min(0).default(0), - isActive: z.boolean().default(true), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -const medicationSchema = z - .object({ - id: z.string().min(1).optional(), - // The source row's id in a portable file, which carries no `id`: the - // relation links name the medication by it, and the restore re-points - // them at the row it writes (`relation-links-backup.ts`). - ref: z.string().min(1).optional(), - name: z.string().min(1), - dose: z.string(), - treatmentClass: z.enum(MedicationCategory).optional(), - dosesPerUnit: z.number().int().nullable().optional(), - unitsPerDose: z - .string() - .min(1) - .refine((v) => isSupportedUnitsPerDose(Number(v)), { - message: UNITS_PER_DOSE_MESSAGE, - }) - .optional(), - active: z.boolean().optional(), - notificationsEnabled: z.boolean().optional(), - pausedAt: isoDateTime.nullable().optional(), - snoozedUntil: isoDateTime.nullable().optional(), - startsOn: isoDateTime.nullable().optional(), - endsOn: isoDateTime.nullable().optional(), - oneShot: z.boolean().optional(), - asNeeded: z.boolean().optional(), - // v1.39.1 (#1033) — absent in a backup written before the field existed; - // the restore defaults it to tracking on, the behaviour those had. - trackIntake: z.boolean().optional(), - // v1.42.2 — absent in a backup written before the option existed; the - // restore defaults it off, the behaviour those had. - autoMarkTaken: z.boolean().optional(), - autoMarkTakenSince: isoDateTime.nullable().optional(), - // v1.39.4 — the clinical category. Absent in older files. A string, not - // the enum: a file from a newer server may name a category this one does - // not know, and the restore reads that as OTHER rather than refusing the - // whole file. Since v1.40 it may be `custom:`, the key of an entry - // in `customMedicationCategories`. - category: z.string().max(64).optional(), - deliveryForm: z.enum(MedicationDeliveryForm).optional(), - trackInjectionSites: z.boolean().optional(), - allowedInjectionSites: z.array(z.enum(InjectionSite)).optional(), - liveActivityEnabled: z.boolean().optional(), - criticalAlarmEnabled: z.boolean().optional(), - atcCode: z.string().nullable().optional(), - rxNormCode: z.string().nullable().optional(), - lowStockNotifiedAt: isoDateTime.nullable().optional(), - lowStockNotifiedThresholdDays: z.number().int().nullable().optional(), - reorderLeadDays: z.number().int().nullable().optional(), - externalSource: z.enum(IntakeSource).nullable().optional(), - externalId: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - schedules: z.array(medicationScheduleSchema).default([]), - // Defaulted so a file written before side effects rode the wire still - // parses, and a drug with none writes []. - sideEffects: z.array(medicationSideEffectSchema).default([]), - // Defaulted for the same reason: a file written before pause eras rode the - // wire still parses, and a drug that was never paused writes []. - pauseEras: z.array(medicationPauseEraSchema).default([]), - // v1.43 — defaulted: a file written before compositions travelled, or a - // drug without one, writes []. - ingredients: z.array(medicationIngredientSchema).default([]), - // Same default, same reason: an older file parses, a drug never titrated - // writes []. - doseChanges: z.array(medicationDoseChangeSchema).default([]), - inventoryItems: z.array(medicationInventoryItemSchema).default([]), - inventoryEvents: z.array(medicationInventoryEventSchema).default([]), - phaseConfig: reminderPhaseConfigSchema.nullable().optional(), - // Same default, same reason: an older file parses, and a drug whose - // target the resolver derives rather than the person pinning it writes []. - efficacyTargets: z.array(medicationEfficacyTargetSchema).default([]), - // Same default again. A drug whose plan has never been replaced has no - // archived era, and a file written before the eras travelled has no key. - scheduleRevisions: z.array(medicationScheduleRevisionSchema).default([]), - // v1.40 (#1024) — NOT defaulted: absent means a file written before - // courses existed, and the restore then derives the one course the - // medication's own window describes, exactly as the migration did. - courses: z.array(medicationCourseSchema).optional(), - }) - .passthrough(); - -const intakeEventSchema = z - .object({ - id: z.string().min(1).optional(), - medicationId: z.string().min(1).optional(), - medication: z.string().min(1), - scheduledFor: isoDateTime, - takenAt: isoDateTime.nullable().optional(), - skipped: z.boolean().optional(), - autoMissed: z.boolean().optional(), - // v1.42.2 — absent in older files: every dose there was confirmed. - autoRecorded: z.boolean().optional(), - attributionSource: z.enum(IntakeAttributionSource).optional(), - source: z.enum(IntakeSource).optional(), - idempotencyKey: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - injectionSite: z.enum(InjectionSite).nullable().optional(), - doseTaken: z.string().nullable().optional(), - inventoryConsumption: z.unknown().nullable().optional(), - externalId: z.string().nullable().optional(), - updatedAt: isoDateTime.optional(), - syncVersion: z.number().int().optional(), - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -/** - * `MoodEntry.tags` is stored as a JSON-array-as-string in the - * `mood_entries.tags` column ("[\"work\",\"sleep\"]"). The previous - * schema accepted any `string` here, so a malformed blob in a backup - * (or one tampered with mid-restore) would land in the DB and crash - * downstream readers that `JSON.parse` it. We now refine to one of - * `null` / empty-string (legacy null wire format) / a JSON string - * that parses to a `string[]`. v1.4.15 H2. - */ -const moodEntryTagsSchema = z - .union([z.null(), z.string()]) - .nullable() - .optional() - .refine( - (v) => { - if (v == null || v === "") return true; - try { - const parsed = JSON.parse(v) as unknown; - return ( - Array.isArray(parsed) && parsed.every((x) => typeof x === "string") - ); - } catch { - return false; - } - }, - { message: "tags must be null, empty, or a JSON array of strings" }, - ); - -const moodFactorSchema = z - .object({ - key: z.string().min(1), - rating: z.number().int(), - }) - .passthrough(); - -const moodEntrySchema = z - .object({ - id: z.string().min(1).optional(), - date: z.string().min(1), - mood: z.string().min(1), - score: z.number().int().min(0).max(10), - // The five level-A values. Optional and nullable: a file written before - // they existed carries none, and an entry whose sliders were never touched - // carries nulls. Absent restores as NULL, never as a defaulted midpoint — - // a restore that invents an answer is worse than one that admits none. - a1: z.number().int().min(0).max(10).nullable().optional(), - a2: z.number().int().min(0).max(10).nullable().optional(), - a3: z.number().int().min(0).max(10).nullable().optional(), - a4: z.number().int().min(0).max(10).nullable().optional(), - a5: z.number().int().min(0).max(10).nullable().optional(), - tags: moodEntryTagsSchema, - // The free text, in both storage shapes. A portable export carries the - // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus - // whatever legacy plaintext the row still holds. Same pair, and same - // handling on restore, as `Measurement.notes` / `notesEncrypted`. - note: z.string().nullable().optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - source: z.string().min(1).optional(), - externalId: z.string().nullable().optional(), - loggedAt: isoDateTime, - // The IANA zone the `date` string is anchored to. Absent means the legacy - // Europe/Berlin reading, which is what a row written before v1.4.25 means - // by a NULL here — so an old file keeps its old meaning exactly. - tz: z.string().nullable().optional(), - syncedAt: isoDateTime.optional(), - syncVersion: z.number().int().optional(), - deletedAt: isoDateTime.nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - factors: z.array(moodFactorSchema).default([]), - // BINARY structured-tag keys. Separate from `factors`, which keeps its - // RATED-only meaning, so every file written before this one parses - // unchanged and restores identically: absent reads as none. - structuredTags: z.array(z.string().min(1)).default([]), - // The day context, when the entry had one. Optional and nullable at the - // top: a file written before this existed carries no `context` key at all - // and must parse and restore exactly as it did. Every field inside is - // optional for the same reason a context row's columns are nullable — - // only the sections somebody opened say anything. - // - // The two multi-selects ride as the stored JSON string rather than as - // arrays, so the restore writes the column back verbatim and a file cannot - // arrive with a list the parser and the column disagree about. Nothing is - // re-validated against the vocabulary here: a backup restores what the - // account had, and a key retired between the export and the restore is - // still that person's answer. - context: z - .object({ - workStatus: z.string().nullable().optional(), - workMinutes: z.number().int().nullable().optional(), - overtimeMinutes: z.number().int().nullable().optional(), - workLoad: z.number().int().nullable().optional(), - workSatisfaction: z.number().int().nullable().optional(), - contactCircles: z.string().nullable().optional(), - contactForm: z.string().nullable().optional(), - contactExtent: z.string().nullable().optional(), - contactQuality: z.number().int().nullable().optional(), - contactSupport: z.number().int().nullable().optional(), - leisureCategories: z.string().nullable().optional(), - leisureMinutes: z.number().int().nullable().optional(), - leisureJoy: z.number().int().nullable().optional(), - leisureRecovery: z.number().int().nullable().optional(), - eventType: z.string().nullable().optional(), - eventValence: z.number().int().nullable().optional(), - eventAt: isoDateTime.nullable().optional(), - // Same pair as the entry's own note: a portable file carries the - // plain text, a disaster-recovery file the ciphertext. - note: z.string().nullable().optional(), - notesEncrypted: base64BytesSchema.nullable().optional(), - }) - .passthrough() - .nullable() - .optional(), - }) - .passthrough(); - -/* ── v1.15.0 cycle-tracking backup shapes ──────────────────────────── */ - -/** - * One menstrual-cycle span. `startDate` is the natural per-user key - * (matching the `(userId, startDate)` unique), so a restore upserts on it. - * Predicted (forecast) rows are excluded from the backup — only observed - * history round-trips. - */ -const cycleSpanSchema = z - .object({ - id: z.string().min(1).optional(), - startDate: z.string().min(1), - endDate: z.string().nullable().optional(), - periodEndDate: z.string().nullable().optional(), - lengthDays: z.number().int().nullable().optional(), - ovulationDate: z.string().nullable().optional(), - ovulationConfirmed: z.boolean().optional(), - isPredicted: z.boolean().optional(), - tz: z.string().nullable().optional(), - syncVersion: z.number().int().optional(), - deletedAt: isoDateTime.nullable().optional(), - absorbedIntoId: z.string().min(1).nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * One cycle day-log. `notesEncrypted` is carried as the AES-256-GCM - * ciphertext envelope verbatim — the backup never decrypts it, so the - * owner's free-text note round-trips encrypted (and a wrong-surface leak is - * impossible). `symptomKeys` carries the seeded catalogue keys so the - * restore can re-link without exporting internal join ids, and - * `symptomSeverities` carries how hard each of them hit. - */ -/** - * The intensity recorded against one of a day's symptoms. - * - * A sparse annotation over `symptomKeys`, not a replacement for it: the keys - * still say which symptoms the day had, this says how hard the ones that were - * rated hit. Only rated links appear, so a missing entry means the person never - * put a number on it. `severity` is left as a plain integer here on purpose — - * the 1-4 range is checked when the row is written, so a file carrying a value - * outside it loses that one intensity instead of failing to restore at all. - */ -const cycleSymptomSeveritySchema = z - .object({ - key: z.string().min(1), - severity: z.number().int().nullable().optional(), - }) - .passthrough(); - -/** The sensitive-category envelope's five fields, as a portable file carries them. */ -const cycleSensitiveFieldsSchema = z.object({ - sexualActivity: z.boolean().optional(), - protectedSex: z.boolean().nullable().optional(), - pregnancyTest: z.enum(HomeTestResult).nullable().optional(), - progesteroneTest: z.enum(HomeTestResult).nullable().optional(), - contraceptive: z.enum(ContraceptiveKind).nullable().optional(), -}); - -const cycleDayLogSchema = z - .object({ - id: z.string().min(1).optional(), - date: z.string().min(1), - cycleId: z.string().nullable().optional(), - flow: z.enum(FlowLevel).nullable().optional(), - intermenstrualBleeding: z.boolean().optional(), - basalBodyTempC: z.number().nullable().optional(), - temperatureExcluded: z.boolean().optional(), - ovulationTest: z.enum(OvulationTest).nullable().optional(), - cervicalMucus: z.enum(CervicalMucus).nullable().optional(), - cervixPosition: z.enum(CervixPosition).nullable().optional(), - cervixFirmness: z.enum(CervixFirmness).nullable().optional(), - cervixOpening: z.enum(CervixOpening).nullable().optional(), - sexualActivity: z.boolean().optional(), - protectedSex: z.boolean().nullable().optional(), - pregnancyTest: z.enum(HomeTestResult).nullable().optional(), - progesteroneTest: z.enum(HomeTestResult).nullable().optional(), - contraceptive: z.enum(ContraceptiveKind).nullable().optional(), - // Free text travels like every other note: a disaster-recovery file - // carries the ciphertext (`*Encrypted`), a portable file the readable - // value (`note`, `sensitive`), which the restore seals under the - // receiving host's key. A portable file written before v1.40 carries the - // ciphertext too; the restore keeps only what this host can open. - sensitiveEncrypted: z.string().nullable().optional(), - notesEncrypted: z.string().nullable().optional(), - note: z.string().nullable().optional(), - // The five intent fields the sensitive-category envelope holds, or the - // unreadable marker when the writing host could not open the envelope. - sensitive: z - .union([cycleSensitiveFieldsSchema, z.string()]) - .nullable() - .optional(), - source: z.enum(MeasurementSource).optional(), - externalId: z.string().nullable().optional(), - tz: z.string().nullable().optional(), - syncVersion: z.number().int().optional(), - deletedAt: isoDateTime.nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - symptomKeys: z.array(z.string()).default([]), - symptomSeverities: z.array(cycleSymptomSeveritySchema).default([]), - }) - .passthrough(); - -/** Cycle-tracking preferences (one row per user). */ -const cycleProfileSchema = z - .object({ - id: z.string().min(1).optional(), - goal: z.enum(CycleTrackingGoal).optional(), - cycleTrackingEnabled: z.boolean().nullable().optional(), - typicalCycleLength: z.number().int().nullable().optional(), - typicalPeriodLength: z.number().int().nullable().optional(), - lutealPhaseLength: z.number().int().nullable().optional(), - secondarySymptom: z.enum(SecondarySymptom).nullable().optional(), - predictionEnabled: z.boolean().optional(), - rawChartMode: z.boolean().optional(), - discreetNotifications: z.boolean().optional(), - sensitiveCategoryEncryption: z.boolean().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * A mood tag the account created, as opposed to the seeded catalogue. - * - * Carried for the same reason as a custom cycle symptom, but the failure it - * prevents is louder: the restore resolves an entry's rated factors by key and - * throws on one it cannot find, so an account with a single custom rated tag - * could not be restored at all. - */ -const customMoodTagSchema = z - .object({ - id: z.string().min(1).optional(), - key: z.string().min(1), - labelKey: z.string().min(1), - categoryId: z.string().min(1), - // "BINARY" | "RATED", enforced in app code rather than a DB enum. - kind: z.string().min(1), - isActive: z.boolean().default(true), - icon: z.string().nullable().optional(), - sortOrder: z.number().int().default(0), - // The user's own words for the tag. Without it the tag comes back as a - // bare key and the person who named it "Migräne" gets `custom:cm3x9…` - // instead. Ciphertext on a disaster-recovery file, readable `label` on a - // portable one; the restore seals the readable value under its own key. - labelEncrypted: z.string().nullable().optional(), - label: z.string().nullable().optional(), - // The scale a RATED factor was recorded on. `inverse` marks a factor - // where a HIGH value is bad (stress, conflict). Dropping it does not lose - // a label, it silently reverses the meaning of every rating already - // stored against that tag. - scaleMin: z.number().int().default(1), - scaleMax: z.number().int().default(5), - inverse: z.boolean().default(false), - }) - .passthrough(); - -/** - * A day's total for one nutrient, from one source. - * - * The export has written these since v1.29 and the canonical schema never - * declared them, so `.passthrough()` carried the key through parsing and no - * reader ever looked at it. The restore therefore dropped every water and - * vitamin total on the floor while reporting success. - */ -const nutrientDaySchema = z - .object({ - day: z.string().min(1), - nutrient: z.string().min(1), - amount: z.number(), - unit: z.string().min(1), - // Closed set in app code rather than a DB enum; kept as a string here so a - // future source does not make an old file unparseable. - source: z.string().min(1), - // Absent from files written before v1.39.7. - externalSourceVersion: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * A symptom the account created, as opposed to the seeded catalogue. - * - * Carried because a day-log's symptom link resolves by key: without the - * definition the key resolves to nothing on the restoring instance, and the - * link is lost. The seeded rows are deliberately NOT carried — every instance - * already has them, and shipping them would mean a restore could rewrite - * another instance's reference data. - */ -const customCycleSymptomSchema = z - .object({ - id: z.string().min(1).optional(), - key: z.string().min(1), - labelKey: z.string().min(1), - categoryId: z.string().min(1), - icon: z.string().nullable().optional(), - sortOrder: z.number().int().default(0), - isActive: z.boolean().default(true), - // Same reasoning as the mood tag's: the user's own words live here. - // Ciphertext on a disaster-recovery file, readable `label` on a portable - // one (the day-log note's split). - labelEncrypted: z.string().nullable().optional(), - label: z.string().nullable().optional(), - }) - .passthrough(); - -/** - * The account's durable self-context — one row per user. - * - * Portable exports carry the decrypted free text; disaster-recovery payloads - * carry the AES-256-GCM envelopes as base64 and leave the plaintext fields - * null. The restore prefers ciphertext when the file has it and re-encrypts - * the plaintext otherwise, which is the same contract the lab note uses. - */ -const healthProfileBackupSchema = z - .object({ - id: z.string().min(1).optional(), - aboutMe: z.string().nullable().default(null), - conditions: z.string().nullable().default(null), - allergies: z.string().nullable().default(null), - coachFocus: z.string().nullable().default(null), - // Every stored section, the four life-circumstance ones included (v1.43): - // a file written by an account that opted one in must restore it opted in. - aiIncludedSections: z - .array(storedHealthProfileAiSectionSchema) - .default([...DEFAULT_HEALTH_PROFILE_AI_SECTIONS]), - // v1.43 (#614) — section confirmations, section key to ISO instant. - // Carried as stored; the read route judges each stamp, so an unreadable - // one costs a "still true?" prompt rather than the whole restore. - lifeConfirmedJson: z - .record(z.string().max(64), z.string().max(64)) - .nullable() - .optional(), - // Emergency profile: three plaintext enums (carried by value in both - // purposes), three free-text columns following the ciphertext-or-plaintext - // split of the self-context fields above. - emergencyBloodType: emergencyBloodTypeSchema.nullable().default(null), - organDonorStatus: organDonorStatusSchema.nullable().default(null), - advanceDirectiveStatus: advanceDirectiveStatusSchema - .nullable() - .default(null), - emergencyContacts: z.string().nullable().default(null), - emergencyImplants: z.string().nullable().default(null), - emergencyNote: z.string().nullable().default(null), - emergencyContactsEncrypted: base64BytesSchema.nullable().optional(), - emergencyImplantsEncrypted: base64BytesSchema.nullable().optional(), - emergencyNoteEncrypted: base64BytesSchema.nullable().optional(), - aboutMeEncrypted: base64BytesSchema.nullable().optional(), - conditionsEncrypted: base64BytesSchema.nullable().optional(), - allergiesEncrypted: base64BytesSchema.nullable().optional(), - coachFocusEncrypted: base64BytesSchema.nullable().optional(), - pendingQuestionsEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const healthProfileFactBackupSchema = z - .object({ - id: z.string().min(1), - kind: z.enum(STORED_FACT_KINDS), - value: z.string().nullable().default(null), - valueEncrypted: base64BytesSchema.optional(), - validFrom: isoDateTime, - validUntil: isoDateTime.nullable(), - provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), - supersededByRevisionId: z.string().min(1).nullable(), - createdAt: isoDateTime, - }) - .passthrough() - .superRefine((fact, ctx) => { - if (fact.valueEncrypted === undefined) { - if ( - fact.value === null || - !isStoredFactPlaintext(fact.kind, fact.value) - ) { - ctx.addIssue({ - code: "custom", - path: ["value"], - message: "A portable profile fact requires a valid readable value", - }); - } - } - if ( - fact.validUntil !== null && - Date.parse(fact.validUntil) <= Date.parse(fact.validFrom) - ) { - ctx.addIssue({ - code: "custom", - path: ["validUntil"], - message: "validUntil must be later than validFrom", - }); - } - }); - -/** One reading of a user-defined metric, nested under the metric that owns it. */ -const customMetricEntryBackupSchema = z - .object({ - id: z.string().min(1).optional(), - value: z.number(), - unit: z.string().min(1), - measuredAt: isoDateTime, - note: z.string().nullable().default(null), - /// v1.39.3 — the note's ciphertext, DR payloads only. - noteEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime.optional(), - /// v1.37.20 — the entry tombstone rides DR payloads so a restore - /// brings the account back exactly as it stood, undo affordance included. - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -/** - * A metric the account defined itself. - * - * The one class of series no integration can ever re-sync: nobody else has the - * definition and nobody else has the readings. It was classified as carried - * from the day the backup plan was written and carried by nothing, so a - * restore rebuilt the account without it and reported success. - * - * Readings are nested rather than flat-with-a-name-reference so the restore - * has no parent to look up and therefore no lookup to miss. - */ -const customMetricBackupSchema = z - .object({ - id: z.string().min(1).optional(), - name: z.string().min(1), - unit: z.string().min(1), - targetLow: z.number().nullable().default(null), - targetHigh: z.number().nullable().default(null), - decimals: z.number().int().nullable().default(null), - description: z.string().nullable().default(null), - correlationEnabled: z.boolean().default(false), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - deletedAt: isoDateTime.nullable().optional(), - entries: z.array(customMetricEntryBackupSchema).default([]), - }) - .passthrough(); - -const correlationPatternBackupSchema = z - .object({ - id: z.string().min(1).optional(), - canonicalKey: z.string().regex(/^p1:[a-f0-9]{64}$/), - family: z.string().min(1), - factorKey: z.string().min(1), - outcomeKey: z.string().min(1), - lagDays: z.number().int().min(0), - sampleSize: z.number().int().positive(), - effectSize: z.number().finite(), - pValue: z.number().min(0).max(1), - qValue: z.number().min(0).max(1).nullable().default(null), - evidenceHash: z.string().regex(/^[a-f0-9]{64}$/), - isCurrent: z.boolean(), - lastComputedAt: isoDateTime, - dismissedAt: isoDateTime.nullable().default(null), - dismissedEvidenceHash: z - .string() - .regex(/^[a-f0-9]{64}$/) - .nullable() - .default(null), - dismissedEffectSize: z.number().finite().nullable().default(null), - dismissedSampleSize: z.number().int().positive().nullable().default(null), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * One local day's cumulative curve for one metric. - * - * The drain writes this row and deletes the per-sample rows it was folded from - * in the same transaction, so past the grace window the file is the only copy. - * `hourlyCumulative` is left unconstrained in length here on purpose: the - * restore checks it against the slot count it shares with the reader and - * throws naming the day, which says what is wrong with one row instead of - * making a whole file unparseable. - */ -const intradayProfileBackupSchema = z - .object({ - id: z.string().min(1).optional(), - type: z.enum(MeasurementType), - dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - hourlyCumulative: z.array(z.number()), - dayTotal: z.number(), - sampleCount: z.number().int(), - // v1.43 (ios #77) — the hours the phone posted, what the curve was built - // from, and how many hours there were. Defaulted so a file written before - // the hours existed restores as a fold of single samples, which it was. - hourlyIncrements: z.array(z.number()).default([]), - basis: z.enum(["PER_SAMPLE", "HOUR_BUCKETS"]).default("PER_SAMPLE"), - bucketCount: z.number().int().nonnegative().nullable().default(null), - // The zone the day was cut on. The reader compares it against the - // account's current zone and drops the mismatched days, so a restore that - // dropped this column would make old curves look comparable when their - // hours mean something else. - timezone: z.string().min(1), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const healthScoreRecordBackupSchema = z - .object({ - id: z.string().min(1).optional(), - dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - // The zone the day was cut on. An account that moved zones has days of - // different lengths behind it, and a restore that guessed would make them - // look comparable. - timezone: z.string().min(1), - composite: z.number().int().min(0).max(100), - // The same closed set the column's CHECK constraint carries. A band the - // schema let through and the database refused would fail the whole - // restore transaction on one bad row. - band: z.enum(["green", "yellow", "red"]), - scoreVersion: z.number().int(), - composition: z.array(z.string().min(1)).min(1), - pillarScores: z.record(z.string(), z.number()), - inputFingerprint: z.string().regex(/^[0-9a-f]{64}$/), - configVersion: z.number().int().nullable().default(null), - configChangedAt: isoDateTime.nullable().default(null), - computedAt: isoDateTime, - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * v1.39 — the needs-based setup state. One row per record. - * - * The three answer objects are validated loosely on purpose: the closed - * vocabularies live in `src/lib/onboarding/needs.ts`, and the restore reads - * every one of them back through the same fail-soft parsers the account - * payload uses. Duplicating the enums here would give the file a second, - * quietly divergent opinion about what an answer is, and the failure mode of - * being stricter here is refusing a whole account's restore over one retired - * chip. - */ -const onboardingRecordBackupSchema = z - .object({ - id: z.string().min(1).optional(), - needs: z.record(z.string(), z.unknown()).default({}), - steps: z.array(z.record(z.string(), z.unknown())).default([]), - firstResult: z.record(z.string(), z.unknown()).nullable().default(null), - // The once-only latch on the module derivation. Carried rather than - // defaulted: a null here would hand the next confirm permission to - // re-apply the questionnaire over every module decision made since. - modulesDerivedAt: isoDateTime.nullable().default(null), - completedAt: isoDateTime.nullable().default(null), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const appSettingsBackupSchema = z - .object({ - id: z.string().min(1), - registrationEnabled: z.boolean(), - mfaRequired: z.boolean(), - defaultLocale: z.string(), - telegramGlobal: z.boolean(), - ntfyGlobal: z.boolean(), - webPushGlobal: z.boolean(), - webPushVapidPublicKey: z.string().nullable(), - webPushVapidPrivateKeyEncrypted: z.string().nullable(), - webPushVapidSubject: z.string().nullable(), - apiGlobal: z.boolean(), - umamiEnabled: z.boolean(), - umamiScriptUrl: z.string().nullable(), - umamiWebsiteId: z.string().nullable(), - glitchtipEnabled: z.boolean(), - glitchtipDsn: z.string().nullable(), - glitchtipEnvironment: z.string().nullable(), - reminderLateMinutes: z.number().int(), - reminderMissedMinutes: z.number().int(), - adminAiKeyEncrypted: z.string().nullable(), - adminAiModel: z.string(), - adminAiBaseUrl: z.string(), - adminCodexAccessTokenEncrypted: z.string().nullable(), - adminCodexRefreshTokenEncrypted: z.string().nullable(), - adminCodexAccountIdEncrypted: z.string().nullable(), - adminCodexTokenExpiresAt: isoDateTime.nullable(), - adminCodexConnectedAt: isoDateTime.nullable(), - adminCodexConnectionStatus: z.string(), - adminAiInsightsFeedbackSummary: z.unknown().nullable(), - defaultUserTimezone: z.string().nullable(), - assistantEnabled: z.boolean(), - assistantCoachEnabled: z.boolean(), - assistantBriefingEnabled: z.boolean(), - assistantInsightStatusEnabled: z.boolean(), - // Optional: a backup written before the switch existed carries none, and - // restores it at its default. A backup from before the Correlations switch - // was retired still carries that key; `passthrough` keeps it harmless and - // the restore writes nothing for it. - assistantDocumentAiEnabled: z.boolean().optional(), - // v1.41 — optional for the same reason. - aiReasoningEnabled: z.boolean().optional(), - aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), - moduleAvailabilityJson: z.unknown().nullable(), - documentMaxFileBytes: z.number().int(), - documentQuotaBytes: z.string().regex(/^\d+$/), - }) - .passthrough(); - -/* ── Structured-record disaster-recovery shapes ───────────────────── - * - * These shapes serve both the historical portable export and the canonical - * weekly/off-host disaster-recovery payload. Portable document entries remain - * metadata-only. Canonical entries additionally carry encrypted BYTEA values - * as base64 plus the codec/hash fields required to recreate InboundDocument - * without decrypting or fabricating content. - */ - -const labResultBackupSchema = z - .object({ - id: z.string().min(1).optional(), - // The source row's id in a portable file, as on a medication. - ref: z.string().min(1).optional(), - panel: z.string().nullable().optional(), - analyte: z.string().min(1), - value: z.number().nullable().optional(), - valueText: z.string().nullable().optional(), - // A qualitative reading ("positive", "trace") has no unit and is stored - // under its marker's empty one (`POST /api/labs`); the refine below keeps - // the unit required for every numeric reading. - unit: z.string(), - referenceLow: z.number().nullable().optional(), - referenceHigh: z.number().nullable().optional(), - // Optional so a backup written before these columns existed still - // restores; absent reads as "this reading had no source window on file". - sourceReferenceLow: z.number().nullable().optional(), - sourceReferenceHigh: z.number().nullable().optional(), - sourceReferenceText: z.string().nullable().optional(), - // v1.42 (#1095) — the value and unit as printed, kept beside a converted - // reading. Optional: absent on every file written before the columns. - sourceValue: z.number().nullable().optional(), - sourceUnit: z.string().nullable().optional(), - takenAt: isoDateTime, - source: z.string().min(1), - biomarkerName: z.string().nullable().optional(), - biomarkerId: z.string().nullable().optional(), - note: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - deletedAt: isoDateTime.nullable().optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough() - .superRefine((reading, ctx) => { - const qualitative = - (reading.value === null || reading.value === undefined) && - typeof reading.valueText === "string" && - reading.valueText.length > 0; - if (reading.unit.length === 0 && !qualitative) { - ctx.addIssue({ - code: "custom", - path: ["unit"], - message: "A numeric lab reading needs its unit", - }); - } - }); - -const biomarkerBackupSchema = z - .object({ - id: z.string().min(1).optional(), - name: z.string().min(1), - // Empty for a qualitative marker, minted from a reading with no unit. The - // payload refine below refuses an empty unit on a marker a numeric - // reading is filed under. - unit: z.string(), - lowerBound: z.number().nullable().optional(), - upperBound: z.number().nullable().optional(), - panel: z.string().nullable().optional(), - hidden: z.boolean().optional(), - // v1.42 (#1095) — the catalogue analyte the marker resolves to. - analyteKey: z.string().nullable().optional(), - context: z.string().nullable().optional(), - // v1.43 — the other spellings the marker answers to. Absent on - // files written before them. - aliases: z - .array( - z - .object({ - id: z.string().min(1).optional(), - name: z.string().min(1).max(120), - source: z.enum(["MERGE", "IMPORT_CHOICE", "MANUAL"]), - createdAt: isoDateTime.optional(), - }) - .passthrough(), - ) - .optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -// v1.43 — the ledger of markers merged into others. -const biomarkerMergeBackupSchema = z - .object({ - id: z.string().min(1).optional(), - targetBiomarkerId: z.string().min(1).optional(), - targetName: z.string().min(1), - sourceBiomarkerId: z.string().min(1), - sourceName: z.string().min(1).max(120), - readingCount: z.number().int().min(0), - mergedAt: isoDateTime, - }) - .passthrough(); - -const illnessSymptomBackupSchema = z - .object({ - key: z.string().min(1), - severity: z.number().int().nullable().optional(), - }) - .passthrough(); - -const illnessDayLogBackupSchema = z - .object({ - id: z.string().min(1).optional(), - episodeId: z.string().min(1).optional(), - date: z.string().min(1), - functionalImpact: z.number().int().nullable().optional(), - feverC: z.number().nullable().optional(), - symptoms: z.array(illnessSymptomBackupSchema).default([]), - note: z.string().nullable().optional(), - updatedAt: isoDateTime.optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - tz: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const illnessEpisodeBackupSchema = z - .object({ - id: z.string().min(1), - label: z.string().min(1), - type: z.enum(IllnessType), - lifecycle: z.enum(IllnessLifecycle), - onsetAt: isoDateTime, - resolvedAt: isoDateTime.nullable().optional(), - // Self-referencing flare/exacerbation link, carried as the exported - // episode's own id — never resolved against another user's rows. - parentConditionId: z.string().nullable().optional(), - note: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - // v1.39.2 — the body site and side. Optional so a file written before - // they existed still parses; the condition restores with neither. The - // site rides as ciphertext in a disaster-recovery file and as plaintext - // in a portable one, exactly like the note beside it. - bodySite: z.string().max(200).nullable().optional(), - bodySiteEncrypted: base64BytesSchema.nullable().optional(), - laterality: z.enum(Laterality).nullable().optional(), - deletedAt: isoDateTime.nullable().optional(), - updatedAt: isoDateTime.optional(), - dayLogs: z.array(illnessDayLogBackupSchema).default([]), - }) - .passthrough(); - -/** - * A person-defined symptom (v1.40) with every occurrence of it nested inside, - * the way a medication carries its side effects. `label` / `labelEncrypted` - * and `note` / `noteEncrypted` are the two ends of the free-text contract: a - * portable file carries the plaintext, a disaster-recovery file the ciphertext. - * An occurrence's `episodeId` is the exported episode's own id; the restore - * nulls one that names an episode the file does not carry. - */ -const symptomEventBackupSchema = z - .object({ - id: z.string().min(1).optional(), - occurredAt: isoDateTime, - intensity: z.number().int().min(0).max(10), - note: z.string().nullable().optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - episodeId: z.string().min(1).nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - }) - .passthrough(); - -const symptomDefinitionBackupSchema = z - .object({ - id: z.string().min(1).optional(), - label: z.string().min(1).optional(), - labelEncrypted: base64BytesSchema.optional(), - icon: z.string().nullable().optional(), - sortOrder: z.number().int(), - isActive: z.boolean(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - events: z.array(symptomEventBackupSchema).default([]), - }) - .passthrough() - .refine((d) => d.label !== undefined || d.labelEncrypted !== undefined, { - message: "A symptom definition needs a label or its ciphertext", - }); - -/** - * A life event (v1.42, #613). `title` / `titleEncrypted` and `note` / - * `noteEncrypted` are the two ends of the free-text contract: a portable file - * carries the plaintext, a disaster-recovery file the ciphertext and the - * tombstone. Dates are calendar strings. - */ -const lifeEventBackupSchema = z - .object({ - id: z.string().min(1).optional(), - category: z.enum(LifeEventCategory), - startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - endDate: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .nullable() - .optional(), - precision: z.enum(LifeEventPrecision), - title: z.string().min(1).optional(), - titleEncrypted: base64BytesSchema.optional(), - note: z.string().nullable().optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough() - .refine((e) => e.title !== undefined || e.titleEncrypted !== undefined, { - message: "A life event needs a title or its ciphertext", - }); - -/** - * A practitioner, an encounter, and the edges between an encounter and the - * things it produced. - * - * `id` is required on both records, unlike the lab result above: a link - * addresses an encounter and its far side by id and nothing else on either row - * is unique enough to rebuild the reference from. Free text follows the note - * contract: ciphertext as base64 on a disaster-recovery file, the readable - * value on a portable one (sealed under the receiving host's key on restore). - */ -const practitionerBackupSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - specialty: z.string().nullable().optional(), - practice: z.string().nullable().optional(), - location: z.string().nullable().optional(), - phone: z.string().nullable().optional(), - /// v1.39.4 — the address and phone ciphertext, DR payloads only. - locationEncrypted: base64BytesSchema.nullable().optional(), - phoneEncrypted: base64BytesSchema.nullable().optional(), - noteEncrypted: base64BytesSchema.nullable().optional(), - note: z.string().nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const encounterBackupSchema = z - .object({ - id: z.string().min(1), - occurredAt: isoDateTime, - status: z.enum(EncounterStatus), - kind: z.enum(EncounterKind), - practitionerId: z.string().nullable().optional(), - reasonEncrypted: base64BytesSchema.nullable().optional(), - outcomeEncrypted: base64BytesSchema.nullable().optional(), - // v1.39.1 — the procedure's body site and side. Optional so a file written - // before they existed still parses; the visit restores with neither. - bodySiteEncrypted: base64BytesSchema.nullable().optional(), - // The readable reason, outcome and body site of a portable file. - reason: z.string().nullable().optional(), - outcome: z.string().nullable().optional(), - bodySite: z.string().nullable().optional(), - laterality: z.enum(Laterality).nullable().optional(), - // Remapped against the restored reminders (they travel since v1.37.20); - // dropped to NULL, with the drop named, only when the file lacks the row. - reminderId: z.string().nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const encounterLinkBackupSchema = z - .object({ - encounterId: z.string().min(1), - targetId: z.string().min(1), - createdAt: isoDateTime, - }) - .passthrough(); - -/** - * One administered dose. - * - * `antigenSlug` is a free string here rather than an enum of the catalogue - * this release ships, deliberately: a slug the catalogue has since dropped - * must still restore, and the renderer degrades to `vaccineName`. Validating - * it at the restore boundary would refuse a file the app itself wrote. - */ -const vaccinationBackupSchema = z - .object({ - id: z.string().min(1), - occurredAt: isoDateTime, - antigenSlug: z.string().nullable().optional(), - vaccineName: z.string().nullable().optional(), - doseNumber: z.number().int().nullable().optional(), - seriesDoses: z.number().int().nullable().optional(), - lotNumber: z.string().nullable().optional(), - site: z.enum(VaccinationSite).nullable().optional(), - practitionerId: z.string().nullable().optional(), - encounterId: z.string().nullable().optional(), - // Remapped against the restored reminders (they travel since v1.37.20); - // dropped to NULL, with the drop named, only when the file lacks the row. - reminderId: z.string().nullable().optional(), - // v1.42 (#1005) — the person's own vaccine definition the dose names; - // remapped against the restored definitions like `reminderId`. - customVaccineId: z.string().nullable().optional(), - // Ciphertext on a disaster-recovery file, readable on a portable one. - noteEncrypted: base64BytesSchema.nullable().optional(), - note: z.string().nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -/** - * One of the person's own vaccine definitions (v1.42, #1005). `components` - * is a list of free strings rather than the antigen enum, for the reason - * `antigenSlug` is: a file the app wrote must restore. - */ -const customVaccineBackupSchema = z - .object({ - id: z.string().min(1), - name: z.string().min(1), - components: z.array(z.string()).optional(), - typicalSeriesDoses: z.number().int().nullable().optional(), - boosterIntervalMonths: z.number().int().nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const vaccinationLinkBackupSchema = z - .object({ - vaccinationId: z.string().min(1), - targetId: z.string().min(1), - createdAt: isoDateTime, - }) - .passthrough(); - -/** - * v1.43 — one row of a relation link table: its two ends, by the column - * names of the table, and the instant it was filed. - */ -function relationLinkBackupSchema( - a: A, - b: B, -) { - return z - .object({ - [a]: z.string().min(1), - [b]: z.string().min(1), - createdAt: isoDateTime, - } as { [K in A | B]: z.ZodString } & { createdAt: typeof isoDateTime }) - .passthrough(); -} - -/** - * One Vorsorge reminder, and one row of its completion ledger (v1.37.20, - * #223 / iOS #68). - * - * `id` is required on both: an encounter, a vaccination record and every - * ledger row address a reminder by it, and a ledger row is addressed by its - * own id on re-insert. The enums are exactly the API's own — `origin` and - * `kind` are the Prisma enums, and the ledger `source` is validated against - * the engine's closed call-site set rather than accepted as free text, so a - * file cannot smuggle a value the engine itself would never write. - */ -const measurementReminderBackupSchema = z - .object({ - id: z.string().min(1), - label: z.string().min(1), - measurementType: z.enum(MeasurementType).nullable().optional(), - intervalDays: z.number().int().nullable().optional(), - rrule: z.string().nullable().optional(), - anchorDate: isoDateTime.nullable().optional(), - endsOn: isoDateTime.nullable().optional(), - origin: z.enum(ReminderOrigin).optional(), - notifyHour: z.number().int().min(0).max(23).optional(), - location: z.string().nullable().optional(), - // Server-computed and restored verbatim — recomputing on restore would - // move a due date the person had already been shown. - nextDueAt: isoDateTime.nullable().optional(), - lastSatisfiedAt: isoDateTime.nullable().optional(), - enabled: z.boolean().optional(), - vaccinationAntigen: z.string().nullable().optional(), - snoozedUntil: isoDateTime.nullable().optional(), - lastSkippedAt: isoDateTime.nullable().optional(), - skipCount: z.number().int().min(0).optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const measurementReminderEventBackupSchema = z - .object({ - id: z.string().min(1), - reminderId: z.string().min(1), - kind: z.enum(MeasurementReminderEventKind), - occurredAt: isoDateTime, - onTime: z.boolean(), - source: z.enum(REMINDER_EVENT_SOURCES), - createdAt: isoDateTime, - }) - .passthrough(); - -/** - * One personal best. - * - * `direction` is required rather than defaulted: it says whether higher or - * lower wins, the read path orders by it, and a best time defaulted to MAX - * would present the account's worst time as its record. `sourceMeasurementId` - * is the provenance pointer the restore resolves, and a real foreign key in the - * database, whatever `prisma/schema.prisma` says, so the restore nulls what it - * cannot resolve rather than letting Postgres refuse the whole file. - */ -const personalRecordBackupSchema = z - .object({ - metricType: z.enum(MeasurementType), - metricSlot: z.string().nullable().optional(), - direction: z.enum(PersonalRecordDirection), - value: z.number(), - unit: z.string().min(1), - achievedAt: isoDateTime, - sourceMeasurementId: z.string().nullable().optional(), - source: z.enum(MeasurementSource).optional(), - externalId: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * One unlocked badge. - * - * `achievementId` is a free string on purpose: it names a definition in the - * code catalogue, which drifts between the release that wrote the file and the - * one that reads it, and a row is evidence that a person earned something on a - * day rather than a claim about what this build ships. - * - * `unlockedAt` is REQUIRED, unlike almost every other stamp in this file. It - * is the field that cannot be recovered from anywhere else, and a file that - * does not state it must fail rather than restore every badge to today. - */ -const userAchievementBackupSchema = z - .object({ - achievementId: z.string().min(1), - unlockedAt: isoDateTime, - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** `YYYY-MM-DD`, the day key both environment tables are addressed by. */ -const environmentDayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { - message: "Expected a YYYY-MM-DD day key", -}); - -/** - * One day's environmental reading. - * - * `source` is required: it is the only record that the day resolved somewhere - * other than the home city, and a defaulted value would re-attribute a trip to - * home. Every weather column is optional, because the feed is allowed to be partial - * and a file written before a column existed says nothing about it. - */ -const environmentContextBackupSchema = z - .object({ - date: environmentDayKey, - // Nullable since v1.42 (migration 0382): once the encryption backfill has - // run, the location travels sealed in `locationEncrypted` instead. - lat: z.number().nullable(), - lon: z.number().nullable(), - locationLabel: z.string().nullable(), - locationEncrypted: base64BytesSchema.nullable().optional(), - source: z.enum(EnvironmentLocationSource), - tempMin: z.number().nullable().optional(), - tempMax: z.number().nullable().optional(), - tempMean: z.number().nullable().optional(), - apparentMean: z.number().nullable().optional(), - sunshineSec: z.number().int().nullable().optional(), - daylightSec: z.number().int().nullable().optional(), - precipSum: z.number().nullable().optional(), - pressureMean: z.number().nullable().optional(), - pressureDelta: z.number().nullable().optional(), - humidityMean: z.number().nullable().optional(), - cloudMean: z.number().nullable().optional(), - weatherCode: z.number().int().nullable().optional(), - // v1.42 (#615) — air quality, pollen and UV. Optional throughout. - apparentMax: z.number().nullable().optional(), - pm25Mean: z.number().nullable().optional(), - pm25Max: z.number().nullable().optional(), - pm10Mean: z.number().nullable().optional(), - no2Mean: z.number().nullable().optional(), - so2Mean: z.number().nullable().optional(), - coMean: z.number().nullable().optional(), - o3Max8h: z.number().nullable().optional(), - eaqiMax: z.number().nullable().optional(), - usaqiMax: z.number().nullable().optional(), - uvIndexMax: z.number().nullable().optional(), - dustMax: z.number().nullable().optional(), - aodMax: z.number().nullable().optional(), - pollenAlderMax: z.number().nullable().optional(), - pollenBirchMax: z.number().nullable().optional(), - pollenGrassMax: z.number().nullable().optional(), - pollenMugwortMax: z.number().nullable().optional(), - pollenOliveMax: z.number().nullable().optional(), - pollenRagweedMax: z.number().nullable().optional(), - aqDomain: z.string().nullable().optional(), - aqHours: z.number().int().nullable().optional(), - aqFetchedAt: isoDateTime.nullable().optional(), - fetchedAt: isoDateTime.optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * One declared stretch spent away from home. Both bounds are day keys rather - * than instants: the resolver compares them lexicographically against other - * day keys, and routing one through a `Date` is how a day key loses a day. - */ -const environmentTravelLocationBackupSchema = z - .object({ - startDate: environmentDayKey, - endDate: environmentDayKey, - // Nullable since v1.42 (migration 0382), as above. - lat: z.number().nullable(), - lon: z.number().nullable(), - label: z.string().nullable(), - locationEncrypted: base64BytesSchema.nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * v1.43 — one local day of Dawarich movement values. Daily values only; the - * country rides readable (portable) or sealed (disaster recovery). - */ -const dawarichDayBackupSchema = z - .object({ - date: environmentDayKey, - totalDistanceM: z.number().int().nonnegative().nullable().optional(), - drivingDistanceM: z.number().int().nonnegative().nullable().optional(), - trainDistanceM: z.number().int().nonnegative().nullable().optional(), - placesVisited: z.number().int().nonnegative().nullable().optional(), - awayFromHomeMinutes: z - .number() - .int() - .min(0) - .max(25 * 60) - .nullable() - .optional(), - countryCode: z - .string() - .regex(/^[A-Z]{2}$/) - .nullable() - .optional(), - countryCodeEncrypted: base64BytesSchema.nullable().optional(), - isTravelDay: z.boolean().nullable().optional(), - fetchedAt: isoDateTime.optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * A turn's stored tables, bounded at restore. The live path never stores more - * than `RESULTS_MAX_BYTES` of table JSON per turn (`fitResultsToStorage`), so - * a file this release writes always fits; a larger value is a damaged or - * crafted file, refused with the rest of it like any other malformed field - * rather than sealed into the database unbounded. The sealed form is the - * ciphertext of at most that much JSON, base64-encoded in the file: about - * 1.8 times the plaintext, so twice the plaintext bound caps it. - */ -const COACH_RESULTS_SEALED_MAX_CHARS = 2 * RESULTS_MAX_BYTES; - -const coachResultsJsonSchema = z - .string() - .refine( - (value) => new TextEncoder().encode(value).byteLength <= RESULTS_MAX_BYTES, - { message: `Coach result tables exceed ${RESULTS_MAX_BYTES} bytes` }, - ); - -const coachResultsSealedSchema = base64BytesSchema.refine( - (value) => value.length <= COACH_RESULTS_SEALED_MAX_CHARS, - { message: "Coach result tables exceed the stored size" }, -); - -/** The trail's model text, bounded at restore like the tables above. */ -const COACH_TRAIL_SEALED_MAX_CHARS = 2 * TRAIL_MAX_BYTES; - -const coachTrailJsonSchema = z - .string() - .refine( - (value) => new TextEncoder().encode(value).byteLength <= TRAIL_MAX_BYTES, - { message: `Coach trail exceeds ${TRAIL_MAX_BYTES} bytes` }, - ); - -const coachTrailSealedSchema = base64BytesSchema.refine( - (value) => value.length <= COACH_TRAIL_SEALED_MAX_CHARS, - { message: "Coach trail exceeds the stored size" }, -); - -/** - * One Coach turn. - * - * `contentEncrypted` and `content` are the two ends of the same contract, so - * both are optional here and exactly one arrives: a disaster-recovery file - * carries the ciphertext, a portable file carries the prose. Requiring either - * would refuse half the valid files; requiring both would refuse all of them. - * The restore picks whichever it was handed. - */ -const coachMessageBackupSchema = z - .object({ - id: z.string().min(1), - role: z.string().min(1), - contentEncrypted: base64BytesSchema.optional(), - content: z.string().optional(), - metricSourceJson: z.string().nullable().optional(), - providerType: z.string().nullable().optional(), - promptVersion: z.string().nullable().optional(), - tokensUsed: z.number().int().nullable().optional(), - model: z.string().nullable().optional(), - // v1.39.4 — the turn's tables: ciphertext on a disaster-recovery file, - // readable JSON on a portable one, absent on every file written before. - resultsEncrypted: coachResultsSealedSchema.nullable().optional(), - resultsJson: coachResultsJsonSchema.nullable().optional(), - // v1.41 — the trail's model text, the same two ends as the tables. - trailEncrypted: coachTrailSealedSchema.nullable().optional(), - trailJson: coachTrailJsonSchema.nullable().optional(), - createdAt: isoDateTime, - }) - .passthrough(); - -const coachConversationDocumentBackupSchema = z - .object({ - documentId: z.string().min(1), - addedAt: isoDateTime, - }) - .passthrough(); - -const coachConversationBackupSchema = z - .object({ - id: z.string().min(1), - // v1.39.3 — a portable file (and every file written before) carries the - // readable title; a disaster-recovery file carries only the ciphertext. - title: z.string().optional(), - titleEncrypted: base64BytesSchema.optional(), - // NOT optional with a default. The fence is permanent and a file that does - // not state it is a file that cannot be trusted to re-fence the - // conversation, so the absence has to be visible rather than defaulted to - // the permissive value. - documentScoped: z.boolean(), - summaryEncrypted: base64BytesSchema.nullable().optional(), - summary: z.string().nullable().optional(), - summaryUpdatedAt: isoDateTime.nullable().optional(), - summaryTurnCount: z.number().int().min(0).optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - messages: z.array(coachMessageBackupSchema).default([]), - attachments: z.array(coachConversationDocumentBackupSchema).default([]), - }) - .passthrough(); - -/** - * The Coach's memory. `deletedAt` is optional rather than nullable-required: - * a portable file omits tombstoned rows entirely and never states the field, - * while a disaster-recovery file always does. - */ -const coachFactBackupSchema = z - .object({ - id: z.string().min(1), - factEncrypted: base64BytesSchema.optional(), - fact: z.string().optional(), - category: z.string().min(1), - confidence: z.number().int().min(0).max(100).optional(), - sourceConversationId: z.string().nullable().optional(), - // v1.41 — absent on every file written before; restored at the column - // defaults then. - source: z.string().min(1).max(32).optional(), - lastUsedAt: isoDateTime.nullable().optional(), - sourceMessageId: z.string().nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const coachPlanBackupSchema = z - .object({ - id: z.string().min(1), - metric: z.string().min(1), - ifCueEncrypted: base64BytesSchema.optional(), - ifCue: z.string().optional(), - thenActionEncrypted: base64BytesSchema.optional(), - thenAction: z.string().optional(), - targetEncrypted: base64BytesSchema.nullable().optional(), - target: z.string().nullable().optional(), - outcomeEncrypted: base64BytesSchema.nullable().optional(), - outcome: z.string().nullable().optional(), - status: z.string().optional(), - reviewDate: isoDateTime.nullable().optional(), - sourceConversationId: z.string().nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -const coachReminderBackupSchema = z - .object({ - id: z.string().min(1), - noteEncrypted: base64BytesSchema.optional(), - note: z.string().optional(), - metric: z.string().nullable().optional(), - relatedPlanId: z.string().nullable().optional(), - triggerKind: z.string().optional(), - dueAt: isoDateTime.nullable().optional(), - contextCue: z.string().nullable().optional(), - status: z.string().optional(), - source: z.string().min(1), - sourceConversationId: z.string().nullable().optional(), - lastSurfacedAt: isoDateTime.nullable().optional(), - surfaceCount: z.number().int().min(0).optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - deletedAt: isoDateTime.nullable().optional(), - }) - .passthrough(); - -/** One document filed against one condition episode. */ -const documentConditionLinkBackupSchema = z - .object({ - documentId: z.string().min(1), - episodeId: z.string().min(1), - createdAt: isoDateTime, - }) - .passthrough(); - -/** - * One fact the extraction pass staged against a document. - * - * `status`, `confidence` and `needsReview` are REQUIRED rather than defaulted, - * unlike most optional-looking fields in this file. Each has a schema default - * that means "nobody has looked at this yet", and the confirm endpoint acts on - * a PENDING fact by committing it into the structured store — so a file that - * does not state the review decision must fail to parse rather than have one - * invented for it and let an already-committed reading be approved twice. - * - * `dataEncrypted` / `data` and their provenance siblings are the two ends of - * the same contract, so all four are optional and exactly one pair arrives: a - * disaster-recovery file carries the ciphertext, a portable file carries the - * decrypted JSON. - */ -const extractedFactBackupSchema = z - .object({ - id: z.string().min(1), - documentId: z.string().min(1), - factType: z.enum(ExtractedFactType), - status: z.enum(ExtractedFactStatus), - confidence: z.number(), - needsReview: z.boolean(), - committedRecordId: z.string().nullable().optional(), - committedRecordType: z.string().nullable().optional(), - dataEncrypted: base64BytesSchema.optional(), - provenanceEncrypted: base64BytesSchema.optional(), - data: z.json().optional(), - provenance: z.json().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - }) - .passthrough(); - -/** - * A grouping the account created for its own mood factors. `id` is REQUIRED, - * unlike everywhere else in this file, because a custom tag addresses its - * category by that id and nothing else can resolve the pairing. - */ -const customMoodTagCategorySchema = z - .object({ - id: z.string().min(1), - key: z.string().min(1), - labelKey: z.string().min(1), - icon: z.string().nullable().optional(), - sortOrder: z.number().int().optional(), - isActive: z.boolean().optional(), - // Same split as the custom tag's label. - labelEncrypted: z.string().nullable().optional(), - label: z.string().nullable().optional(), - }) - .passthrough(); - -/** A tag the account hid, named by key so a seeded tag resolves anywhere. */ -const hiddenMoodTagSchema = z - .object({ - key: z.string().min(1), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * A completed screener administration. `responsesEncrypted` is REQUIRED rather - * than optional, unlike every other ciphertext field in this file: the column - * is NOT NULL, so a file that omits it describes a row that cannot be written, - * and saying so at the schema is better than discovering it inside the - * transaction that has already wiped the account. - */ -const mentalHealthAssessmentBackupSchema = z - .object({ - id: z.string().min(1), - instrument: z.enum(AssessmentInstrument), - locale: z.string().min(1), - version: z.string().optional(), - responsesEncrypted: base64BytesSchema, - totalScore: z.number().int(), - severityBand: z.string().min(1), - item9Flagged: z.boolean().optional(), - crisisShownAt: isoDateTime.nullable().optional(), - takenAt: isoDateTime, - tz: z.string().nullable().optional(), - source: z.string().optional(), - externalId: z.string().nullable().optional(), - syncVersion: z.number().int().optional(), - deletedAt: isoDateTime.nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - }) - .passthrough(); - -const consentReceiptBackupSchema = z - .object({ - id: z.string().min(1), - kind: z.string().min(1), - artefact: z.string().min(1), - signedAt: isoDateTime, - revokedAt: isoDateTime.nullable().optional(), - createdAt: isoDateTime.optional(), - }) - .passthrough(); - -const allergyBackupSchema = z - .object({ - id: z.string().min(1), - substance: z.string().min(1), - category: z.enum(AllergyCategory), - type: z.enum(AllergyType), - severity: z.enum(AllergySeverity).nullable().optional(), - status: z.enum(AllergyStatus), - onsetAt: isoDateTime.nullable().optional(), - reaction: z.string().nullable().optional(), - note: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - reactionEncrypted: base64BytesSchema.nullable().optional(), - notesEncrypted: base64BytesSchema.nullable().optional(), - deletedAt: isoDateTime.nullable().optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const familyHistoryBackupSchema = z - .object({ - id: z.string().min(1), - relationship: z.enum(FamilyRelationship), - condition: z.string().min(1), - ageAtOnset: z.number().int().nullable().optional(), - note: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const workoutBackupSchema = z - .object({ - id: z.string().min(1).optional(), - sportType: z.string().min(1), - startedAt: isoDateTime, - endedAt: isoDateTime, - durationSec: z.number().int(), - totalEnergyKcal: z.number().nullable().optional(), - totalDistanceM: z.number().nullable().optional(), - avgHeartRate: z.number().int().nullable().optional(), - maxHeartRate: z.number().int().nullable().optional(), - minHeartRate: z.number().int().nullable().optional(), - stepCount: z.number().int().nullable().optional(), - elevationM: z.number().nullable().optional(), - pauseDurationSec: z.number().int().nullable().optional(), - source: z.enum(MeasurementSource), - externalId: z.string().nullable().optional(), - externalSourceVersion: z.string().nullable().optional(), - metadata: z.json().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -const documentBackupSchema = z - .object({ - id: z.string().min(1), - kind: z.enum(InboundDocumentKind), - title: z.string().nullable().optional(), - filename: z.string().nullable().optional(), - mimeType: z.string().min(1), - byteSize: z.number().int(), - status: z.enum(InboundDocumentStatus), - reportDate: z.string().nullable().optional(), - documentDate: z.string().nullable().optional(), - contentEncrypted: base64BytesSchema.optional(), - contentSha256: z - .string() - .regex(/^[a-f0-9]{64}$/) - .nullable() - .optional(), - contentCodec: z.string().min(1).optional(), - providerType: z.string().nullable().optional(), - errorReason: z.string().nullable().optional(), - summaryEncrypted: base64BytesSchema.nullable().optional(), - summaryGeneratedAt: isoDateTime.nullable().optional(), - summaryState: z.enum(DocumentSummaryState).optional(), - // Refs #776 — the index-attempt record (canonical DR payloads only). - // Free string on purpose: a future outcome value must not fail a restore. - lastIndexAttemptAt: isoDateTime.nullable().optional(), - lastIndexOutcome: z.string().nullable().optional(), - // v1.39.2 (#1038) — import provenance. Closed like the upload field, so a - // restore cannot write a system the detail sheet has no name for. - sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).nullable().optional(), - sourceId: z.string().max(DOCUMENT_SOURCE_ID_MAX).nullable().optional(), - // v1.39.3 — the source instance (an origin). Length-bounded here and - // normalised on restore; a value that is not an origin restores as null - // (matches any instance) rather than failing the restore. - sourceInstance: z - .string() - .max(DOCUMENT_SOURCE_INSTANCE_MAX) - .nullable() - .optional(), - aiReadDeferred: z.boolean().optional(), - summary: z.string().nullable().optional(), - createdAt: isoDateTime.optional(), - updatedAt: isoDateTime.optional(), - }) - .passthrough(); - -/** - * One ECG strip. - * - * `waveformEncrypted` and `waveform` are the two ends of the same contract, so - * both are optional here and exactly one arrives: a disaster-recovery file - * carries the ciphertext, a portable file carries the micro-volt samples. - * Requiring either would refuse half the valid files. `samplingFrequency` and - * `sampleCount` stay required in both, because without them the samples are a - * list of numbers with no time axis. - */ -const ecgRecordingBackupSchema = z - .object({ - id: z.string().min(1).optional(), - source: z.enum(MeasurementSource), - externalRecordingId: z.string().min(1), - recordedAt: isoDateTime, - waveformEncrypted: base64BytesSchema.optional(), - waveform: z.array(z.number()).optional(), - samplingFrequency: z.number().int(), - sampleCount: z.number().int(), - durationSeconds: z.number().nullable().optional(), - lead: z.string().nullable().optional(), - averageHeartRate: z.number().int().nullable().optional(), - rhythmClassification: z.enum(RhythmClassification).nullable().optional(), - measurementId: z.string().min(1).nullable().optional(), - createdAt: isoDateTime, - updatedAt: isoDateTime, - }) - .passthrough(); - -/** - * The account's own settings: the `User` columns `USER_COLUMN_BACKUP_CLASS` - * calls `SETTING`, by column name. Never a credential, an identity column or - * the host's own bookkeeping on the row. - * - * Every field is optional: a column a file does not carry leaves the account - * as it is, so a file from a release with fewer setting columns restores - * what it has. Types are checked here; whether a value is one this host will - * write (a known language, an allowed AI endpoint, an image the upload would - * accept) is decided by the restore, which drops and names what it refuses - * rather than refusing the whole account over one preference. - * - * The insurance number arrives as `insuranceNumberEncrypted` (ciphertext) in - * a disaster-recovery file and as `insuranceNumber` (readable) in a portable - * one, the split every other sealed column uses. - */ -const accountSettingsBackupSchema = z - .object({ - heightCm: z.number().nullable().optional(), - dateOfBirth: isoDateTime.nullable().optional(), - gender: z.string().nullable().optional(), - hasDiabetes: z.boolean().optional(), - displayName: z.string().nullable().optional(), - fullName: z.string().nullable().optional(), - insurerName: z.string().nullable().optional(), - insuranceNumber: z.string().nullable().optional(), - insuranceNumberEncrypted: z.string().nullable().optional(), - insurerIkNumber: z.string().nullable().optional(), - lastReportPracticeName: z.string().nullable().optional(), - avatarBytes: base64BytesSchema.nullable().optional(), - avatarContentType: z.string().nullable().optional(), - avatarUpdatedAt: isoDateTime.nullable().optional(), - homeLat: z.number().nullable().optional(), - homeLon: z.number().nullable().optional(), - homeLabel: z.string().nullable().optional(), - homeTimezone: z.string().nullable().optional(), - homeSince: isoDateTime.nullable().optional(), - // v1.42 (#615) — the sealed home location rides a disaster-recovery file - // as ciphertext; a portable file carries the readable home above. - homeLocationEncrypted: base64BytesSchema.nullable().optional(), - environmentAirQualityEnabled: z.boolean().optional(), - timezone: z.string().optional(), - locale: z.string().nullable().optional(), - unitPreference: z.string().nullable().optional(), - glucoseUnit: z.string().nullable().optional(), - timeFormat: z.string().optional(), - dateFormat: z.string().optional(), - modulePreferencesJson: z.unknown().optional(), - thresholdsJson: z.unknown().optional(), - healthScoreConfigJson: z.unknown().optional(), - sourcePriorityJson: z.unknown().optional(), - dashboardWidgetsJson: z.unknown().optional(), - insightsLayoutJson: z.unknown().optional(), - medicationListLayoutJson: z.unknown().optional(), - moodTagLayoutJson: z.unknown().optional(), - documentsLayoutJson: z.unknown().optional(), - moduleListLayoutJson: z.unknown().optional(), - previewFeaturesJson: z.unknown().optional(), - activityGoalsJson: z.unknown().optional(), - reportSelectionJson: z.unknown().optional(), - globalExcludedInjectionSites: z.array(z.string()).optional(), - healthKitConfigJson: z.unknown().optional(), - notificationPrefs: z.unknown().optional(), - moodReminderEnabled: z.boolean().optional(), - aiProvider: z.string().nullable().optional(), - aiModel: z.string().nullable().optional(), - aiBaseUrl: z.string().nullable().optional(), - aiCompatBaseUrl: z.string().nullable().optional(), - aiCompatModel: z.string().nullable().optional(), - aiProviderChain: z.unknown().optional(), - aiResponseTimeoutSeconds: z.number().int().nullable().optional(), - useCentralCodex: z.boolean().optional(), - insightsPrivacyMode: z.string().optional(), - insightsExcludeMetrics: z.array(z.string()).optional(), - disableCoach: z.boolean().optional(), - // v1.43 (#1217) — the account's AI usage level; the codec checks the value. - aiUsageLevel: z.string().nullable().optional(), - coachPrefsJson: z.unknown().optional(), - documentsAutoAiRead: z.boolean().optional(), - labsLocalOcrEnabled: z.boolean().optional(), - storeWorkoutRoutes: z.boolean().optional(), - onboardingCompletedAt: isoDateTime.nullable().optional(), - onboardingTourCompleted: z.boolean().optional(), - onboardingTourProgressJson: z.unknown().optional(), - disclaimerAcknowledgedAt: isoDateTime.nullable().optional(), - disclaimerAcknowledgedVersion: z.string().nullable().optional(), - passkeyUpgradeNudgeDismissed: z.boolean().optional(), - }) - .passthrough(); const backupManifestSchema = z .object({ diff --git a/stryker.config.mjs b/stryker.config.mjs index 4f4e93b0d..3202f700d 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -34,6 +34,10 @@ const config = { "src/lib/measurements/display-transform.ts", "src/lib/glucose.ts", "src/lib/validations/backup.ts", + // The payload schema's slices, one per backup domain. + "src/lib/export/*-backup-schema.ts", + "src/lib/export/backup-schema-kit.ts", + "src/lib/cycle/backup-schema.ts", ], coverageAnalysis: "perTest", incremental: true, diff --git a/stryker.floors.json b/stryker.floors.json index ca25a0571..01b0f85a2 100644 --- a/stryker.floors.json +++ b/stryker.floors.json @@ -28,7 +28,39 @@ "floor": 85 }, "backup-validation": { - "paths": ["src/lib/validations/backup.ts"], + "paths": [ + "src/lib/validations/backup.ts", + "src/lib/export/account-settings-backup-schema.ts", + "src/lib/export/allergies-backup-schema.ts", + "src/lib/export/app-settings-backup-schema.ts", + "src/lib/export/awards-backup-schema.ts", + "src/lib/export/coach-backup-schema.ts", + "src/lib/export/document-filing-backup-schema.ts", + "src/lib/export/documents-backup-schema.ts", + "src/lib/export/ecg-backup-schema.ts", + "src/lib/export/environment-backup-schema.ts", + "src/lib/export/family-history-backup-schema.ts", + "src/lib/export/health-score-backup-schema.ts", + "src/lib/export/illness-backup-schema.ts", + "src/lib/export/intraday-profile-backup-schema.ts", + "src/lib/export/labs-backup-schema.ts", + "src/lib/export/life-events-backup-schema.ts", + "src/lib/export/measurements-backup-schema.ts", + "src/lib/export/medications-backup-schema.ts", + "src/lib/export/mood-backup-schema.ts", + "src/lib/export/nutrients-backup-schema.ts", + "src/lib/export/onboarding-backup-schema.ts", + "src/lib/export/profile-backup-schema.ts", + "src/lib/export/relation-links-backup-schema.ts", + "src/lib/export/reminders-backup-schema.ts", + "src/lib/export/sensitive-backup-schema.ts", + "src/lib/export/symptoms-backup-schema.ts", + "src/lib/export/vaccinations-backup-schema.ts", + "src/lib/export/visits-backup-schema.ts", + "src/lib/export/workouts-backup-schema.ts", + "src/lib/export/backup-schema-kit.ts", + "src/lib/cycle/backup-schema.ts" + ], "floor": 24 } }