mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
203 lines
7.0 KiB
TypeScript
203 lines
7.0 KiB
TypeScript
/**
|
|
* #1031 — an account too large to export or restore.
|
|
*
|
|
* For an account of 1.25 million measurements the full export took the app
|
|
* down, and its weekly backup could not be restored, downloaded or summarised
|
|
* at all: the backup's JSON (662 MB) is longer than any string V8 can hold,
|
|
* and every reader turned the file into one string first. Measured on a
|
|
* seeded account of that size in a 1 GB container, the streamed paths now
|
|
* export in 25 s, upload in under 20 s and restore in 94 s, and the restored
|
|
* rows match the file exactly.
|
|
*
|
|
* A million rows do not fit a test run. This file runs the same paths at a
|
|
* size that spans many pages and batches, and checks the rows come back
|
|
* exactly: the export streamed into the response, uploaded as gzip through
|
|
* the raw-body path, and restored through the streamed two-pass reader; and a
|
|
* weekly backup restored the same way, every column intact, with a personal
|
|
* record still pointing at the measurement it was found in.
|
|
*/
|
|
import { gzipSync } from "node:zlib";
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
|
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
|
import { encryptNote } from "@/lib/crypto/note-cipher";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
const COUNT = 7_300; // more than one read page and several write batches
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
});
|
|
|
|
async function seedAccount() {
|
|
const prisma = getPrismaClient();
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username: "large-account",
|
|
email: "large-account@example.test",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const start = Date.UTC(2024, 0, 1);
|
|
const yesterday = new Date(Date.now() - 86_400_000);
|
|
await prisma.measurement.createMany({
|
|
data: Array.from({ length: COUNT }, (_, i) => ({
|
|
id: `m-${String(i).padStart(5, "0")}`,
|
|
userId: user.id,
|
|
type: i % 3 === 0 ? ("PULSE" as const) : ("SLEEP_DURATION" as const),
|
|
value: 50 + (i % 40) + 0.25,
|
|
valueMax: i % 3 === 0 ? 120 : null,
|
|
unit: i % 3 === 0 ? "bpm" : "minutes",
|
|
source: "APPLE_HEALTH" as const,
|
|
measuredAt: new Date(start + i * 300_000),
|
|
externalId: `hk-${i}`,
|
|
sleepStage: i % 3 === 0 ? null : ("CORE" as const),
|
|
deviceType: "watch",
|
|
syncVersion: 1 + (i % 4),
|
|
notesEncrypted: i === 17 ? encryptNote("after the long run") : null,
|
|
// Deleted yesterday, inside the tombstone retention, so a weekly
|
|
// backup's restore writes it back.
|
|
deletedAt: i % 97 === 0 ? yesterday : null,
|
|
})),
|
|
});
|
|
await prisma.personalRecord.create({
|
|
data: {
|
|
userId: user.id,
|
|
metricType: "PULSE",
|
|
direction: "MAX",
|
|
value: 89.25,
|
|
unit: "bpm",
|
|
achievedAt: new Date(start + 3 * 300_000),
|
|
sourceMeasurementId: "m-00003",
|
|
},
|
|
});
|
|
return user;
|
|
}
|
|
|
|
async function measurementsOf(userId: string) {
|
|
return getPrismaClient().measurement.findMany({
|
|
where: { userId },
|
|
orderBy: { id: "asc" },
|
|
omit: { createdAt: true, updatedAt: true },
|
|
});
|
|
}
|
|
|
|
async function restore(id: string) {
|
|
const { POST } = await import("./restore-job-driver");
|
|
const res = await POST(
|
|
new Request(`http://localhost/api/admin/backups/${id}/restore`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ confirm: "RESTORE" }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
return res;
|
|
}
|
|
|
|
describe("large backups round-trip through the streamed paths (#1031)", () => {
|
|
it("export → gzip upload → restore gives back every live measurement", async () => {
|
|
const user = await seedAccount();
|
|
const live = (await measurementsOf(user.id)).filter((m) => !m.deletedAt);
|
|
|
|
const { GET } = await import("@/app/api/export/full-backup/route");
|
|
const exported = await GET(
|
|
new Request("http://localhost/api/export/full-backup") as never,
|
|
);
|
|
expect(exported.status).toBe(200);
|
|
const file = Buffer.from(await exported.arrayBuffer());
|
|
expect(JSON.parse(file.toString("utf8")).measurements).toHaveLength(
|
|
live.length,
|
|
);
|
|
|
|
const { POST: upload } =
|
|
await import("@/app/api/admin/backups/upload/route");
|
|
const uploaded = await upload(
|
|
new Request("http://localhost/api/admin/backups/upload", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/gzip" },
|
|
body: gzipSync(file),
|
|
}) as never,
|
|
);
|
|
expect(uploaded.status).toBe(201);
|
|
const { data } = (await uploaded.json()) as {
|
|
data: { id: string; summary: { measurements: number } };
|
|
};
|
|
expect(data.summary.measurements).toBe(live.length);
|
|
|
|
expect((await restore(data.id)).status).toBe(200);
|
|
const restored = await measurementsOf(user.id);
|
|
// A portable file carries the fields a person reads, not the sync
|
|
// bookkeeping, so those are what must come back.
|
|
const readable = (rows: typeof restored) =>
|
|
rows.map((m) => ({
|
|
id: m.id,
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt.toISOString(),
|
|
source: m.source,
|
|
}));
|
|
expect(readable(restored)).toEqual(readable(live));
|
|
});
|
|
|
|
it("a weekly backup restores every column, and a record's pointer survives", async () => {
|
|
const user = await seedAccount();
|
|
const before = await measurementsOf(user.id);
|
|
const prisma = getPrismaClient();
|
|
|
|
const { id } = await storeBackupBlob(
|
|
prisma,
|
|
{ userId: user.id, type: "WEEKLY_AUTO" },
|
|
(write) =>
|
|
streamFullBackupJson(prisma, user.id, write, {
|
|
purpose: "disaster-recovery",
|
|
}),
|
|
);
|
|
|
|
// Change the account so the restore has something to undo.
|
|
await prisma.measurement.deleteMany({ where: { userId: user.id } });
|
|
|
|
const res = await restore(id);
|
|
expect(res.status).toBe(200);
|
|
expect(await measurementsOf(user.id)).toEqual(before);
|
|
const record = await prisma.personalRecord.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(record.sourceMeasurementId).toBe("m-00003");
|
|
});
|
|
});
|