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