From 51dbfefbcbe56cd43810b20cedeaec0b49d90075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 28 Jul 2026 00:07:10 +0200 Subject: [PATCH] fix(backup): carry the intraday day shape through export and restore `IntradayCumulativeProfile` shipped classified as backed up and carried by nothing. The drain folds the per-sample rows into twenty-four running totals and deletes the originals in the same transaction, so past the grace window the table is the only copy of a day's curve: a restore rebuilt the account without it, reported success, and left the same-time baseline reading "still learning" for a fortnight. Builder and restore sit together in one file, the way the profile and cycle pairs do, because the restore route delegates and a grep of the route alone lies. The section spreads into the payload rather than being copied key by key. Two curves for the same metric and day, or a curve that is not a whole day, throw naming the day instead of writing a row every reader drops. The pair is proven end to end rather than at each end: the round-trip suite builds a real payload from a populated account, serialises it, parses it back through the real parser, drives the real restore, and asserts on the rows written. Remove the read and it goes red. --- .../[id]/restore/__tests__/round-trip.test.ts | 57 +++++++ .../api/admin/backups/[id]/restore/route.ts | 12 ++ .../export/__tests__/per-type-routes.test.ts | 6 + .../export/encrypted/__tests__/route.test.ts | 3 + src/app/api/export/encrypted/route.ts | 1 + src/app/api/export/full-backup/route.ts | 1 + src/components/admin/backups-section.tsx | 4 +- .../__tests__/full-backup-payload.test.ts | 49 +++++- .../__tests__/intraday-profile-backup.test.ts | 158 ++++++++++++++++++ src/lib/export/backup-plan.ts | 3 + src/lib/export/full-backup-payload.ts | 21 ++- src/lib/export/intraday-profile-backup.ts | Bin 0 -> 8144 bytes src/lib/validations/backup.ts | 34 ++++ 13 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 src/lib/export/__tests__/intraday-profile-backup.test.ts create mode 100644 src/lib/export/intraday-profile-backup.ts diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index c8de28d7c..0c09b5df7 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -85,6 +85,16 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; const OWNER = "user-A"; +/** + * A day's cumulative curve, twenty-four running totals. Uneven on purpose: a + * flat ramp would survive a restore that rebuilt the shape from `dayTotal` + * instead of carrying it, and this file is the only copy of the real one. + */ +const DAY_CURVE = [ + 0, 0, 0, 0, 0, 0, 120, 940, 2310, 2380, 2400, 2860, 4120, 4180, 4200, 4260, + 5010, 6340, 7480, 8020, 8090, 8110, 8110, 8110, +]; + /* ── the account being backed up ────────────────────────────────────── */ function sourceClient() { @@ -173,6 +183,24 @@ function sourceClient() { updatedAt: new Date("2026-07-19T00:00:00.000Z"), }), }, + // The hourly shape of a drained day. The per-sample rows it was folded + // from are already gone, so nothing but this row can rebuild it. + intradayCumulativeProfile: { + findMany: vi.fn().mockResolvedValue([ + { + id: "shape-1", + userId: OWNER, + type: "ACTIVITY_STEPS", + dateKey: "2026-07-18", + hourlyCumulative: DAY_CURVE, + dayTotal: DAY_CURVE[23], + sampleCount: 96, + timezone: "Europe/Berlin", + createdAt: new Date("2026-07-19T02:00:00.000Z"), + updatedAt: new Date("2026-07-19T02:00:00.000Z"), + }, + ]), + }, customMetric: { findMany: vi.fn().mockResolvedValue([ { @@ -349,6 +377,35 @@ describe("backup round trip — export, wire schema, restore", () => { }); }); + it("writes the day's cumulative shape back, every hour of it", async () => { + const { written } = await roundTrip(); + + const write = written.find( + (w) => w.model === "intradayCumulativeProfile" && w.op === "createMany", + ); + expect( + write, + "the day curve reached the file and nothing wrote it back", + ).toBeDefined(); + + const rows = write!.data as unknown as Array>; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: "shape-1", + userId: OWNER, + type: "ACTIVITY_STEPS", + dateKey: "2026-07-18", + dayTotal: DAY_CURVE[23], + sampleCount: 96, + // Dropping the zone would make a curve cut on another clock look + // comparable to one cut on this account's. + timezone: "Europe/Berlin", + }); + // The curve is the part that cannot be rebuilt from anything else, so it + // is asserted hour by hour rather than by its total. + expect(rows[0].hourlyCumulative).toEqual(DAY_CURVE); + }); + it("writes the account's own cycle symptom back before resolving its links", async () => { const { written, res } = await roundTrip(); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 5381a321f..0093aeca8 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -39,6 +39,7 @@ import { import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { restoreProfileData } from "@/lib/export/profile-backup"; +import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { invalidateUserData } from "@/lib/cache/invalidate"; export const dynamic = "force-dynamic"; @@ -67,6 +68,7 @@ interface RestoreResponse { documents: number; healthProfile: number; customMetrics: number; + intradayProfiles: number; }; } @@ -753,6 +755,15 @@ const handler = apiHandler( // find the cycle restore one line above. const profileCleared = await restoreProfileData(tx, ownerId, payload); + // The hourly shape of a cumulative day. Both ends live in + // `src/lib/export/intraday-profile-backup.ts` beside its builder, so + // a grep of THIS file alone will not find them either. + const intradayCleared = await restoreIntradayProfileData( + tx, + ownerId, + payload, + ); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1110,6 +1121,7 @@ const handler = apiHandler( documents: documents.count, healthProfile: profileCleared.healthProfile, customMetrics: profileCleared.customMetrics, + intradayProfiles: intradayCleared.intradayProfiles, }; }, { diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 3d6b74e02..8804207d7 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -22,6 +22,9 @@ vi.mock("@/lib/db", () => ({ // `buildProfileBackupSection`. userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) }, customMetric: { findMany: vi.fn().mockResolvedValue([]) }, + // The hourly shape of a cumulative day, read by + // `buildIntradayProfileBackupSection`. + intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -84,6 +87,9 @@ beforeEach(() => { null as never, ); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.intradayCumulativeProfile.findMany).mockResolvedValue( + [] as never, + ); }); afterEach(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 1d3bea4ba..0af4b02a2 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -20,6 +20,9 @@ vi.mock("@/lib/db", () => ({ // `buildProfileBackupSection`. userHealthProfile: { findUnique: vi.fn().mockResolvedValue(null) }, customMetric: { findMany: vi.fn().mockResolvedValue([]) }, + // The hourly shape of a cumulative day, read by + // `buildIntradayProfileBackupSection`. + intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index cf13f5eb0..1fc1d6748 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -120,6 +120,7 @@ export const POST = apiHandler(async (request: NextRequest) => { export_health_profile_count: counts.healthProfile, export_custom_metric_count: counts.customMetrics, export_custom_metric_entry_count: counts.customMetricEntries, + export_intraday_profile_count: counts.intradayProfiles, export_archive_bytes: archive.byteLength, }, }); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index 5c77a14ed..c2ac96575 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -79,6 +79,7 @@ export const GET = apiHandler(async (request: NextRequest) => { export_health_profile_count: counts.healthProfile, export_custom_metric_count: counts.customMetrics, export_custom_metric_entry_count: counts.customMetricEntries, + export_intraday_profile_count: counts.intradayProfiles, }, }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 1c8852914..6f9cefb41 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -246,6 +246,7 @@ export function BackupsSection() { healthProfile?: number; customMetrics?: number; customMetricEntries?: number; + intradayProfiles?: number; }; }>("/api/admin/backups/upload", { method: "POST", body: fd }); }, @@ -272,7 +273,8 @@ export function BackupsSection() { (data.summary.nutrientDays ?? 0) + (data.summary.healthProfile ?? 0) + (data.summary.customMetrics ?? 0) + - (data.summary.customMetricEntries ?? 0); + (data.summary.customMetricEntries ?? 0) + + (data.summary.intradayProfiles ?? 0); toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index d5d1e7a07..4d37b30c0 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -187,6 +187,22 @@ function makePrisma() { }, ]), }, + intradayCumulativeProfile: { + findMany: vi.fn().mockResolvedValue([ + { + id: "shape-1", + userId: "user-1", + type: "ACTIVITY_STEPS", + dateKey: "2026-07-18", + hourlyCumulative: Array.from({ length: 24 }, (_, h) => h * 300), + dayTotal: 6900, + sampleCount: 96, + timezone: "Europe/Berlin", + createdAt: new Date("2026-07-19T02:00:00.000Z"), + updatedAt: new Date("2026-07-19T02:00:00.000Z"), + }, + ]), + }, // Left unmocked on purpose: `buildProfileBackupSection` runs for real // against these, so the assertions below exercise the builder rather than // a stand-in that would agree with whatever the payload happened to do. @@ -531,9 +547,11 @@ describe("buildFullBackupPayload — section keys reach the payload", () => { ...Object.keys( await mocks.buildRecordsBackupSection.mock.results[0].value, ), - // The profile section runs for real, so its keys come from the builder. + // The profile and intraday sections run for real, so their keys come + // from the builders. "healthProfile", "customMetrics", + "intradayProfiles", ]; const missing = sectionKeys.filter((key) => !(key in payload)); @@ -675,4 +693,33 @@ describe("buildFullBackupPayload — profile and custom metrics", () => { }), ); }); + + it("carries the day curve hour by hour, with its count", async () => { + installSectionMocks(); + const prisma = makePrisma(); + + const { payload, counts } = await buildFullBackupPayload( + prisma as never, + "user-1", + { + purpose: "disaster-recovery", + exportedAt: new Date("2026-07-20T00:00:00.000Z"), + }, + ); + + // Rebuilding the curve from `dayTotal` would produce a plausible ramp that + // describes no day that happened, so the whole array is asserted. + expect(payload.intradayProfiles).toEqual([ + expect.objectContaining({ + id: "shape-1", + type: "ACTIVITY_STEPS", + dateKey: "2026-07-18", + hourlyCumulative: Array.from({ length: 24 }, (_, h) => h * 300), + dayTotal: 6900, + sampleCount: 96, + timezone: "Europe/Berlin", + }), + ]); + expect(counts.intradayProfiles).toBe(1); + }); }); diff --git a/src/lib/export/__tests__/intraday-profile-backup.test.ts b/src/lib/export/__tests__/intraday-profile-backup.test.ts new file mode 100644 index 000000000..946666766 --- /dev/null +++ b/src/lib/export/__tests__/intraday-profile-backup.test.ts @@ -0,0 +1,158 @@ +/** + * The intraday-shape backup pair, and the two ways one row can be wrong. + * + * The end-to-end proof lives in the restore round-trip suite; this file covers + * what that one cannot: the refusals. Both are cases where writing the row + * would succeed and the day would then be missing from the feature anyway, + * which is the failure mode the whole backup guard exists to make loud. + */ +import { describe, expect, it, vi } from "vitest"; + +// The slot-count module reaches for the global Prisma client at load; this +// test only wants the constant off it. +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { PROFILE_HOURS } from "@/lib/measurements/intraday-cumulative-profile"; +import { + buildIntradayProfileBackupSection, + countIntradayProfileBackupSection, + restoreIntradayProfileData, + PROFILE_BACKUP_HOURS, + type IntradayProfileBackupEntry, +} from "@/lib/export/intraday-profile-backup"; + +const CURVE = Array.from({ length: 24 }, (_, h) => h * 100); + +function sourceRow() { + return { + id: "shape-1", + userId: "user-A", + type: "ACTIVITY_STEPS", + dateKey: "2026-07-18", + hourlyCumulative: CURVE, + dayTotal: CURVE[23], + sampleCount: 96, + timezone: "Europe/Berlin", + createdAt: new Date("2026-07-19T02:00:00.000Z"), + updatedAt: new Date("2026-07-19T02:00:00.000Z"), + }; +} + +function client() { + return { + intradayCumulativeProfile: { + findMany: vi.fn().mockResolvedValue([sourceRow()]), + }, + } as never; +} + +function entry( + overrides: Partial = {}, +): IntradayProfileBackupEntry { + return { + type: "ACTIVITY_STEPS" as IntradayProfileBackupEntry["type"], + dateKey: "2026-07-18", + hourlyCumulative: CURVE, + dayTotal: CURVE[23], + sampleCount: 96, + timezone: "Europe/Berlin", + ...overrides, + }; +} + +function tx(created: Array>) { + return { + intradayCumulativeProfile: { + deleteMany: vi.fn().mockResolvedValue({ count: 3 }), + createMany: vi.fn(async (args: { data: Record[] }) => { + created.push(...args.data); + return { count: args.data.length }; + }), + }, + } as never; +} + +describe("intraday profile backup", () => { + it("carries the same slot count the reader indexes by", () => { + // Re-declared rather than imported, so it is pinned here instead: a change + // to one without the other would put rows in the table that every read + // path drops. + expect(PROFILE_BACKUP_HOURS).toBe(PROFILE_HOURS); + }); + + it("gives a disaster-recovery payload the row's own identity", async () => { + const section = await buildIntradayProfileBackupSection( + client(), + "user-A", + { + purpose: "disaster-recovery", + }, + ); + + expect(section.intradayProfiles).toHaveLength(1); + expect(section.intradayProfiles[0]).toMatchObject({ + id: "shape-1", + type: "ACTIVITY_STEPS", + dateKey: "2026-07-18", + hourlyCumulative: CURVE, + dayTotal: CURVE[23], + sampleCount: 96, + timezone: "Europe/Berlin", + createdAt: "2026-07-19T02:00:00.000Z", + updatedAt: "2026-07-19T02:00:00.000Z", + }); + expect(countIntradayProfileBackupSection(section)).toEqual({ + intradayProfiles: 1, + }); + }); + + it("leaves the row identity out of a portable export", async () => { + const section = await buildIntradayProfileBackupSection(client(), "user-A"); + + expect(section.intradayProfiles[0]).not.toHaveProperty("id"); + expect(section.intradayProfiles[0]).not.toHaveProperty("createdAt"); + // The shape itself still travels — it is the only copy either way. + expect(section.intradayProfiles[0].hourlyCumulative).toEqual(CURVE); + }); + + it("restores the curve and reports what it cleared", async () => { + const created: Array> = []; + const cleared = await restoreIntradayProfileData(tx(created), "user-B", { + intradayProfiles: [entry({ id: "shape-1" })], + }); + + expect(cleared).toEqual({ intradayProfiles: 3 }); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + id: "shape-1", + userId: "user-B", + dateKey: "2026-07-18", + hourlyCumulative: CURVE, + }); + }); + + it("refuses two curves for the same metric and day, naming the day", async () => { + const created: Array> = []; + + await expect( + restoreIntradayProfileData(tx(created), "user-B", { + intradayProfiles: [entry(), entry({ dayTotal: 1 })], + }), + ).rejects.toThrow(/ACTIVITY_STEPS on 2026-07-18/); + expect( + created, + "nothing may land when the file contradicts itself", + ).toEqual([]); + }); + + it("refuses a curve that is not a whole day, naming the day", async () => { + const created: Array> = []; + + await expect( + restoreIntradayProfileData(tx(created), "user-B", { + intradayProfiles: [entry({ hourlyCumulative: CURVE.slice(0, 23) })], + }), + ).rejects.toThrow(/23 hourly slots, not 24/); + expect(created, "a row every reader drops is worse than none").toEqual([]); + }); +}); diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 7744e008f..309c2946e 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -154,12 +154,14 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ "src/lib/export/paged-measurements.ts", "src/lib/export/records-backup.ts", "src/lib/export/profile-backup.ts", + "src/lib/export/intraday-profile-backup.ts", "src/lib/cycle/backup.ts", ]; export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/app/api/admin/backups/[id]/restore/route.ts", "src/lib/export/profile-backup.ts", + "src/lib/export/intraday-profile-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -178,6 +180,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ */ export const TWO_ENDED_MODELS: readonly string[] = [ "Measurement", + "IntradayCumulativeProfile", "Medication", "MedicationSchedule", "MedicationIntakeEvent", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index dcb6db3c7..a241e5b8b 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -31,9 +31,18 @@ import { type ProfileBackupCounts, type ProfileBackupSection, } from "@/lib/export/profile-backup"; +import { + buildIntradayProfileBackupSection, + countIntradayProfileBackupSection, + type IntradayProfileBackupCounts, + type IntradayProfileBackupSection, +} from "@/lib/export/intraday-profile-backup"; export interface FullBackupCounts - extends RecordsBackupCounts, ProfileBackupCounts { + extends + RecordsBackupCounts, + ProfileBackupCounts, + IntradayProfileBackupCounts { measurements: number; medications: number; intakeEvents: number; @@ -107,6 +116,7 @@ export async function buildFullBackupPayload( cycle, records, profile, + intradayProfiles, nutrientDays, ] = await Promise.all([ disasterRecovery @@ -210,6 +220,12 @@ export async function buildFullBackupPayload( buildProfileBackupSection(prisma, userId, { purpose: disasterRecovery ? "disaster-recovery" : "portable-export", }), + // The hourly shape of a cumulative day. Not derived, despite looking it: + // the drain folds it out of the per-sample rows in the same transaction + // that deletes them, so past the grace window nothing can rebuild it. + buildIntradayProfileBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // Nutrient day totals were absent from every export path, which // contradicted the schema's own reason for denormalising the unit column // ("rows stay self-describing in exports even if the catalog ever drifts"). @@ -234,6 +250,7 @@ export async function buildFullBackupPayload( const cycleSection: CycleBackupSection = cycle; const recordsSection: RecordsBackupSection = records; const profileSection: ProfileBackupSection = profile; + const intradaySection: IntradayProfileBackupSection = intradayProfiles; const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -379,6 +396,7 @@ export async function buildFullBackupPayload( ...cycleSection, ...recordsSection, ...profileSection, + ...intradaySection, nutrientDays: nutrientDays.map((n) => ({ day: n.day, nutrient: n.nutrient, @@ -401,6 +419,7 @@ export async function buildFullBackupPayload( nutrientDays: nutrientDays.length, ...countRecordsBackupSection(records), ...countProfileBackupSection(profile), + ...countIntradayProfileBackupSection(intradayProfiles), }, }; } diff --git a/src/lib/export/intraday-profile-backup.ts b/src/lib/export/intraday-profile-backup.ts new file mode 100644 index 0000000000000000000000000000000000000000..233ea26309b0c092aee347d35e95e56bd8b7fb51 GIT binary patch literal 8144 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 AasU7T literal 0 HcmV?d00001 diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index fbf92183a..1fe0f8507 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -454,6 +454,34 @@ const customMetricBackupSchema = z }) .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(), + // 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 appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -721,6 +749,9 @@ export const backupPayloadSchema = z // `null` / `[]`. healthProfile: healthProfileBackupSchema.nullable().default(null), customMetrics: z.array(customMetricBackupSchema).default([]), + // The hourly shape of a cumulative day. Defaulted for the same reason as + // the pair above: a file written before the table existed carries no key. + intradayProfiles: z.array(intradayProfileBackupSchema).default([]), manifest: backupManifestSchema.nullable().default(null), }) .passthrough() @@ -779,6 +810,8 @@ export interface BackupSummary { customMetrics: number; /** Readings across every user-defined metric. */ customMetricEntries: number; + /** Stored day curves for the cumulative metrics, across every metric. */ + intradayProfiles: number; } export function summarizeBackup(payload: BackupPayload): BackupSummary { @@ -810,6 +843,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { (sum, metric) => sum + metric.entries.length, 0, ), + intradayProfiles: payload.intradayProfiles.length, }; }