Files
HealthLog/src/lib/measurements/__tests__/folded-window.test.ts
T
Marc-André Bombeck adc7baca14 Fold only complete local days and repair means folded from part of one
The daily-mean consolidation and the dense hourly fold cut at
`now - threshold` instead of a local day boundary. A day was folded in two
runs and the second run overwrote the stored mean with the mean of the
later part alone: every mean-type day, and the boundary hour of every
heart-rate, HRV and SpO2 day, plus the derived resting figure and the DAY
rollup of such a day. The Health Connect importer split its raw window at
the same unaligned instant.

- Fold up to the start of the account's local day the threshold ends in
  (`foldBoundary`), for every consolidation pass and the Health Connect
  importer's raw/hourly split.
- A window that already has a live `stats:` row is refolded from every
  sample of it, live rows plus the fold's own tombstones; with none left
  the stored mean stays.
- `folded_window` refuses a raw sample only when its local day lies before
  the aligned boundary and a live `stats:` row of its source covers it,
  also within reach of the sample after a timezone change.
- A deletion inside a day the fold had not finished is class B.
- Add a one-time, resumable fold repair per account that recomputes each
  hourly and daily mean from its remaining samples and writes only values
  that differ, recorded in `measurement_fold_repairs` (migration 0389).
- The compaction-tombstone purge waits for the repair per account, and the
  boot sends no purge run before one account is repaired.
- The restore keys live `stats:` ids by source, and writes compaction
  tombstones back from a file older than the account's repair, resetting
  the repair.
2026-10-08 18:42:01 +02:00

335 lines
10 KiB
TypeScript

/**
* v1.42 — the `folded_window` rule and the compaction-tombstone class.
*
* Pins what counts as a folded sample (source, type, age, id shape), which
* `stats:` id covers it (the fold's own hourly or daily id, in the account's
* zone), and that a tombstone is class A only when it was already past the
* fold threshold at the moment it was deleted, which is what keeps a
* person's deletion of a recent sample out of the purge.
*/
import { describe, expect, it, vi } from "vitest";
import {
coveringStatsExternalId,
findCompactionTombstones,
findFoldedWindowDuplicates,
isFoldedWindowCandidate,
} from "../folded-window";
const DAY = 86_400_000;
const NOW = new Date("2026-10-08T12:00:00.000Z");
function raw(
type: string,
measuredAt: Date,
overrides: Record<string, unknown> = {},
) {
return {
type,
source: "APPLE_HEALTH",
externalId: "uuid-1",
measuredAt,
...overrides,
} as Parameters<typeof isFoldedWindowCandidate>[0];
}
describe("isFoldedWindowCandidate", () => {
it("takes dense samples past 90 days and mean samples past 36 hours", () => {
expect(
isFoldedWindowCandidate(
raw("PULSE", new Date(NOW.getTime() - 91 * DAY)),
NOW,
),
).toBe(true);
expect(
isFoldedWindowCandidate(
raw("PULSE", new Date(NOW.getTime() - 89 * DAY)),
NOW,
),
).toBe(false);
expect(
isFoldedWindowCandidate(
raw("RESPIRATORY_RATE", new Date(NOW.getTime() - 2 * DAY)),
NOW,
),
).toBe(true);
expect(
isFoldedWindowCandidate(
raw("RESPIRATORY_RATE", new Date(NOW.getTime() - DAY)),
NOW,
),
).toBe(false);
});
it("refuses other sources, other types, `stats:` and `retired:` ids", () => {
const old = new Date(NOW.getTime() - 200 * DAY);
expect(
isFoldedWindowCandidate(raw("PULSE", old, { source: "MANUAL" }), NOW),
).toBe(false);
expect(isFoldedWindowCandidate(raw("WEIGHT", old), NOW)).toBe(false);
expect(
isFoldedWindowCandidate(
raw("PULSE", old, {
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-01-01T10",
}),
NOW,
),
).toBe(false);
expect(
isFoldedWindowCandidate(
raw("PULSE", old, { externalId: "retired:m1:uuid-1" }),
NOW,
),
).toBe(false);
expect(
isFoldedWindowCandidate(raw("PULSE", old, { externalId: null }), NOW),
).toBe(false);
});
});
describe("coveringStatsExternalId", () => {
it("names the local hour for a dense type and the local day for a mean type", () => {
// 22:30 UTC on 1 Jan is 23:30 in Berlin (UTC+1).
const at = new Date("2026-01-01T22:30:00.000Z");
expect(
coveringStatsExternalId(
{ type: "PULSE", measuredAt: at },
"Europe/Berlin",
),
).toBe("stats:HKQuantityTypeIdentifierHeartRate:2026-01-01T23");
// 23:30 UTC is already 2 Jan in Berlin.
const late = new Date("2026-01-01T23:30:00.000Z");
expect(
coveringStatsExternalId(
{ type: "RESPIRATORY_RATE", measuredAt: late },
"Europe/Berlin",
),
).toBe("stats:HKQuantityTypeIdentifierRespiratoryRate:2026-01-02");
expect(
coveringStatsExternalId(
{ type: "WEIGHT", measuredAt: at },
"Europe/Berlin",
),
).toBeNull();
});
});
function client(
live: Array<{ type: string; externalId: string; measuredAt: Date }>,
) {
const findMany = vi.fn(async () => live);
return {
findMany,
client: {
measurement: { findMany },
user: {
findUnique: vi.fn(async () => ({ timezone: "UTC" })),
},
} as unknown as Parameters<typeof findFoldedWindowDuplicates>[0],
};
}
describe("findFoldedWindowDuplicates", () => {
const old = new Date("2026-03-01T10:15:00.000Z");
it("asks nothing when no row is old enough", async () => {
const { client: c, findMany } = client([]);
const found = await findFoldedWindowDuplicates(
c,
"u1",
[raw("PULSE", new Date(NOW.getTime() - DAY))],
{ now: NOW },
);
expect(found.size).toBe(0);
expect(findMany).not.toHaveBeenCalled();
});
it("returns the rows a live `stats:` row covers, in live-only reads", async () => {
const { client: c, findMany } = client([
{
type: "PULSE",
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10",
measuredAt: new Date("2026-03-01T10:30:00.000Z"),
},
]);
const found = await findFoldedWindowDuplicates(
c,
"u1",
[
raw("PULSE", old),
raw("PULSE", new Date("2026-03-01T11:15:00.000Z"), {
externalId: "uuid-2",
}),
raw("WEIGHT", old),
],
{ now: NOW },
);
// The 11:15 sample is 45 minutes from the 10:30 anchor: outside the
// half hour any zone's hour anchor can be from a sample of its hour.
expect([...found]).toEqual([0]);
// One read by externalId, one for the timezone-change reach of the
// sample it did not cover; both live rows of the sample's own source.
expect(findMany).toHaveBeenCalledTimes(2);
for (const call of findMany.mock.calls) {
const where = (call as unknown as [{ where: Record<string, unknown> }])[0]
.where;
expect(where.deletedAt).toBeNull();
expect(where.source).toBe("APPLE_HEALTH");
}
});
});
describe("findCompactionTombstones", () => {
const measuredAt = new Date("2026-03-01T10:15:00.000Z");
const covering = [
{
type: "PULSE",
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10",
measuredAt: new Date("2026-03-01T10:30:00.000Z"),
},
];
it("takes a tombstone deleted after the fold threshold under a live `stats:` row", async () => {
const { client: c } = client(covering);
const found = await findCompactionTombstones(c, "u1", "UTC", [
{
...raw("PULSE", measuredAt),
deletedAt: new Date(measuredAt.getTime() + 91 * DAY),
},
]);
expect([...found]).toEqual([0]);
});
it("leaves a person's deletion of a then-recent sample alone, even under a `stats:` row", async () => {
const { client: c } = client(covering);
const found = await findCompactionTombstones(c, "u1", "UTC", [
{
...raw("PULSE", measuredAt),
deletedAt: new Date(measuredAt.getTime() + 2 * DAY),
},
]);
expect(found.size).toBe(0);
});
it("leaves a dense tombstone alone when only a pre-hourly daily row covers it", async () => {
const { client: c } = client([
{
type: "PULSE",
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01",
measuredAt: new Date("2026-03-01T12:00:00.000Z"),
},
]);
const found = await findCompactionTombstones(c, "u1", "UTC", [
{
...raw("PULSE", measuredAt),
deletedAt: new Date(measuredAt.getTime() + 91 * DAY),
},
]);
expect(found.size).toBe(0);
});
it("leaves a deletion alone whose day the fold had not finished yet", async () => {
// Past the 90 days by an hour, but the rest of the local day was still
// inside them: an older release folded such a day in two runs, and a
// person deleting a sample of it deleted something the fold had not
// taken.
const { client: c, findMany } = client(covering);
const found = await findCompactionTombstones(c, "u1", "UTC", [
{
...raw("PULSE", measuredAt),
deletedAt: new Date(measuredAt.getTime() + 90 * DAY + 3_600_000),
},
]);
expect(found.size).toBe(0);
expect(findMany).not.toHaveBeenCalled();
});
it("leaves a live row alone", async () => {
const { client: c } = client(covering);
const found = await findCompactionTombstones(c, "u1", "UTC", [
{ ...raw("PULSE", measuredAt), deletedAt: null },
]);
expect(found.size).toBe(0);
});
});
/**
* Health Connect folds its raw heart-rate history the way Apple Health does:
* past the 90-day window into one `stats:` row per local hour, under its own
* source. The mean-day rule stays Apple Health only, because the nightly
* mean consolidation never reads another source.
*/
describe("Health Connect", () => {
const old = new Date("2026-03-01T10:15:00.000Z");
const hc = (
type: string,
at: Date,
overrides: Record<string, unknown> = {},
) =>
raw(type, at, {
source: "HEALTH_CONNECT",
externalId: "hc:00000000-0000-0000-0000-000000000001:1",
...overrides,
});
/** A lookup that answers per source, as the database does. */
function sourceAwareClient(
live: Array<{ type: string; source: string; externalId: string }>,
) {
const findMany = vi.fn(async (args: { where: { source: string } }) =>
live.filter((row) => row.source === args.where.source),
);
return {
findMany,
client: {
measurement: { findMany },
} as unknown as Parameters<typeof findFoldedWindowDuplicates>[0],
};
}
it("takes a raw dense sample past the window, and not a mean-type one", () => {
expect(isFoldedWindowCandidate(hc("PULSE", old), NOW)).toBe(true);
expect(
isFoldedWindowCandidate(
hc("PULSE", new Date(NOW.getTime() - 89 * DAY)),
NOW,
),
).toBe(false);
expect(isFoldedWindowCandidate(hc("RESPIRATORY_RATE", old), NOW)).toBe(
false,
);
expect(
isFoldedWindowCandidate(
hc("PULSE", old, {
externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10",
}),
NOW,
),
).toBe(false);
});
it("is covered only by a `stats:` row of its own source", async () => {
const hour = "stats:HKQuantityTypeIdentifierHeartRate:2026-03-01T10";
const { client: c, findMany } = sourceAwareClient([
{ type: "PULSE", source: "HEALTH_CONNECT", externalId: hour },
]);
const found = await findFoldedWindowDuplicates(
c,
"u1",
[hc("PULSE", old), raw("PULSE", old)],
{ now: NOW, tz: "UTC" },
);
// The Health Connect sample is a duplicate; the Apple Health sample of
// the same hour is not, because no Apple Health `stats:` row covers it.
expect([...found]).toEqual([0]);
const sources = findMany.mock.calls.map(
(call) =>
(call as unknown as [{ where: { source: string } }])[0].where.source,
);
expect([...new Set(sources)].sort()).toEqual([
"APPLE_HEALTH",
"HEALTH_CONNECT",
]);
});
});