Files
HealthLog/tests/integration/dense-intraday-retention.test.ts
T
Marc-André Bombeck d8f623aea3 fix(measurements): close residual P2002 in the dense intra-day fold
Resolve the canonical daily row deterministically: prefer the row already
carrying the target stats: externalId before falling back to the row on the
canonical local-noon instant, with a stable orderBy tiebreaker. Adopting a
sibling and stamping the target externalId onto it could otherwise collide on
the (userId, type, source, externalId) index with the real canonical row.

Re-anchor the adopted row to local-noon only when that slot is free, so an
externalId row at a tz/DST-shifted instant cannot collide on the measured_at
composite during adopt.

Guard the create against a concurrent insert on the same canonical composite:
catch P2002 outside the transaction (a caught error inside leaves the tx in
the aborted state) and retry the fold once, which then resolves and adopts the
winning row instead of creating a duplicate.

Add a real-Postgres case covering a row already on the canonical instant under
the target stats: externalId plus a fresh out-of-window batch: the fold adopts
in place, no P2002, one canonical row with the correctly folded mean.
2026-06-03 09:17:13 +02:00

392 lines
13 KiB
TypeScript

/**
* v1.10.2 — real-Postgres integration coverage for
* `runDenseIntradayRetention()`. The mocked unit tests could not surface
* the v1.10.0.2 P2002 fold-coexistence bug: the fold mints a canonical
* daily-mean row at the user's local-noon instant, but a row may ALREADY
* sit on that `(userId, type, measuredAt, source, sleepStage)` unique
* index from another path. An externalId-keyed upsert misses the lookup
* and the INSERT then violates the measured_at index with P2002.
*
* This file seeds that exact colliding shape against a live Postgres and
* asserts the reworked fold coexists with the pre-existing daily row,
* stays idempotent, and preserves the dense-tier scope.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention";
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
const TEST_USER_ID = "user-dense-retention";
const TZ = "Europe/Berlin";
const DAY_KEY = "2026-05-01";
// The canonical local-noon instant the fold writes for the seeded day.
const CANONICAL = canonicalDailyTimestamp(DAY_KEY, TZ);
const HRV_STATS_ID = "stats:HKQuantityTypeIdentifierHeartRateVariabilitySDNN:2026-05-01";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "dense-retention",
email: "dense-retention@example.test",
timezone: TZ,
},
});
});
describe("runDenseIntradayRetention (real Postgres)", () => {
it("folds out-of-window HRV per-sample rows into one daily MEAN and soft-deletes them", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 40,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
externalId: "hk-hrv-1",
},
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 60,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T09:00:00.000Z"),
externalId: "hk-hrv-2",
},
],
});
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
expect(summary.totals.perSampleRowsSoftDeleted).toBe(2);
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
});
expect(live).toHaveLength(1);
// MEAN of (40, 60) = 50.
expect(live[0].value).toBeCloseTo(50, 6);
expect(live[0].externalId).toBe(HRV_STATS_ID);
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
});
it("coexists with a pre-existing daily row on the canonical instant (the P2002 case)", async () => {
const prisma = getPrismaClient();
// A previously-collapsed daily `stats:` row ALREADY sits on the
// canonical local-noon instant under a DIFFERENT HK stats id than the
// one the fold mints (e.g. a row minted by a sibling consolidation path,
// or a legacy stats id). The scan EXCLUDES it (NOT startsWith 'stats:'),
// so it is never folded into the mean — but pre-v1.10.2 the fold's
// externalId-keyed upsert missed it on lookup and the INSERT then
// collided on (userId, type, measuredAt, source, sleepStage) with P2002.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 999, // stale value to be overwritten by the fold
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: CANONICAL,
externalId: "stats:legacy-hrv-id:2026-05-01",
},
});
// Out-of-window per-sample rows that should fold into the canonical row.
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 40,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
externalId: "hk-hrv-1",
},
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 60,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T18:00:00.000Z"),
externalId: "hk-hrv-2",
},
],
});
// Must not throw P2002.
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
});
// Exactly one live daily row: the pre-existing one, adopted in place.
expect(live).toHaveLength(1);
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
// The fold refreshed the value to the per-sample MEAN (50), overwriting
// the stale 999, and stamped the canonical `stats:` externalId.
expect(live[0].value).toBeCloseTo(50, 6);
expect(live[0].externalId).toBe(HRV_STATS_ID);
// The two out-of-window samples are tombstoned.
const tombstoned = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: { not: null },
},
});
expect(tombstoned).toHaveLength(2);
});
it("adopts the row already carrying the target stats externalId on the canonical instant (VECTOR 1)", async () => {
const prisma = getPrismaClient();
// BOTH-present collision (VECTOR 1). A prior fold already minted the
// canonical daily row carrying the TARGET `stats:` externalId and it
// sits on the canonical local-noon instant. A fresh batch of
// out-of-window per-sample rows then arrives for the same day. The fold
// must adopt the EXISTING canonical row in place — refreshing its value
// and re-anchoring it — never INSERT a second row at local-noon (that
// would collide on the measured_at composite) and never stamp the target
// externalId onto a sibling (that would collide on the externalId
// composite with this very row). The pre-fix `findFirst` had no
// externalId discrimination and no deterministic `orderBy`, so it could
// pick the wrong row and trip P2002 on the externalId index.
await prisma.measurement.create({
data: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 999, // stale — the fold overwrites it with the day's mean
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: CANONICAL,
externalId: HRV_STATS_ID, // the TARGET canonical externalId
},
});
// Out-of-window per-sample rows that fold into the canonical row.
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 40,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
externalId: "hk-hrv-1",
},
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 60,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T18:00:00.000Z"),
externalId: "hk-hrv-2",
},
],
});
// Must not throw P2002.
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
});
// Exactly ONE live canonical row remains — the row that already carried
// the target `stats:` externalId, adopted in place and folded.
expect(live).toHaveLength(1);
expect(live[0].externalId).toBe(HRV_STATS_ID);
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
// MEAN of the two folded per-sample values (40, 60) = 50, overwriting
// the stale 999.
expect(live[0].value).toBeCloseTo(50, 6);
// Both scanned per-sample rows are tombstoned.
const tombstoned = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: { not: null },
},
});
expect(tombstoned).toHaveLength(2);
});
it("does not tombstone a per-sample row that happens to fall on the canonical instant", async () => {
const prisma = getPrismaClient();
// One per-sample row sits exactly on the canonical local-noon instant;
// it must become the adopted daily row, not get soft-deleted.
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 50,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: CANONICAL,
externalId: "hk-hrv-noon",
},
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 70,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
externalId: "hk-hrv-morning",
},
],
});
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(1);
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
});
expect(live).toHaveLength(1);
expect(live[0].measuredAt.getTime()).toBe(CANONICAL.getTime());
// MEAN of (50, 70) = 60 — the adopted row carries the day's mean.
expect(live[0].value).toBeCloseTo(60, 6);
expect(live[0].externalId).toBe(HRV_STATS_ID);
});
it("is idempotent — a second run is a no-op and the value stays correct", async () => {
const prisma = getPrismaClient();
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "PULSE",
value: 60,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T06:00:00.000Z"),
externalId: "hk-pulse-1",
},
{
userId: TEST_USER_ID,
type: "PULSE",
value: 80,
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-01T18:00:00.000Z"),
externalId: "hk-pulse-2",
},
],
});
const first = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(first.totals.daysConsolidated).toBe(1);
expect(first.totals.perSampleRowsSoftDeleted).toBe(2);
// Second run must converge to zero work and never collide on the
// canonical row it minted on the first pass.
const second = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
retentionDays: 0,
log: () => {},
});
expect(second.totals.daysConsolidated).toBe(0);
expect(second.totals.perSampleRowsSoftDeleted).toBe(0);
const live = await prisma.measurement.findMany({
where: { userId: TEST_USER_ID, type: "PULSE", deletedAt: null },
});
expect(live).toHaveLength(1);
// MEAN of (60, 80) = 70.
expect(live[0].value).toBeCloseTo(70, 6);
});
it("keeps in-window per-sample rows raw (retention bound)", async () => {
const prisma = getPrismaClient();
const recent = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); // 2 days ago
await prisma.measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 45,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: recent,
externalId: "hk-hrv-recent-1",
},
{
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
value: 55,
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: new Date(recent.getTime() + 60 * 60 * 1000),
externalId: "hk-hrv-recent-2",
},
],
});
const summary = await runDenseIntradayRetention(prisma, {
userId: TEST_USER_ID,
// Default 14-day window — both rows are inside it.
log: () => {},
});
expect(summary.totals.daysConsolidated).toBe(0);
const live = await prisma.measurement.findMany({
where: {
userId: TEST_USER_ID,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
});
// Both raw samples survive — the intra-day shape is preserved.
expect(live).toHaveLength(2);
});
});