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 } }