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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-28 00:07:10 +02:00
1 parent 9d1f21408f
commit 51dbfefbcb
13 files changed
+346 -3

No files matched your search

@@ -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<Record<string, unknown>>;
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();
@@ -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<string, string>();
const restoredBiomarkerIds = new Set<string>();
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,
};
},
{