mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A later case in the file folds the rollups again, and a fold sums in the order its plan reads, so the second fold's sums could differ from the first's in the last bit; the comparison read the table after that fold.
209 lines
6.4 KiB
TypeScript
209 lines
6.4 KiB
TypeScript
/**
|
|
* Migration 0372 rewrites the `mean` of stored PULSE rollup rows to the
|
|
* hourly-mean statistic, and touches nothing else.
|
|
*
|
|
* The rows are first built by the writer, then their `mean` is set to a
|
|
* sentinel the way a row written before this release still holds the reading
|
|
* mean. The migration must restore exactly what the writer stores now: DAY =
|
|
* mean of the day's hour means, WEEK / MONTH / YEAR = mean of their days. A
|
|
* second type (HRV) with the same readings keeps its sentinel, every other
|
|
* column keeps its value, and a rerun changes nothing.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
|
|
|
const prisma = getPrismaClient();
|
|
const USER = "pulse-mean-migration";
|
|
const SENTINEL = 999;
|
|
|
|
const MIGRATION = readFileSync(
|
|
resolve(
|
|
process.cwd(),
|
|
"prisma",
|
|
"migrations",
|
|
"0372_pulse_hourly_mean_day",
|
|
"migration.sql",
|
|
),
|
|
"utf8",
|
|
);
|
|
|
|
function steps(): string[] {
|
|
return MIGRATION.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("--"))
|
|
.join("\n")
|
|
.split(";")
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
}
|
|
|
|
async function runMigration(): Promise<void> {
|
|
// One connection: the temporary tables live in the session that made them.
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const step of steps()) await tx.$executeRawUnsafe(step);
|
|
});
|
|
}
|
|
|
|
/** Tuesday 2026-03-03: one hard hour of 12 readings at 150, three hours of one reading at 60. */
|
|
const TUE = Date.UTC(2026, 2, 3);
|
|
/** Wednesday: two hours at 70. */
|
|
const WED = Date.UTC(2026, 2, 4);
|
|
const TUE_VALUE = (150 + 3 * 60) / 4;
|
|
const WED_VALUE = 70;
|
|
|
|
function rows(
|
|
type: "PULSE" | "HEART_RATE_VARIABILITY",
|
|
): Prisma.MeasurementCreateManyInput[] {
|
|
const out: Prisma.MeasurementCreateManyInput[] = [];
|
|
const add = (at: number, value: number) =>
|
|
out.push({
|
|
id: `${type}-${at}`,
|
|
userId: USER,
|
|
type,
|
|
unit: type === "PULSE" ? "bpm" : "ms",
|
|
source: "APPLE_HEALTH",
|
|
value,
|
|
measuredAt: new Date(at),
|
|
});
|
|
for (let i = 0; i < 12; i += 1) add(TUE + 10 * 3_600_000 + i * 300_000, 150);
|
|
for (const h of [12, 14, 16]) add(TUE + h * 3_600_000, 60);
|
|
for (const h of [8, 9]) add(WED + h * 3_600_000, 70);
|
|
return out;
|
|
}
|
|
|
|
type Row = {
|
|
type: string;
|
|
granularity: string;
|
|
bucket_start: Date;
|
|
count: number;
|
|
mean: number;
|
|
min_value: number;
|
|
max_value: number;
|
|
sum_value: number | null;
|
|
sd: number | null;
|
|
sum_x: number | null;
|
|
sum_xy: number | null;
|
|
sum_xx: number | null;
|
|
sum_yy: number | null;
|
|
};
|
|
|
|
const readRows = () =>
|
|
prisma.$queryRaw<Row[]>`
|
|
SELECT "type"::text AS type, "granularity"::text AS granularity,
|
|
"bucket_start", "count", "mean", "min_value", "max_value",
|
|
"sum_value", "sd", "sum_x", "sum_xy", "sum_xx", "sum_yy"
|
|
FROM measurement_rollups
|
|
WHERE "user_id" = ${USER}
|
|
ORDER BY 1, 2, 3
|
|
`;
|
|
|
|
let before: Row[] = [];
|
|
/**
|
|
* The rows right after the migration. "Matches what the writer stores now"
|
|
* folds the rollups again, and a fold sums in whatever order the plan reads,
|
|
* so its sums can differ from the first fold's in the last bit; what the
|
|
* migration left is compared from here.
|
|
*/
|
|
let migrated: Row[] = [];
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: { id: USER, username: USER, timezone: "UTC" },
|
|
});
|
|
await prisma.measurement.createMany({
|
|
data: [...rows("PULSE"), ...rows("HEART_RATE_VARIABILITY")],
|
|
});
|
|
await recomputeUserRollups(USER, {
|
|
types: ["PULSE", "HEART_RATE_VARIABILITY"],
|
|
from: new Date(Date.UTC(2026, 0, 1)),
|
|
to: new Date(Date.UTC(2027, 0, 1)),
|
|
});
|
|
await prisma.$executeRaw`
|
|
UPDATE measurement_rollups SET "mean" = ${SENTINEL} WHERE "user_id" = ${USER}
|
|
`;
|
|
before = await readRows();
|
|
await runMigration();
|
|
migrated = await readRows();
|
|
}, 120_000);
|
|
|
|
const meanOf = (all: Row[], type: string, granularity: string, at?: number) =>
|
|
all.find(
|
|
(r) =>
|
|
r.type === type &&
|
|
r.granularity === granularity &&
|
|
(at === undefined || r.bucket_start.getTime() === at),
|
|
)?.mean;
|
|
|
|
describe("migration 0372", () => {
|
|
it("covers all four granularities of both types", () => {
|
|
for (const type of ["PULSE", "HEART_RATE_VARIABILITY"]) {
|
|
for (const g of ["DAY", "WEEK", "MONTH", "YEAR"]) {
|
|
expect(meanOf(before, type, g)).toBe(SENTINEL);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives a PULSE day the mean of its hours' means", async () => {
|
|
const after = await readRows();
|
|
expect(meanOf(after, "PULSE", "DAY", TUE)).toBeCloseTo(TUE_VALUE, 9);
|
|
expect(meanOf(after, "PULSE", "DAY", WED)).toBeCloseTo(WED_VALUE, 9);
|
|
});
|
|
|
|
it("gives a PULSE week, month and year the mean of their days", async () => {
|
|
const after = await readRows();
|
|
for (const g of ["WEEK", "MONTH", "YEAR"]) {
|
|
expect(meanOf(after, "PULSE", g)).toBeCloseTo(
|
|
(TUE_VALUE + WED_VALUE) / 2,
|
|
9,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("matches what the writer stores now", async () => {
|
|
const migrated = await readRows();
|
|
await recomputeUserRollups(USER, {
|
|
types: ["PULSE"],
|
|
from: new Date(Date.UTC(2026, 0, 1)),
|
|
to: new Date(Date.UTC(2027, 0, 1)),
|
|
});
|
|
const written = await readRows();
|
|
for (const r of written.filter((x) => x.type === "PULSE")) {
|
|
const m = migrated.find(
|
|
(x) =>
|
|
x.type === r.type &&
|
|
x.granularity === r.granularity &&
|
|
x.bucket_start.getTime() === r.bucket_start.getTime(),
|
|
);
|
|
expect(m?.mean).toBeCloseTo(r.mean, 9);
|
|
}
|
|
// Restore the migrated state for the remaining assertions.
|
|
await prisma.$executeRaw`
|
|
UPDATE measurement_rollups SET "mean" = ${SENTINEL}
|
|
WHERE "user_id" = ${USER} AND "type" = 'HEART_RATE_VARIABILITY'
|
|
`;
|
|
});
|
|
|
|
it("leaves every other type's mean alone", async () => {
|
|
const after = await readRows();
|
|
for (const r of after.filter((x) => x.type !== "PULSE")) {
|
|
expect(r.mean).toBe(SENTINEL);
|
|
}
|
|
});
|
|
|
|
it("leaves every column but mean alone", () => {
|
|
const strip = ({ mean: _mean, ...rest }: Row) => rest;
|
|
expect(migrated.map(strip)).toEqual(before.map(strip));
|
|
});
|
|
|
|
it("is idempotent", async () => {
|
|
const first = await readRows();
|
|
await runMigration();
|
|
expect(await readRows()).toEqual(first);
|
|
});
|
|
});
|