mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix: compress the stored backup before encrypting it
The weekly pass built the whole account in memory, serialised it whole, encrypted the string whole, and handed the result to the database whole. That is four full copies of the record at the same moment — the object graph, the JSON, the base64 ciphertext at 1.33x the JSON, and the copy the driver makes of that parameter. Measured against a seeded account of 445 000 readings, the JSON alone is 242 MB and the run dies of heap exhaustion; under a modest heap it never reaches the insert. The pass was not slow, it could not finish. A health record's JSON is extremely repetitive, so gzip takes that 242 MB to 14 MB and every copy after it shrinks with it. The same run completes in about twenty seconds with a peak heap of 434 MB and a stored row of 25 MB instead of 322 MB — which also matters because the copy lives inside the database it would be needed to restore. Both backup jobs now hand the serialised form back from a frame of its own, so the payload graph is released before the compress step allocates anything. The marker sits inside the ciphertext, so the stored column keeps the one shape `decrypt()` already understands and rows written before this still restore. The off-host object gets the same treatment through the version byte its envelope always carried; version 1 objects still read back, which both the restore drill and the restore script depend on. The obvious saving — dropping soft-deleted readings, four rows in five on a real record — is not taken, and the round-trip test now seeds a tombstone and asserts it makes the journey. A tombstone inside the retention window is what tells a synced client the row is gone and what makes a re-sent sample a no-op; a restore without them brings back every measurement the account ever deleted on the next device sync.
This commit is contained in:
1 parent
b5dd9372a7
commit
d8196d5208
13 files changed
+330
-38
No files matched your search
@@ -4,7 +4,7 @@
|
||||
* Verifies:
|
||||
* - 201 + a `DataBackup` row with type prefix `MANUAL_UPLOAD_` is
|
||||
* created when the JSON file matches the schema.
|
||||
* - the original payload round-trips through encrypt → decrypt.
|
||||
* - the original payload round-trips through the stored backup envelope.
|
||||
* - 422 when the JSON is malformed.
|
||||
* - 422 when the schema validation fails (missing required field).
|
||||
* - 422 when the userId on the file doesn't exist.
|
||||
@@ -15,7 +15,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -166,9 +166,11 @@ describe("POST /api/admin/backups/upload", () => {
|
||||
expect(row?.type).toMatch(/^MANUAL_UPLOAD_\d+$/);
|
||||
expect(row?.userId).toBe(admin.id);
|
||||
|
||||
// Round-trip the ciphertext to make sure we stored the same payload
|
||||
// we received.
|
||||
const decrypted = JSON.parse(decrypt(row!.data));
|
||||
// Round-trip the stored blob to make sure we kept the same payload we
|
||||
// received. Through the envelope rather than through `decrypt` alone: an
|
||||
// uploaded file is stored exactly the way the weekly worker stores one, so
|
||||
// the restore path reads both without knowing where the row came from.
|
||||
const decrypted = JSON.parse(unpackBackupBlob(row!.data));
|
||||
expect(decrypted.userId).toBe(admin.id);
|
||||
expect(decrypted.measurements).toHaveLength(1);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
encryptWaveformToBytes,
|
||||
} from "@/lib/withings/ecg-waveform-codec";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { packBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
|
||||
@@ -259,6 +260,27 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
// A DELETED reading, kept as a tombstone. It is here because the obvious way
|
||||
// to make the weekly backup cheaper — stop exporting soft-deleted rows, which
|
||||
// on a real record is four rows in five — is wrong, and nothing in the tree
|
||||
// said so out loud. A tombstone inside the retention window is what tells a
|
||||
// synced client the row is gone (`/api/sync/changes` reads exactly these) and
|
||||
// what makes a re-sent Apple Health sample a no-op instead of a resurrection.
|
||||
// Drop them from a disaster-recovery file and a restore quietly brings back
|
||||
// every measurement the account ever deleted on the next device sync.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "PULSE",
|
||||
value: 61,
|
||||
unit: "bpm",
|
||||
measuredAt: AT("2026-07-02T06:30:00.000Z"),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "round-trip-deleted-sample",
|
||||
syncVersion: 4,
|
||||
deletedAt: AT("2026-07-20T09:15:00.000Z"),
|
||||
},
|
||||
});
|
||||
// The EVENT row an ECG strip is filed against, and the strip itself. The
|
||||
// pairing is the point: `EcgRecording.measurementId` is a real foreign key,
|
||||
// so this is the reference whose remap has to survive the round trip rather
|
||||
@@ -1231,6 +1253,26 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
|
||||
// The tombstone has to be IN the file. A disaster-recovery payload that
|
||||
// carries only live rows is smaller and cheaper and wrong: the restore
|
||||
// wipes the account first, so a deletion that is not in the file is a
|
||||
// deletion the account no longer knows it made, and the next device sync
|
||||
// re-inserts every one of those samples as fresh readings.
|
||||
const exportedMeasurements = payload.measurements as Array<{
|
||||
externalId: string | null;
|
||||
deletedAt: string | null;
|
||||
syncVersion: number;
|
||||
}>;
|
||||
expect(
|
||||
exportedMeasurements.find(
|
||||
(row) => row.externalId === "round-trip-deleted-sample",
|
||||
),
|
||||
"a soft-deleted reading is missing from the disaster-recovery payload",
|
||||
).toMatchObject({
|
||||
deletedAt: "2026-07-20T09:15:00.000Z",
|
||||
syncVersion: 4,
|
||||
});
|
||||
|
||||
// Cascade rather than a hand-listed sweep — see the file comment.
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
@@ -1247,7 +1289,12 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
// The envelope the weekly worker actually writes: gzip, then
|
||||
// encrypt. The three cases further down deliberately keep the plain
|
||||
// `encrypt(json)` form, which is what every row written before this
|
||||
// envelope existed looks like — an operator's newest usable copy may
|
||||
// well be one of those, so both arms have to reach the restore route.
|
||||
data: packBackupBlob(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
@@ -2026,6 +2073,18 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
// measurement pointer is asserted against the row the RESTORE wrote rather
|
||||
// than against the id the fixture used, because resolving to something
|
||||
// that exists is the property the reference owes.
|
||||
const restoredTombstone = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID, externalId: "round-trip-deleted-sample" },
|
||||
});
|
||||
expect(
|
||||
{
|
||||
deletedAt: restoredTombstone.deletedAt?.toISOString() ?? null,
|
||||
syncVersion: restoredTombstone.syncVersion,
|
||||
},
|
||||
"the deleted reading came back, but not as a deletion — a tombstone " +
|
||||
"restored without its `deletedAt` is a resurrected measurement",
|
||||
).toEqual({ deletedAt: "2026-07-20T09:15:00.000Z", syncVersion: 4 });
|
||||
|
||||
const restoredMeasurement = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID, type: "WEIGHT" },
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user