diff --git a/src/lib/export/__tests__/backup-preview.test.ts b/src/lib/export/__tests__/backup-preview.test.ts index 8016c6c4c..b82ac0b6f 100644 --- a/src/lib/export/__tests__/backup-preview.test.ts +++ b/src/lib/export/__tests__/backup-preview.test.ts @@ -101,8 +101,8 @@ describe("createBackupPreviewCollector", () => { const expectedKeys = new BackupKeyIdCollector(); expectedKeys.visit(FILE); - it("says what a full read of the copy says", () => { - const collector = createBackupPreviewCollector(); + it("says what a full read of the copy says", async () => { + const collector = await createBackupPreviewCollector(); observeAsTheWriterDoes(FILE, collector.observe); const result = collector.finish(countsOf(FILE)); expect(result).not.toBeNull(); @@ -112,8 +112,8 @@ describe("createBackupPreviewCollector", () => { expect(result!.keys.keyIds()).toEqual(["v3"]); }); - it("answers null for a copy that does not name its version, owner and date", () => { - const collector = createBackupPreviewCollector(); + it("answers null for a copy that does not name its version, owner and date", async () => { + const collector = await createBackupPreviewCollector(); const { userId, ...rest } = FILE; void userId; observeAsTheWriterDoes(rest, collector.observe); @@ -121,6 +121,64 @@ describe("createBackupPreviewCollector", () => { }); }); +describe("createBackupPreviewCollector against the schema", () => { + // A copy the schema refuses must not be described as restorable: a null + // preview sends the preview route to read the copy, which says why not. + async function previewOf(file: Record) { + const collector = await createBackupPreviewCollector(); + observeAsTheWriterDoes(file, collector.observe); + return collector.finish(countsOf(FILE)); + } + + it("answers null when a bulk row violates the schema", async () => { + const measurements = FILE.measurements.map((m, i) => + i === 1 ? { ...m, measuredAt: "not a date" } : m, + ); + expect(await previewOf({ ...FILE, measurements })).toBeNull(); + }); + + it("answers null when a whole section violates the schema", async () => { + const labResults = [{ ...FILE.labResults[0], takenAt: 42 }]; + expect(await previewOf({ ...FILE, labResults })).toBeNull(); + }); + + it("answers null when the header does not parse", async () => { + expect( + await previewOf({ ...FILE, exportedAt: "yesterday-ish" }), + ).toBeNull(); + }); + + it("answers null for a canonical copy with a measurement without an id", async () => { + const measurements = FILE.measurements.map((m, i) => + i === 0 ? { ...m, id: undefined } : m, + ); + expect(await previewOf({ ...FILE, measurements })).toBeNull(); + }); + + it("agrees with the whole-document parse on every case above", () => { + // The oracle: each refused file is refused by the full schema too. + const bad = [ + { + ...FILE, + measurements: FILE.measurements.map((m, i) => + i === 1 ? { ...m, measuredAt: "not a date" } : m, + ), + }, + { ...FILE, labResults: [{ ...FILE.labResults[0], takenAt: 42 }] }, + { ...FILE, exportedAt: "yesterday-ish" }, + { + ...FILE, + measurements: FILE.measurements.map((m, i) => + i === 0 ? { ...m, id: undefined } : m, + ), + }, + ]; + for (const file of bad) { + expect(backupPayloadSchema.safeParse(file).success).toBe(false); + } + }); +}); + describe("storedPreviewFor", () => { const chunked = { data: null, chunkCount: 4, chunkStreamId: "s1" }; const preview = () => diff --git a/src/lib/export/backup-preview.ts b/src/lib/export/backup-preview.ts index 91eebab21..446998d36 100644 --- a/src/lib/export/backup-preview.ts +++ b/src/lib/export/backup-preview.ts @@ -35,7 +35,10 @@ import { } from "@/lib/export/backup-key-ids"; import type { FullBackupCounts } from "@/lib/export/full-backup-payload"; import type { StoredBackupRef } from "@/lib/export/stored-backup"; -import type { BackupSummary } from "@/lib/validations/backup-summary"; +import { + BACKUP_SCHEMA_VERSION, + type BackupSummary, +} from "@/lib/validations/backup-summary"; export interface BackupPreview { version: 1; @@ -207,7 +210,9 @@ export interface BackupPreviewCollector { observe(member: string, value: unknown): void; /** * The counts and key uses, once the writer has finished; null when the - * copy did not name its schema version, owner and date. + * copy did not name its schema version, owner and date, or when a section + * did not pass the backup schema. A null preview sends the preview route + * to read the copy itself, which then says why it cannot be restored. */ finish(counts: FullBackupCounts): { summary: BackupSummary; @@ -215,19 +220,64 @@ export interface BackupPreviewCollector { } | null; } +type SchemaModule = typeof import("@/lib/validations/backup"); + +/** + * A check of one member of the payload against `backupPayloadSchema`, as the + * writer shows it: a whole section, or one row of a bulk table. An array + * section is checked an element at a time, so the check holds one element's + * copy at most, never a section's. Each value is checked in its JSON form, + * exactly as it lands in the copy. A member the schema does not name passes, + * as it does under the schema's passthrough. + */ +function sectionChecker( + mod: SchemaModule, +): (member: string, value: unknown) => boolean { + const shape = mod.backupPayloadSchema.shape as Record; + const asWritten = (value: unknown): unknown => + value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + const unwrap = (schema: z.ZodType): z.ZodType => + schema instanceof z.ZodDefault + ? unwrap(schema.unwrap() as z.ZodType) + : schema; + return (member, value) => { + const declared = shape[member]; + if (!declared) return true; + const section = unwrap(declared); + if (section instanceof z.ZodArray) { + const element = section.element as z.ZodType; + const rows = Array.isArray(value) ? value : [value]; + return rows.every((row) => element.safeParse(asWritten(row)).success); + } + return declared.safeParse(asWritten(value)).success; + }; +} + /** * Work out a weekly copy's preview from what its writer already has in hand: - * each section as it is written, for the key uses, and the writer's own - * counts. Nothing is parsed and nothing is kept but the key samples, so the - * preview costs the pass no memory that grows with the record. + * each section as it is written, for the key uses and the schema check, and + * the writer's own counts. Nothing is parsed back and nothing is kept but the + * key samples, so the preview costs the pass no memory that grows with the + * record. The check loads the backup schema (`backup-summary.ts` says why it + * is loaded on first use), a fixed cost of the module, not of the record. */ -export function createBackupPreviewCollector(): BackupPreviewCollector { +export async function createBackupPreviewCollector(): Promise { + const check = sectionChecker(await import("@/lib/validations/backup")); const keys = new BackupKeyIdCollector(); const header: Partial< Record<"schemaVersion" | "userId" | "exportedAt", string> > = {}; + let valid = true; + let measurementWithoutId = false; return { observe(member, value) { + if (valid && !check(member, value)) valid = false; + if (member === "measurements") { + const rows: unknown[] = Array.isArray(value) ? value : [value]; + if (rows.some((row) => !(row as { id?: unknown } | null)?.id)) { + measurementWithoutId = true; + } + } if ( (member === "schemaVersion" || member === "userId" || @@ -242,6 +292,12 @@ export function createBackupPreviewCollector(): BackupPreviewCollector { finish(counts) { const { schemaVersion, userId, exportedAt } = header; if (!schemaVersion || !userId || !exportedAt) return null; + if (!valid) return null; + // The schema's one whole-document rule: a canonical copy names every + // measurement by a stable id. + if (schemaVersion === BACKUP_SCHEMA_VERSION && measurementWithoutId) { + return null; + } const summary = { schemaVersion, userId, exportedAt } as BackupSummary; for (const field of SUMMARY_COUNTS) summary[field] = counts[field]; return { summary, keys }; diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 62e1adeda..c8bc8fa83 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -207,7 +207,7 @@ export async function handleDataBackup( // so its size does not depend on this process's memory (#1031). // The restore preview is taken from the writer's own counts and // the sections as they are written, never by parsing the copy. - const preview = createBackupPreviewCollector(); + const preview = await createBackupPreviewCollector(); let counts: FullBackupCounts | undefined; const { bytes: storedBytes } = await storeBackupBlob( prisma, diff --git a/tests/integration/backup-preview-memory.test.ts b/tests/integration/backup-preview-memory.test.ts index fbe8eb6f6..569ec6e7b 100644 --- a/tests/integration/backup-preview-memory.test.ts +++ b/tests/integration/backup-preview-memory.test.ts @@ -118,6 +118,11 @@ describe("weekly copy preview under a memory budget", () => { }); it("holds nothing of the record once the producer is done", async () => { + // The backup schema the preview checks each section against is a module + // loaded once per process (about 12 MB, `backup-summary.ts`), not memory + // that grows with the record: load it before the baseline, as a running + // server has long since done. + await import("@/lib/validations/backup"); const baseline = liveHeapBytes(); let peakHeld = 0; let heldAtEnd = 0; @@ -127,7 +132,7 @@ describe("weekly copy preview under a memory budget", () => { rawPeak = Math.max(rawPeak, process.memoryUsage().heapUsed); }, 5); // Wired exactly as the weekly job wires it (`backup-handlers.ts`). - const preview = createBackupPreviewCollector(); + const preview = await createBackupPreviewCollector(); let counts: FullBackupCounts | undefined; try { await storeBackupBlob( diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 045447121..28e9b83bc 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1495,7 +1495,7 @@ describe("every model the plan claims two-ended survives a real restore", () => // below then covers BOTH, since the row that gets restored comes from the // streamed bytes. let streamedJson = ""; - const previewCollector = createBackupPreviewCollector(); + const previewCollector = await createBackupPreviewCollector(); const streamedCounts = await streamFullBackupJson( prisma, OWNER_ID, @@ -3779,7 +3779,7 @@ describe("every column of every two-ended model survives a real restore", () => const exportedAt = new Date("2026-08-01T00:00:00.000Z"); let json = ""; - const previewCollector = createBackupPreviewCollector(); + const previewCollector = await createBackupPreviewCollector(); const counts = await streamFullBackupJson( prisma, OWNER_ID,