mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +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.
532 lines
15 KiB
TypeScript
532 lines
15 KiB
TypeScript
/**
|
|
* v1.42 — a stored `stats:` mean is recomputed from its samples only where
|
|
* those are provably all still in the table, and never from a fragment.
|
|
* Against Postgres.
|
|
*
|
|
* - The fold repair corrects a partial mean inside the tombstone horizon,
|
|
* takes the window's live samples into it, and leaves a day before the
|
|
* horizon exactly as it is, counting it as skipped. A second run corrects
|
|
* nothing.
|
|
* - The daily-mean consolidation and the dense hourly fold leave a stored
|
|
* day or hour before the horizon as it is, its live samples included, and
|
|
* still fold a day before the horizon that has no stored mean yet.
|
|
* - The repair and the consolidation over the same account, at the same
|
|
* time, end on the mean over every sample, and a repair after them
|
|
* corrects nothing.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const WS_HK = "HKQuantityTypeIdentifierWalkingSpeed";
|
|
const HRV_HK = "HKQuantityTypeIdentifierHeartRateVariabilitySDNN";
|
|
|
|
/** The instant every test reads the horizon against. */
|
|
const NOW = "2026-10-08T12:00:00.000Z";
|
|
/** About 210 days back: before the daily horizon and the hourly one. */
|
|
const OLD_DAY = "2026-03-10";
|
|
/** Inside the daily horizon. */
|
|
const RECENT_DAY = "2026-09-18";
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.setSystemTime(new Date(NOW));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function account(id: string) {
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id,
|
|
username: id,
|
|
email: `${id}@example.test`,
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function liveValue(userId: string, type: string, externalId: string) {
|
|
const row = await getPrismaClient().measurement.findFirst({
|
|
where: { userId, type: type as never, externalId, deletedAt: null },
|
|
select: { value: true },
|
|
});
|
|
return row?.value;
|
|
}
|
|
|
|
async function liveSampleIds(userId: string, type: string) {
|
|
const rows = await getPrismaClient().measurement.findMany({
|
|
where: {
|
|
userId,
|
|
type: type as never,
|
|
deletedAt: null,
|
|
NOT: { externalId: { startsWith: "stats:" } },
|
|
},
|
|
select: { id: true },
|
|
orderBy: { id: "asc" },
|
|
});
|
|
return rows.map((row) => row.id);
|
|
}
|
|
|
|
type Sample = {
|
|
id: string;
|
|
type: "WALKING_SPEED" | "HEART_RATE_VARIABILITY";
|
|
value: number;
|
|
at: string;
|
|
deletedAt?: string;
|
|
};
|
|
|
|
async function seed(userId: string, stats: Sample[], samples: Sample[]) {
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
...stats.map((row) => ({
|
|
id: `${userId}-${row.id}`,
|
|
userId,
|
|
type: row.type,
|
|
value: row.value,
|
|
unit: row.type === "WALKING_SPEED" ? "m/s" : "ms",
|
|
source: "APPLE_HEALTH" as const,
|
|
measuredAt: new Date(row.at),
|
|
externalId:
|
|
row.type === "WALKING_SPEED"
|
|
? `stats:${WS_HK}:${row.at.slice(0, 10)}`
|
|
: `stats:${HRV_HK}:${row.at.slice(0, 13)}`,
|
|
})),
|
|
...samples.map((row) => ({
|
|
id: `${userId}-${row.id}`,
|
|
userId,
|
|
type: row.type,
|
|
value: row.value,
|
|
unit: row.type === "WALKING_SPEED" ? "m/s" : "ms",
|
|
source: "APPLE_HEALTH" as const,
|
|
measuredAt: new Date(row.at),
|
|
externalId: `uuid-${userId}-${row.id}`,
|
|
deletedAt: row.deletedAt ? new Date(row.deletedAt) : null,
|
|
syncVersion: 1,
|
|
})),
|
|
],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An old day whose mean was taken from its full day long ago (the samples'
|
|
* tombstones are purged), and a fragment of it present now: two samples a
|
|
* later re-upload put back and a fold took again in September, and one more
|
|
* re-uploaded today. A mean over the fragment is 1; the stored mean is 3.
|
|
*/
|
|
async function seedOldDay(userId: string) {
|
|
await seed(
|
|
userId,
|
|
[
|
|
{
|
|
id: "old-mean",
|
|
type: "WALKING_SPEED",
|
|
value: 3,
|
|
at: `${OLD_DAY}T12:00:00.000Z`,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "old-t1",
|
|
type: "WALKING_SPEED",
|
|
value: 1,
|
|
at: `${OLD_DAY}T08:00:00.000Z`,
|
|
deletedAt: "2026-09-01T03:00:00.000Z",
|
|
},
|
|
{
|
|
id: "old-t2",
|
|
type: "WALKING_SPEED",
|
|
value: 1,
|
|
at: `${OLD_DAY}T09:00:00.000Z`,
|
|
deletedAt: "2026-09-01T03:00:00.000Z",
|
|
},
|
|
{
|
|
id: "old-live",
|
|
type: "WALKING_SPEED",
|
|
value: 1,
|
|
at: `${OLD_DAY}T10:00:00.000Z`,
|
|
},
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A recent day an old release folded in two runs (stored: the evening only,
|
|
* 2), plus one sample synced after the second run. Every sample is here: the
|
|
* mean over all of them is (1 + 1 + 2 + 2 + 2) / 5 = 1.6.
|
|
*/
|
|
async function seedRecentDay(userId: string) {
|
|
await seed(
|
|
userId,
|
|
[
|
|
{
|
|
id: "recent-mean",
|
|
type: "WALKING_SPEED",
|
|
value: 2,
|
|
at: `${RECENT_DAY}T12:00:00.000Z`,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "recent-t1",
|
|
type: "WALKING_SPEED",
|
|
value: 1,
|
|
at: `${RECENT_DAY}T08:00:00.000Z`,
|
|
deletedAt: "2026-09-20T03:00:00.000Z",
|
|
},
|
|
{
|
|
id: "recent-t2",
|
|
type: "WALKING_SPEED",
|
|
value: 1,
|
|
at: `${RECENT_DAY}T10:00:00.000Z`,
|
|
deletedAt: "2026-09-20T03:00:00.000Z",
|
|
},
|
|
{
|
|
id: "recent-t3",
|
|
type: "WALKING_SPEED",
|
|
value: 2,
|
|
at: `${RECENT_DAY}T20:00:00.000Z`,
|
|
deletedAt: "2026-09-21T03:00:00.000Z",
|
|
},
|
|
{
|
|
id: "recent-t4",
|
|
type: "WALKING_SPEED",
|
|
value: 2,
|
|
at: `${RECENT_DAY}T22:00:00.000Z`,
|
|
deletedAt: "2026-09-21T03:00:00.000Z",
|
|
},
|
|
{
|
|
id: "recent-live",
|
|
type: "WALKING_SPEED",
|
|
value: 2,
|
|
at: `${RECENT_DAY}T23:30:00.000Z`,
|
|
},
|
|
],
|
|
);
|
|
}
|
|
|
|
describe("fold repair horizon", () => {
|
|
it("repairs inside the horizon, leaves an older day as it is, and a second run corrects nothing", async () => {
|
|
const prisma = getPrismaClient();
|
|
await account("horizon-repair");
|
|
await seedOldDay("horizon-repair");
|
|
await seedRecentDay("horizon-repair");
|
|
const oldTombstonesBefore = await prisma.measurement.findMany({
|
|
where: { userId: "horizon-repair", deletedAt: { not: null } },
|
|
select: { id: true, deletedAt: true },
|
|
orderBy: { id: "asc" },
|
|
});
|
|
const { repairFoldedMeans } =
|
|
await import("@/lib/jobs/measurement-fold-repair");
|
|
|
|
const first = await repairFoldedMeans(prisma, "horizon-repair");
|
|
expect(first.status).toBe("completed");
|
|
expect(first.byType.WALKING_SPEED).toEqual({
|
|
windowsChecked: 1,
|
|
windowsCorrected: 1,
|
|
restingCorrected: 0,
|
|
windowsSkippedBeyondHorizon: 1,
|
|
windowsLeftAmbiguous: 0,
|
|
samplesAbsorbed: 1,
|
|
});
|
|
expect(
|
|
await liveValue(
|
|
"horizon-repair",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${OLD_DAY}`,
|
|
),
|
|
).toBe(3);
|
|
expect(
|
|
await liveValue(
|
|
"horizon-repair",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${RECENT_DAY}`,
|
|
),
|
|
).toBeCloseTo(1.6, 9);
|
|
// The old day's live sample is still live; the recent one is a leftover.
|
|
expect(await liveSampleIds("horizon-repair", "WALKING_SPEED")).toEqual([
|
|
"horizon-repair-old-live",
|
|
]);
|
|
const oldTombstonesAfter = await prisma.measurement.findMany({
|
|
where: {
|
|
userId: "horizon-repair",
|
|
deletedAt: { not: null },
|
|
id: { in: oldTombstonesBefore.map((row) => row.id) },
|
|
},
|
|
select: { id: true, deletedAt: true },
|
|
orderBy: { id: "asc" },
|
|
});
|
|
expect(oldTombstonesAfter).toEqual(oldTombstonesBefore);
|
|
|
|
const again = await repairFoldedMeans(prisma, "horizon-repair");
|
|
expect(again.byType.WALKING_SPEED).toEqual({
|
|
windowsChecked: 1,
|
|
windowsCorrected: 0,
|
|
restingCorrected: 0,
|
|
windowsSkippedBeyondHorizon: 1,
|
|
windowsLeftAmbiguous: 0,
|
|
samplesAbsorbed: 0,
|
|
});
|
|
expect(
|
|
await liveValue(
|
|
"horizon-repair",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${RECENT_DAY}`,
|
|
),
|
|
).toBeCloseTo(1.6, 9);
|
|
});
|
|
|
|
it("leaves an hourly mean before the hourly horizon as it is", async () => {
|
|
const prisma = getPrismaClient();
|
|
await account("horizon-repair-hourly");
|
|
await seed(
|
|
"horizon-repair-hourly",
|
|
[
|
|
{
|
|
id: "hour-mean",
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 60,
|
|
at: `${OLD_DAY}T10:30:00.000Z`,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "hour-t1",
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 20,
|
|
at: `${OLD_DAY}T10:05:00.000Z`,
|
|
// Folded 90 days on, kept 75 more: purged by now in reality, here
|
|
// the one survivor of a fragment.
|
|
deletedAt: "2026-06-10T10:30:00.000Z",
|
|
},
|
|
],
|
|
);
|
|
const { repairFoldedMeans } =
|
|
await import("@/lib/jobs/measurement-fold-repair");
|
|
|
|
const result = await repairFoldedMeans(prisma, "horizon-repair-hourly");
|
|
expect(result.byType.HEART_RATE_VARIABILITY).toMatchObject({
|
|
windowsChecked: 0,
|
|
windowsCorrected: 0,
|
|
windowsSkippedBeyondHorizon: 1,
|
|
});
|
|
expect(
|
|
await liveValue(
|
|
"horizon-repair-hourly",
|
|
"HEART_RATE_VARIABILITY",
|
|
`stats:${HRV_HK}:${OLD_DAY}T10`,
|
|
),
|
|
).toBe(60);
|
|
});
|
|
});
|
|
|
|
describe("regular folds before the horizon", () => {
|
|
it("the daily-mean consolidation keeps an old stored mean and its live sample, and folds an old day without one", async () => {
|
|
const prisma = getPrismaClient();
|
|
await account("horizon-mean");
|
|
await seedOldDay("horizon-mean");
|
|
// An old day nobody folded yet: no stored mean to protect.
|
|
await seed(
|
|
"horizon-mean",
|
|
[],
|
|
[
|
|
{
|
|
id: "new-a",
|
|
type: "WALKING_SPEED",
|
|
value: 1,
|
|
at: "2026-03-12T08:00:00.000Z",
|
|
},
|
|
{
|
|
id: "new-b",
|
|
type: "WALKING_SPEED",
|
|
value: 2,
|
|
at: "2026-03-12T09:00:00.000Z",
|
|
},
|
|
],
|
|
);
|
|
const { consolidateDailyMean } =
|
|
await import("@/lib/measurements/consolidate-daily-mean");
|
|
|
|
const summary = await consolidateDailyMean(prisma, {
|
|
userId: "horizon-mean",
|
|
cutoffHours: 36,
|
|
log: () => {},
|
|
});
|
|
|
|
expect(summary.totals.daysLeftAsStored).toBe(1);
|
|
expect(summary.totals.daysConsolidated).toBe(1);
|
|
expect(
|
|
await liveValue(
|
|
"horizon-mean",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${OLD_DAY}`,
|
|
),
|
|
).toBe(3);
|
|
expect(
|
|
await liveValue(
|
|
"horizon-mean",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:2026-03-12`,
|
|
),
|
|
).toBeCloseTo(1.5, 9);
|
|
expect(await liveSampleIds("horizon-mean", "WALKING_SPEED")).toEqual([
|
|
"horizon-mean-old-live",
|
|
]);
|
|
|
|
// The next night changes nothing either.
|
|
const again = await consolidateDailyMean(prisma, {
|
|
userId: "horizon-mean",
|
|
cutoffHours: 36,
|
|
log: () => {},
|
|
});
|
|
expect(again.totals.daysConsolidated).toBe(0);
|
|
expect(
|
|
await liveValue(
|
|
"horizon-mean",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${OLD_DAY}`,
|
|
),
|
|
).toBe(3);
|
|
});
|
|
|
|
it("the dense hourly fold keeps an old stored hour and its live sample", async () => {
|
|
const prisma = getPrismaClient();
|
|
await account("horizon-dense");
|
|
await seed(
|
|
"horizon-dense",
|
|
[
|
|
{
|
|
id: "hour-mean",
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 60,
|
|
at: `${OLD_DAY}T10:30:00.000Z`,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "hour-t1",
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 20,
|
|
at: `${OLD_DAY}T10:05:00.000Z`,
|
|
deletedAt: "2026-06-10T10:30:00.000Z",
|
|
},
|
|
{
|
|
id: "hour-live",
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 20,
|
|
at: `${OLD_DAY}T10:10:00.000Z`,
|
|
},
|
|
// Another hour of the same day, never folded: folds as usual.
|
|
{
|
|
id: "other-hour",
|
|
type: "HEART_RATE_VARIABILITY",
|
|
value: 50,
|
|
at: `${OLD_DAY}T14:10:00.000Z`,
|
|
},
|
|
],
|
|
);
|
|
const { runDenseIntradayRetention } =
|
|
await import("@/lib/measurements/dense-intraday-retention");
|
|
|
|
const summary = await runDenseIntradayRetention(prisma, {
|
|
userId: "horizon-dense",
|
|
log: () => {},
|
|
});
|
|
|
|
expect(summary.totals.hoursLeftAsStored).toBe(1);
|
|
expect(
|
|
await liveValue(
|
|
"horizon-dense",
|
|
"HEART_RATE_VARIABILITY",
|
|
`stats:${HRV_HK}:${OLD_DAY}T10`,
|
|
),
|
|
).toBe(60);
|
|
expect(
|
|
await liveValue(
|
|
"horizon-dense",
|
|
"HEART_RATE_VARIABILITY",
|
|
`stats:${HRV_HK}:${OLD_DAY}T14`,
|
|
),
|
|
).toBe(50);
|
|
expect(
|
|
await liveSampleIds("horizon-dense", "HEART_RATE_VARIABILITY"),
|
|
).toEqual(["horizon-dense-hour-live"]);
|
|
});
|
|
});
|
|
|
|
describe("repair and consolidation together", () => {
|
|
it("end on the mean over every sample, and a later repair corrects nothing", async () => {
|
|
const prisma = getPrismaClient();
|
|
await account("together");
|
|
await seedRecentDay("together");
|
|
const { repairFoldedMeans } =
|
|
await import("@/lib/jobs/measurement-fold-repair");
|
|
const { consolidateDailyMean } =
|
|
await import("@/lib/measurements/consolidate-daily-mean");
|
|
|
|
await Promise.all([
|
|
repairFoldedMeans(prisma, "together"),
|
|
consolidateDailyMean(prisma, {
|
|
userId: "together",
|
|
cutoffHours: 36,
|
|
log: () => {},
|
|
}),
|
|
]);
|
|
expect(
|
|
await liveValue(
|
|
"together",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${RECENT_DAY}`,
|
|
),
|
|
).toBeCloseTo(1.6, 9);
|
|
expect(await liveSampleIds("together", "WALKING_SPEED")).toEqual([]);
|
|
|
|
const again = await repairFoldedMeans(prisma, "together");
|
|
expect(again.byType.WALKING_SPEED?.windowsCorrected).toBe(0);
|
|
expect(
|
|
await liveValue(
|
|
"together",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${RECENT_DAY}`,
|
|
),
|
|
).toBeCloseTo(1.6, 9);
|
|
});
|
|
|
|
it("a consolidation that ran first leaves the repair nothing to correct", async () => {
|
|
const prisma = getPrismaClient();
|
|
await account("fold-first");
|
|
await seedRecentDay("fold-first");
|
|
const { repairFoldedMeans } =
|
|
await import("@/lib/jobs/measurement-fold-repair");
|
|
const { consolidateDailyMean } =
|
|
await import("@/lib/measurements/consolidate-daily-mean");
|
|
|
|
await consolidateDailyMean(prisma, {
|
|
userId: "fold-first",
|
|
cutoffHours: 36,
|
|
log: () => {},
|
|
});
|
|
expect(
|
|
await liveValue(
|
|
"fold-first",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${RECENT_DAY}`,
|
|
),
|
|
).toBeCloseTo(1.6, 9);
|
|
|
|
const repair = await repairFoldedMeans(prisma, "fold-first");
|
|
expect(repair.byType.WALKING_SPEED?.windowsCorrected).toBe(0);
|
|
expect(
|
|
await liveValue(
|
|
"fold-first",
|
|
"WALKING_SPEED",
|
|
`stats:${WS_HK}:${RECENT_DAY}`,
|
|
),
|
|
).toBeCloseTo(1.6, 9);
|
|
});
|
|
});
|