From f049eeb8b1b2d4d6ab54657bdfd33f9fff1f1193 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 07:39:29 +0200 Subject: [PATCH 01/20] fix(measurements): rework dense intra-day fold to coexist with the canonical daily row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dense intra-day retention fold minted its daily-mean stats: row with an externalId-keyed upsert. On real data a row already occupies the canonical local-noon instant — a sibling daily stats: row or a per-sample HRV/PULSE reading that fell on noon — so the upsert lookup missed (the existing row carries a different externalId) and the INSERT then violated the (user_id, type, measured_at, source, sleep_stage) unique index with P2002. The mocked unit tests could not surface it. Resolve the canonical row by the measured_at composite the collision is on (sleep_stage IS NULL for these continuous types, matched via the NULLS NOT DISTINCT index) and adopt it in place — refresh the mean, stamp the canonical stats: externalId, un-tombstone — or create it when absent. Exclude the adopted row id from the per-sample soft-delete so a noon-instant sample becomes the daily row instead of being tombstoned. Add a testcontainer integration test that seeds the colliding shape (a pre-existing canonical-instant row plus out-of-window per-sample rows) and pins no-P2002, coexistence, the noon-sample case, idempotency, and the retention bound. Update the unit coverage for the adopt-in-place path. --- .../dense-intraday-retention.test.ts | 76 ++++- .../measurements/dense-intraday-retention.ts | 86 +++-- .../dense-intraday-retention.test.ts | 308 ++++++++++++++++++ 3 files changed, 434 insertions(+), 36 deletions(-) create mode 100644 tests/integration/dense-intraday-retention.test.ts diff --git a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts index 2f039b49f..b513065e9 100644 --- a/src/lib/measurements/__tests__/dense-intraday-retention.test.ts +++ b/src/lib/measurements/__tests__/dense-intraday-retention.test.ts @@ -35,14 +35,26 @@ function row( return { id, type, value, measuredAt: new Date(iso), externalId, unit }; } -function buildPrismaMock(rowsByType: Record) { - const upsert = vi.fn().mockResolvedValue({}); +function buildPrismaMock( + rowsByType: Record, + // When set, the canonical-row lookup inside `writeDay` resolves to this + // existing row id — simulating a daily row already sitting on the + // canonical local-noon instant (the P2002 coexistence case). + existingCanonicalId: string | null = null, +) { + const create = vi.fn().mockResolvedValue({ id: "minted-daily" }); + const update = vi.fn().mockResolvedValue({}); + const findFirst = vi + .fn() + .mockResolvedValue( + existingCanonicalId ? { id: existingCanonicalId } : null, + ); const updateMany = vi.fn().mockResolvedValue({ count: 0 }); const findManyMeasurement = vi.fn( async (args: { where: { type: string } }) => rowsByType[args.where.type] ?? [], ); - const tx = { measurement: { upsert, updateMany } }; + const tx = { measurement: { create, update, findFirst, updateMany } }; return { mock: { user: { @@ -55,7 +67,9 @@ function buildPrismaMock(rowsByType: Record) { cb(tx), ), } as unknown as PrismaClient, - upsert, + create, + update, + findFirst, updateMany, findManyMeasurement, }; @@ -119,13 +133,13 @@ describe("runDenseIntradayRetention — retention bound", () => { }); describe("runDenseIntradayRetention — fold flow", () => { - it("upserts the day MEAN and soft-deletes the out-of-window rows", async () => { + it("creates the day MEAN and soft-deletes the out-of-window rows when no canonical row exists", async () => { // Two HRV samples on an out-of-window day. const hrvRows = [ row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"), row("b", 60, "2026-05-01T09:00:00.000Z", "HEART_RATE_VARIABILITY"), ]; - const { mock, upsert, updateMany } = buildPrismaMock({ + const { mock, create, update, updateMany } = buildPrismaMock({ HEART_RATE_VARIABILITY: hrvRows, }); @@ -134,13 +148,15 @@ describe("runDenseIntradayRetention — fold flow", () => { log: () => {}, }); - const upsertArg = upsert.mock.calls[0]?.[0] as { - create: { value: number; source: string; type: string }; + // No pre-existing canonical row → create, not update. + expect(update).not.toHaveBeenCalled(); + const createArg = create.mock.calls[0]?.[0] as { + data: { value: number; source: string; type: string }; }; // value is the MEAN (50), not the sum (100). - expect(upsertArg.create.value).toBeCloseTo(50, 6); - expect(upsertArg.create.source).toBe("APPLE_HEALTH"); - expect(upsertArg.create.type).toBe("HEART_RATE_VARIABILITY"); + expect(createArg.data.value).toBeCloseTo(50, 6); + expect(createArg.data.source).toBe("APPLE_HEALTH"); + expect(createArg.data.type).toBe("HEART_RATE_VARIABILITY"); // soft-delete, never hard delete. const updArg = updateMany.mock.calls[0]?.[0] as { @@ -151,8 +167,41 @@ describe("runDenseIntradayRetention — fold flow", () => { expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1); }); + it("adopts an existing canonical row in place instead of colliding (P2002 coexistence)", async () => { + const hrvRows = [ + row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"), + row("b", 60, "2026-05-01T09:00:00.000Z", "HEART_RATE_VARIABILITY"), + ]; + // A daily row already sits on the canonical local-noon instant. + const { mock, create, update, updateMany } = buildPrismaMock( + { HEART_RATE_VARIABILITY: hrvRows }, + "existing-canonical-row", + ); + + await runDenseIntradayRetention(mock, { retentionDays: 0, log: () => {} }); + + // The fold adopts the existing row in place — no create (which would + // collide on the measured_at unique index), an update with the stats + // externalId + refreshed mean. + expect(create).not.toHaveBeenCalled(); + const updateArg = update.mock.calls[0]?.[0] as { + where: { id: string }; + data: { value: number; externalId: string; deletedAt: null }; + }; + expect(updateArg.where.id).toBe("existing-canonical-row"); + expect(updateArg.data.value).toBeCloseTo(50, 6); + expect(updateArg.data.externalId.startsWith("stats:")).toBe(true); + expect(updateArg.data.deletedAt).toBeNull(); + + // The soft-delete excludes the adopted canonical row id. + const updManyArg = updateMany.mock.calls[0]?.[0] as { + where: { id: { not: string } }; + }; + expect(updManyArg.where.id.not).toBe("existing-canonical-row"); + }); + it("does not write on a dry-run", async () => { - const { mock, upsert } = buildPrismaMock({ + const { mock, create, update } = buildPrismaMock({ HEART_RATE_VARIABILITY: [ row("a", 40, "2026-05-01T08:00:00.000Z", "HEART_RATE_VARIABILITY"), ], @@ -162,7 +211,8 @@ describe("runDenseIntradayRetention — fold flow", () => { dryRun: true, log: () => {}, }); - expect(upsert).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/measurements/dense-intraday-retention.ts b/src/lib/measurements/dense-intraday-retention.ts index da61b9f6b..070ca8fdd 100644 --- a/src/lib/measurements/dense-intraday-retention.ts +++ b/src/lib/measurements/dense-intraday-retention.ts @@ -183,41 +183,81 @@ export async function runDenseIntradayRetention( const unit = dayRows[0]?.unit ?? "unknown"; let removed = 0; await pc.$transaction(async (tx) => { - // Mint / refresh the canonical daily-mean row first. The unique - // index (userId, type, source, externalId) makes the upsert - // idempotent across re-runs. Built field-by-field (no spread) per - // the no-mass-assignment convention. - await tx.measurement.upsert({ + // Fold the day to one canonical daily-mean row at local-noon. + // + // Unlike `consolidate-daily-mean` (whose types never share the + // canonical local-noon instant with another path), the dense-tier + // types HRV / PULSE are dense enough that a per-sample row can land + // exactly on the canonical timestamp, and a previously-minted daily + // row can already occupy it. An externalId-keyed upsert misses the + // lookup (the colliding row carries a different externalId) and the + // INSERT then violates the OTHER unique index + // `(userId, type, measuredAt, source, sleepStage)` with P2002. + // + // The canonical daily row is identified by BOTH unique keys at once: + // its measuredAt is always `canonicalTimestamp` (local-noon) and its + // externalId is always the `stats:` id, so the two indexes point at + // the same logical row. Resolve the row by the measured_at composite + // — the one the collision is on (`sleepStage` is NULL for these + // continuous types, matched via the NULLS-NOT-DISTINCT index) — and + // either adopt it in place or create it. Built field-by-field (no + // spread) per the no-mass-assignment convention. + const existing = await tx.measurement.findFirst({ where: { - userId_type_source_externalId: { - userId, - type, - source: "APPLE_HEALTH", - externalId, - }, - }, - create: { userId, type, - value: reducedValue, - unit, source: "APPLE_HEALTH", measuredAt: canonicalTimestamp, - externalId, - }, - update: { - value: reducedValue, - measuredAt: canonicalTimestamp, - deletedAt: null, + sleepStage: null, }, + select: { id: true }, }); + let canonicalRowId: string; + if (existing) { + // Adopt the row already sitting on the canonical instant: refresh + // its value, stamp the `stats:` externalId, and un-tombstone it. + // This coexists with a pre-existing daily row (a prior fold, a + // manual daily entry, or a per-sample row that happened at local + // noon) instead of colliding with it. + await tx.measurement.update({ + where: { id: existing.id }, + data: { + value: reducedValue, + unit, + externalId, + deletedAt: null, + }, + }); + canonicalRowId = existing.id; + } else { + const created = await tx.measurement.create({ + data: { + userId, + type, + value: reducedValue, + unit, + source: "APPLE_HEALTH", + measuredAt: canonicalTimestamp, + externalId, + }, + select: { id: true }, + }); + canonicalRowId = created.id; + } + // Soft-delete the out-of-window per-sample rows in the same // transaction — tombstone, never hard-delete; they remain as an // audit trail and drop off the live read + this pass's re-run - // discovery. + // discovery. EXCLUDE the canonical row itself: a per-sample row that + // happened to fall on the canonical local-noon instant is the row we + // just adopted as the daily mean, so tombstoning it would erase the + // fold. const del = await tx.measurement.updateMany({ - where: { id: { in: sourceRowIds }, deletedAt: null }, + where: { + id: { in: sourceRowIds, not: canonicalRowId }, + deletedAt: null, + }, data: { deletedAt: new Date() }, }); removed = del.count; diff --git a/tests/integration/dense-intraday-retention.test.ts b/tests/integration/dense-intraday-retention.test.ts new file mode 100644 index 000000000..1301f1989 --- /dev/null +++ b/tests/integration/dense-intraday-retention.test.ts @@ -0,0 +1,308 @@ +/** + * 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("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); + }); +}); From 64965162c7ecc6eabb751d81e2d9a84f55c4963c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 07:39:35 +0200 Subject: [PATCH 02/20] feat(measurements): re-enable the dense intra-day retention drain The v1.10.0.2 kill-switch defaulted the drain OFF after the P2002 fold collision. With the fold reworked to coexist with the canonical daily row and integration-tested against real rows, flip the default back ON (DENSE_INTRADAY_RETENTION_ENABLED=true unless explicitly set to false). The boot-discovery still defers past the startup storm via the boot-delay so it never contends for the connection pool on a data-heavy tenant; the nightly 03:50 walk and the per-user handler run again. --- src/lib/jobs/dense-intraday-retention.ts | 25 ++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index 34a6b58a5..f10a445ce 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -62,20 +62,21 @@ export const DENSE_INTRADAY_RETENTION_CRON = "50 3 * * *"; export const DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS = 600; /** - * Kill-switch, default OFF. The drain folds out-of-window per-sample rows - * into one daily-mean `stats:` row, but its canonical-timestamp upsert - * collides with an already-present daily row on the + * Kill-switch, default ON (re-enabled in v1.10.2). The v1.10.0.2 default + * was OFF because the fold's canonical-timestamp upsert collided with an + * already-present daily row on the * `(user_id, type, measured_at, source, sleep_stage)` unique constraint * (`P2002`) on real data — a fold-coexistence bug the mocked unit tests - * could not surface. Until the fold is reworked + integration-tested - * against real rows, the drain is disabled: the boot-discovery enqueues - * nothing, the nightly walk is skipped, and the per-user handler no-ops so - * any already-queued backlog completes harmlessly instead of erroring. - * Re-enable with `DENSE_INTRADAY_RETENTION_ENABLED=true` once fixed. The - * Stress proxy degrades gracefully on sparse intra-day data meanwhile. + * could not surface. v1.10.2 reworks `writeDay` to resolve the canonical + * local-noon row by the measured_at composite (the index the collision was + * on) and adopt it in place, so the fold now coexists with a pre-existing + * daily row, and an integration test pins the no-P2002 contract. The drain + * is on by default again; the boot-discovery still defers past the startup + * storm (`DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS`). An operator can + * still disable it explicitly with `DENSE_INTRADAY_RETENTION_ENABLED=false`. */ export const DENSE_INTRADAY_RETENTION_ENABLED = - process.env.DENSE_INTRADAY_RETENTION_ENABLED === "true"; + process.env.DENSE_INTRADAY_RETENTION_ENABLED !== "false"; export interface DenseIntradayRetentionPayload { userId: string; @@ -89,8 +90,8 @@ export interface DenseIntradayRetentionPayload { export async function runDenseIntradayRetentionForUser( userId: string, ): Promise<{ daysConsolidated: number; perSampleRowsSoftDeleted: number }> { - // Kill-switch: no-op so any already-queued backlog completes cleanly - // (drains the queue) instead of erroring on the P2002 fold collision. + // Kill-switch: when an operator opts out, no-op so any already-queued + // backlog completes cleanly (drains the queue) instead of running the fold. if (!DENSE_INTRADAY_RETENTION_ENABLED) { return { daysConsolidated: 0, perSampleRowsSoftDeleted: 0 }; } From 2795a50eef5612657268ef5f247666847413ab37 Mon Sep 17 00:00:00 2001 From: tarantila <5209092+tarantila@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:46:58 +0200 Subject: [PATCH 03/20] fix(docker): harden Docker build context --- .dockerignore | 58 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/.dockerignore b/.dockerignore index 147544119..0b09575f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,30 +7,41 @@ # compose `environment:` block at boot — `.env` should never ship. .env .env.* -!.env.example -!.env.production.example +.env.example +.env.production.example # Version control + CI metadata. .git .github +.gitignore .gitleaks.toml # Build / install output that gets regenerated inside the builder stage. node_modules .next out +dist coverage test-results playwright-report +playwright/.cache +playwright.config.ts +vitest.config.mts +vitest.integration.config.mts +tests +tests/ +e2e +e2e/ e2e-results .eslintcache +eslint-plugins +eslint-plugins/ +knip.json -# Persisted session state from e2e runs. -e2e/setup/storageState.json - -# Planning scratch + per-release audit notes (operator-local). -.planning -docs/audit +# Generated local cache files +*.tsbuildinfo +next-env.d.ts +public/sw-version.js # Captures from local debugging sessions. *.har @@ -43,3 +54,34 @@ docs/audit .vscode .idea .DS_Store +*.swp +*.swo + +# Logs / Debug +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Docker compose / local ops files +docker-compose*.yml +compose*.yml +Dockerfile +.dockerignore + +# Documentation not needed for runtime image +README.md +CHANGELOG.md +CLAUDE.md +CONTRIBUTING.md +CONTRIBUTING-AI.md +CODE_OF_CONDUCT.md +SECURITY.md +LICENSE.md +LICENSE +AGENTS.md +.planning +docs +docs/*.md +docs/**/*.md From 81b9606f6f4fcd640539f6ccd3877868bd6037e0 Mon Sep 17 00:00:00 2001 From: tarantila <5209092+tarantila@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:09:29 +0200 Subject: [PATCH 04/20] fix(docs): improve README language --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c8479c9f3..04239df32 100644 --- a/README.md +++ b/README.md @@ -530,7 +530,7 @@ A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client ### What the phone unlocks - **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other. -- **Medication reminders that actually fire.** Local notifications with action buttons (Genommen / Snooze 15 min / Übersprungen) that hit the server's mark-intake endpoint directly — without opening the app. +- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app. - **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone. - **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line. - **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface. From 48f20f46221deb77fd28cbfbb1445f2ec3aa4806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 08:45:45 +0200 Subject: [PATCH 05/20] feat(api): cap request-body size on batch and export ingest routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply safeJson's opt-in maxBytes ceiling to the body-accepting batch and export routes that lacked one. The cap measures the raw text before JSON.parse, so an over-limit payload returns a clean 413 without buffering onto the heap. - workouts/batch: reuse the existing 5 MB ceiling so chunked uploads with no Content-Length are bounded too. - measurements/batch: 4 MB — an order of magnitude above a 500-entry batch's few-hundred-KB worst case. - export/health-record: 64 KB — the selection payload is small and bounded. Each cap is a DoS ceiling sized well above any legitimate payload, never a tight bound on a real batch. --- src/app/api/export/health-record/route.ts | 9 ++- src/app/api/measurements/batch/route.ts | 13 +++- src/app/api/workouts/batch/route.ts | 11 ++- .../__tests__/api-response-safe-json.test.ts | 69 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/api-response-safe-json.test.ts diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index b8f14a602..543156eb4 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -58,7 +58,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - const { data: body, error: jsonError } = await safeJson(request); + // The selection payload is small and bounded — format, range, + // section toggles, a few flags, an optional practice-name string. + // 64 KB is far above any legitimate selection while still rejecting + // a multi-megabyte body before it reaches `JSON.parse`. A DoS + // ceiling, not a tight bound. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = exportSelectionSchema.safeParse(body); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 152435efa..a0c2a7af1 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -62,6 +62,15 @@ const PR_DETECTION_SILENT_THRESHOLD = 50; const MAX_BATCH_ENTRIES = 500; +// Body-size ceiling backstopping the per-entry cap. A worst-case entry +// (max-length hkIdentifier + unit + externalId + two ISO dates + the +// numeric fields) serialises to well under 1 KB, so a legitimate +// 500-entry batch tops out a few hundred KB. 4 MB leaves an order of +// magnitude of headroom above any real batch while still rejecting a +// multi-megabyte payload before it reaches `JSON.parse` and the heap. +// The cap is a DoS ceiling, not a tight bound. +const MAX_BODY_BYTES = 4 * 1024 * 1024; + // v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60 // batches per user per minute. Healthy iOS sync drains its // HealthKit observer queue in well under one batch per minute (the @@ -177,7 +186,9 @@ async function postBatch(request: NextRequest): Promise { return apiError("Too many batch submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: MAX_BODY_BYTES, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 5ee00fb99..8982eb914 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -175,7 +175,16 @@ async function postBatch(request: NextRequest): Promise { return apiError("Too many batch submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + // The Content-Length pre-flight above bounds the common case, but a + // chunked upload carries no Content-Length. The `safeJson` body cap + // backstops that gap: the raw text is measured before `JSON.parse`, + // so an over-limit chunked body returns a clean 413 rather than + // buffering an unbounded payload onto the heap. Reuses the same 5 MB + // ceiling — the route's 100-workout × 20 000-point worst case sits + // well below it, so a real iOS batch is never rejected. + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: MAX_BODY_BYTES, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the diff --git a/src/lib/__tests__/api-response-safe-json.test.ts b/src/lib/__tests__/api-response-safe-json.test.ts new file mode 100644 index 000000000..0b01d545b --- /dev/null +++ b/src/lib/__tests__/api-response-safe-json.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; + +import { safeJson } from "../api-response"; + +// `safeJson`'s opt-in `maxBytes` cap is the DoS ceiling the batch / +// export ingest routes lean on. The check runs against the raw text +// before `JSON.parse`, so an over-limit body never lands in heap and +// the caller gets a clean 413 envelope. + +function jsonRequest(body: string): Request { + return new Request("https://example.test/api/x", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); +} + +describe("safeJson maxBytes", () => { + it("parses a body at or under the cap", async () => { + const payload = JSON.stringify({ entries: [1, 2, 3] }); + const result = await safeJson(jsonRequest(payload), { + maxBytes: 1024, + }); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ entries: [1, 2, 3] }); + }); + + it("rejects an over-limit body with a 413 envelope", async () => { + const payload = JSON.stringify({ blob: "x".repeat(2048) }); + const result = await safeJson(jsonRequest(payload), { + maxBytes: 1024, + }); + expect(result.data).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error!.status).toBe(413); + + const body = (await result.error!.json()) as { + data: null; + error: string; + }; + expect(body.data).toBeNull(); + expect(body.error).toContain("1024"); + }); + + it("returns 400 on invalid JSON within the cap", async () => { + const result = await safeJson(jsonRequest("{not json"), { + maxBytes: 1024, + }); + expect(result.data).toBeUndefined(); + expect(result.error!.status).toBe(400); + }); + + it("still rejects a non-JSON content-type before measuring bytes", async () => { + const request = new Request("https://example.test/api/x", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "x".repeat(4096), + }); + const result = await safeJson(request, { maxBytes: 1024 }); + expect(result.error!.status).toBe(415); + }); + + it("parses unbounded when maxBytes is omitted", async () => { + const payload = JSON.stringify({ ok: true }); + const result = await safeJson(jsonRequest(payload)); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ ok: true }); + }); +}); From 786454ee2f7edabc23ae27937e1a3bcfaa328686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 08:47:05 +0200 Subject: [PATCH 06/20] fix(ui): repoint medication + admin status colours to theme tokens for light-mode AA The intake-history taken badge and the admin reminders status glyphs used bare Tailwind -400/-500 shades chosen for the dark theme. On the white Alucard light card those land well below WCAG AA (green-400 1.74:1, red-400 2.77:1, yellow-400 1.53:1). Repoint to the success / destructive / warning theme tokens (light values 6.1:1 / 5.0:1 / 5.8:1 on white) and pin the original shades behind dark: so the dark rendering stays identical. --- src/components/admin/reminders-section.tsx | 12 ++++++------ .../medications/intake-history-list-v2.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 779780777..cd2bf61de 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -236,9 +236,9 @@ export function RemindersSection() { {testNotification.data.results.map((r, i) => (
{r.success ? ( - + ) : ( - + )} {r.channel} {r.error && ( @@ -281,11 +281,11 @@ export function RemindersSection() { {med.schedules.map((sched, j) => { const statusColor = sched.status === "open" - ? "text-green-400" + ? "text-success dark:text-green-400" : sched.status === "threshold" - ? "text-yellow-400" + ? "text-warning dark:text-yellow-400" : sched.status === "missed" - ? "text-red-400" + ? "text-destructive dark:text-red-400" : sched.status === "skipped" ? "text-muted-foreground" : ""; @@ -299,7 +299,7 @@ export function RemindersSection() { {sched.label} {sched.notificationSent && ( - + {t("admin.reminderCheckNotifSent")} diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 27cdb3432..a35dd9796 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -361,7 +361,7 @@ export function IntakeHistoryListV2({ {isTaken ? (