mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
A manual full sync re-reads the whole history, and nearly all of it is already stored. Each reading cost a findFirst plus an update, and every re-read day was marked dirty, so the run refolded the DAY tier and queued three coarse recomputes for every day of the account: about thirty seconds of database work behind 1.4 s of Withings calls. The measures leg now reads the stored rows of the window once, skips the unchanged ones, updates the changed or tombstoned ones and inserts the new ones in batches. Only inserted or changed readings count as touched. recomputeBucketsForKeys refolds exactly the touched DAY buckets in one aggregate and one transaction, and queues one coarse job per run of adjacent buckets instead of three per day.
205 lines
6.5 KiB
TypeScript
205 lines
6.5 KiB
TypeScript
/**
|
|
* A Withings measure sync at the volume a full re-read of a real account
|
|
* carries: two years of daily weigh-ins (seven body-composition values each)
|
|
* and a daily blood-pressure reading, about 7 300 measures over 7 300
|
|
* (type, day) buckets.
|
|
*
|
|
* The manual full sync re-reads the whole history, and nearly all of it is
|
|
* already stored. The run must therefore cost what CHANGED, not what was
|
|
* READ: one set-based read and write for the measures, and a rollup refold
|
|
* only for the days that gained or changed a reading. The timings are logged
|
|
* for the record; the assertions are on behaviour, so a slow CI host cannot
|
|
* fail them.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { encrypt } from "@/lib/crypto";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/withings/client", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/withings/client")>();
|
|
return { ...actual, fetchMeasurements: vi.fn() };
|
|
});
|
|
|
|
import { fetchMeasurements } from "@/lib/withings/client";
|
|
import { syncUserMeasurements } from "@/lib/withings/sync";
|
|
|
|
const USER = "user-withings-volume";
|
|
const DAY_MS = 86_400_000;
|
|
const DAYS = 730;
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
const WEIGH_IN: Array<[string, number]> = [
|
|
["WEIGHT", 81.4],
|
|
["BODY_FAT", 21.5],
|
|
["FAT_MASS", 17.5],
|
|
["FAT_FREE_MASS", 63.9],
|
|
["MUSCLE_MASS", 60.7],
|
|
["BONE_MASS", 3.2],
|
|
["TOTAL_BODY_WATER", 46.1],
|
|
];
|
|
const BP: Array<[string, number]> = [
|
|
["BLOOD_PRESSURE_SYS", 124],
|
|
["BLOOD_PRESSURE_DIA", 79],
|
|
["PULSE", 64],
|
|
];
|
|
|
|
/** Two years of history ending yesterday, at fixed UTC minutes. */
|
|
function history(anchor: number) {
|
|
const out: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
|
for (let d = DAYS; d >= 1; d -= 1) {
|
|
const day = anchor - d * DAY_MS;
|
|
for (const [type, base] of WEIGH_IN) {
|
|
out.push({
|
|
type,
|
|
value: base + (d % 7) * 0.1,
|
|
measuredAt: new Date(day + 6 * 3_600_000 + 12 * 60_000),
|
|
});
|
|
}
|
|
for (const [type, base] of BP) {
|
|
out.push({
|
|
type,
|
|
value: base + (d % 5),
|
|
measuredAt: new Date(day + 20 * 3_600_000 + 41 * 60_000),
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: "withings-volume",
|
|
email: "withings-volume@example.test",
|
|
},
|
|
});
|
|
await prisma.withingsConnection.create({
|
|
data: {
|
|
userId: USER,
|
|
withingsUserId: "wu-volume",
|
|
accessToken: encrypt("access-token"),
|
|
refreshToken: encrypt("refresh-token"),
|
|
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
scope: "user.metrics,user.activity",
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.mocked(fetchMeasurements).mockReset();
|
|
});
|
|
|
|
describe("Withings full sync at account volume", () => {
|
|
it("writes the history once, then re-reads it at the cost of what changed", async () => {
|
|
const anchor = Date.UTC(2026, 9, 20);
|
|
const all = history(anchor);
|
|
vi.mocked(fetchMeasurements).mockResolvedValue(all);
|
|
|
|
let started = performance.now();
|
|
const first = await syncUserMeasurements(USER, { fullSync: true });
|
|
const firstMs = performance.now() - started;
|
|
expect(first).toEqual({ imported: all.length, failed: false });
|
|
expect(
|
|
await prisma.measurement.count({
|
|
where: { userId: USER, source: "WITHINGS" },
|
|
}),
|
|
).toBe(all.length);
|
|
expect(
|
|
await prisma.measurementRollup.count({
|
|
where: { userId: USER, granularity: "DAY" },
|
|
}),
|
|
).toBe(DAYS * (WEIGH_IN.length + BP.length));
|
|
|
|
// The re-read: the same history, one corrected weigh-in, one reading
|
|
// that was deleted here and comes back, and a new reading today.
|
|
const corrected = all.find((m) => m.type === "WEIGHT")!;
|
|
const resurrected = all.find((m) => m.type === "PULSE")!;
|
|
await prisma.measurement.updateMany({
|
|
where: {
|
|
userId: USER,
|
|
type: "PULSE",
|
|
measuredAt: resurrected.measuredAt,
|
|
},
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
const today = {
|
|
type: "WEIGHT",
|
|
value: 80.9,
|
|
measuredAt: new Date(anchor + 7 * 3_600_000),
|
|
};
|
|
const reread = [
|
|
...all.map((m) => (m === corrected ? { ...m, value: 79.0 } : m)),
|
|
today,
|
|
];
|
|
vi.mocked(fetchMeasurements).mockResolvedValue(reread);
|
|
|
|
const before = new Date();
|
|
started = performance.now();
|
|
const second = await syncUserMeasurements(USER, { fullSync: true });
|
|
const secondMs = performance.now() - started;
|
|
console.log(
|
|
`withings full sync: ${all.length} measures first run ${firstMs.toFixed(0)} ms, re-read ${secondMs.toFixed(0)} ms`,
|
|
);
|
|
|
|
expect(second).toEqual({ imported: reread.length, failed: false });
|
|
const stored = await prisma.measurement.findMany({
|
|
where: {
|
|
userId: USER,
|
|
source: "WITHINGS",
|
|
OR: [
|
|
{ type: "WEIGHT", measuredAt: corrected.measuredAt },
|
|
{ type: "PULSE", measuredAt: resurrected.measuredAt },
|
|
{ type: "WEIGHT", measuredAt: today.measuredAt },
|
|
],
|
|
},
|
|
select: { type: true, value: true, measuredAt: true, deletedAt: true },
|
|
});
|
|
expect(stored).toHaveLength(3);
|
|
expect(stored.every((m) => m.deletedAt === null)).toBe(true);
|
|
expect(
|
|
stored.find(
|
|
(m) => m.measuredAt.getTime() === corrected.measuredAt.getTime(),
|
|
)?.value,
|
|
).toBe(79.0);
|
|
|
|
// Only the three days that changed were refolded; the other buckets keep
|
|
// the aggregate the first run wrote.
|
|
const refolded = await prisma.measurementRollup.findMany({
|
|
where: {
|
|
userId: USER,
|
|
granularity: "DAY",
|
|
computedAt: { gte: before },
|
|
},
|
|
select: { type: true, bucketStart: true, mean: true },
|
|
});
|
|
const dayOf = (d: Date) =>
|
|
new Date(Math.floor(d.getTime() / DAY_MS) * DAY_MS);
|
|
expect(
|
|
refolded.map((r) => [r.type, r.bucketStart.toISOString()]).sort(),
|
|
).toEqual([
|
|
["PULSE", dayOf(resurrected.measuredAt).toISOString()],
|
|
["WEIGHT", dayOf(corrected.measuredAt).toISOString()],
|
|
["WEIGHT", dayOf(today.measuredAt).toISOString()],
|
|
]);
|
|
expect(
|
|
refolded.find(
|
|
(r) =>
|
|
r.type === "WEIGHT" &&
|
|
r.bucketStart.getTime() === dayOf(corrected.measuredAt).getTime(),
|
|
)?.mean,
|
|
).toBe(79.0);
|
|
});
|
|
});
|