diff --git a/prisma/migrations/0055_measurement_sleepstage_composite/migration.sql b/prisma/migrations/0055_measurement_sleepstage_composite/migration.sql new file mode 100644 index 000000000..d796abf49 --- /dev/null +++ b/prisma/migrations/0055_measurement_sleepstage_composite/migration.sql @@ -0,0 +1,35 @@ +-- v1.4.25 W17b/c — extend the measurement dedup composite with sleep_stage. +-- +-- The Withings Sleep v2 sync writes one Measurement row per stage segment +-- (AWAKE | LIGHT/CORE | DEEP | REM) for the same night. Every segment +-- shares (user_id, type=SLEEP_DURATION, measured_at, source=WITHINGS) — +-- only the per-stage label differentiates them. The legacy composite +-- (user_id, type, measured_at, source) collapses them onto a single row; +-- including sleep_stage in the unique key keeps every stage distinct +-- while preserving idempotency on a re-sync. +-- +-- Postgres NULL semantics: a unique index treats two NULL entries as +-- DISTINCT by default, which would re-break dedup for every non-sleep +-- row (where sleep_stage IS NULL). PG15+ ships `NULLS NOT DISTINCT` as +-- a native unique-index option that flips that behaviour — two rows +-- with NULL sleep_stage now collide just like any other duplicate. The +-- application stack runs Postgres 16 (docker-compose pins +-- postgres:16-alpine), so the syntax is available everywhere HealthLog +-- deploys. +-- +-- Prisma 7 still defaults to the NULLS-DISTINCT shape when it +-- regenerates the index, so this migration is hand-written. Future +-- `prisma migrate dev --create-only` runs that touch this index need to +-- preserve the `NULLS NOT DISTINCT` clause manually. + +-- Drop the legacy NULLS-DISTINCT composite. The index name is the +-- Prisma 0001_init default — verified via grep on the migrations tree. +DROP INDEX IF EXISTS "measurements_user_id_type_measured_at_source_key"; + +-- Recreate with sleep_stage as the fifth axis and NULLS NOT DISTINCT +-- so non-sleep rows (sleep_stage IS NULL) continue to dedup on the +-- first four columns alone — same effective semantics as before for +-- every meastype that is not SLEEP_DURATION. +CREATE UNIQUE INDEX "measurements_user_id_type_measured_at_source_sleep_stage_key" + ON "measurements" ("user_id", "type", "measured_at", "source", "sleep_stage") + NULLS NOT DISTINCT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b27ad21fe..f54a127cf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -360,7 +360,16 @@ model Measurement { // Original measuredAt-based dedup — kept for legacy Withings ingest // and the manual UI's "no duplicate at the same wall-clock" guard. - @@unique([userId, type, measuredAt, source]) + // + // v1.4.25 W17b/c — `sleepStage` is the fifth axis so per-stage sleep + // segments from a single night (one row per stage, same measuredAt) + // can co-exist. Migration 0055 recreated the index with + // `NULLS NOT DISTINCT` so non-sleep rows (sleepStage IS NULL) still + // collide on the first four columns — same effective dedup as before + // for every meastype other than SLEEP_DURATION. Future regenerations + // must preserve the NULLS NOT DISTINCT clause (Prisma 7 still emits + // NULLS DISTINCT by default). + @@unique([userId, type, measuredAt, source, sleepStage]) // v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index c3ebf4237..4da156e6a 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -196,28 +196,43 @@ export async function syncUserMeasurements( let imported = 0; for (const m of measures) { - // Upsert to avoid duplicates (unique on userId+type+measuredAt+source) const measType = m.type as MeasurementType; try { - await prisma.measurement.upsert({ + // v1.4.25 W17b/c — Migration 0055 added `sleepStage` to the + // composite unique with `NULLS NOT DISTINCT`, so a non-sleep + // upsert keyed on (userId, type, measuredAt, source) with + // sleepStage IS NULL is still safe. But Prisma's typed compound + // input requires a non-null `sleepStage`, so we model the + // idempotent write as a `findFirst` + `create`/`update`. The + // unique index still serializes concurrent inserts, so the worst + // case is a Prisma P2002 we catch and ignore. + const existing = await prisma.measurement.findFirst({ where: { - userId_type_measuredAt_source: { + userId, + type: measType, + measuredAt: m.measuredAt, + source: "WITHINGS", + sleepStage: null, + }, + select: { id: true }, + }); + if (existing) { + await prisma.measurement.update({ + where: { id: existing.id }, + data: { value: m.value }, + }); + } else { + await prisma.measurement.create({ + data: { userId, type: measType, + value: m.value, + unit: getUnitForType(m.type), measuredAt: m.measuredAt, source: "WITHINGS", }, - }, - update: { value: m.value }, - create: { - userId, - type: measType, - value: m.value, - unit: getUnitForType(m.type), - measuredAt: m.measuredAt, - source: "WITHINGS", - }, - }); + }); + } imported++; } catch (err) { getEvent()?.addWarning(`Failed to upsert measure: ${err}`); diff --git a/tests/integration/withings-sleep-stage-composite.test.ts b/tests/integration/withings-sleep-stage-composite.test.ts new file mode 100644 index 000000000..c943500bc --- /dev/null +++ b/tests/integration/withings-sleep-stage-composite.test.ts @@ -0,0 +1,173 @@ +/** + * v1.4.25 W17b/c — Migration 0055 composite-uniqueness contract. + * + * The Withings Sleep v2 sync writes one Measurement row per stage + * segment (AWAKE | CORE | DEEP | REM) for the same night, sharing + * (user_id, type=SLEEP_DURATION, measured_at, source=WITHINGS). The + * legacy four-column composite collapsed them onto a single row; + * Migration 0055 added `sleep_stage` as the fifth axis and recreated + * the index with `NULLS NOT DISTINCT` so: + * + * - per-stage rows for the same night now co-exist (one per stage) + * - non-sleep rows (sleep_stage IS NULL) still dedup on the first + * four columns alone — no behavioural change for every meastype + * other than SLEEP_DURATION + * - re-syncing the same night is idempotent (the stage label still + * anchors the upsert key) + */ +import { Prisma } from "@/generated/prisma/client"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-sleep-stage-composite"; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: TEST_USER_ID, + username: "sleep-stage-composite", + email: "sleep-stage-composite@example.test", + }, + }); +}); + +describe("Migration 0055 — measurement composite with sleep_stage", () => { + it("allows multiple sleep-stage rows for the same (user, type, measuredAt, source)", async () => { + const prisma = getPrismaClient(); + const measuredAt = new Date("2026-05-12T22:00:00.000Z"); + + // Four stage rows that share every legacy-composite column — only + // sleep_stage differentiates them. All four must persist. + const stages: Array<"AWAKE" | "CORE" | "DEEP" | "REM"> = [ + "AWAKE", + "CORE", + "DEEP", + "REM", + ]; + for (const stage of stages) { + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "SLEEP_DURATION", + value: 60, + unit: "minutes", + source: "WITHINGS", + measuredAt, + sleepStage: stage, + }, + }); + } + + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "SLEEP_DURATION" }, + orderBy: { sleepStage: "asc" }, + }); + expect(rows).toHaveLength(4); + expect(rows.map((r) => r.sleepStage).sort()).toEqual([ + "AWAKE", + "CORE", + "DEEP", + "REM", + ]); + }); + + it("still rejects duplicates within the same stage (idempotent re-sync)", async () => { + const prisma = getPrismaClient(); + const measuredAt = new Date("2026-05-12T22:00:00.000Z"); + const row = { + userId: TEST_USER_ID, + type: "SLEEP_DURATION" as const, + value: 60, + unit: "minutes", + source: "WITHINGS" as const, + measuredAt, + sleepStage: "DEEP" as const, + }; + + await prisma.measurement.create({ data: row }); + + // Same five-tuple → must surface as Prisma P2002. + await expect(prisma.measurement.create({ data: row })).rejects.toThrow( + Prisma.PrismaClientKnownRequestError, + ); + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(rows).toHaveLength(1); + }); + + it("preserves NULL-vs-NULL dedup for non-sleep rows (NULLS NOT DISTINCT)", async () => { + const prisma = getPrismaClient(); + const measuredAt = new Date("2026-05-12T08:30:00.000Z"); + const row = { + userId: TEST_USER_ID, + type: "WEIGHT" as const, + value: 82.3, + unit: "kg", + source: "WITHINGS" as const, + measuredAt, + // sleepStage omitted → NULL + }; + + await prisma.measurement.create({ data: row }); + + // Migration 0055 created the unique index with NULLS NOT DISTINCT, + // so the second insert MUST collide on the four legacy columns + // even though sleep_stage is NULL on both sides. + await expect(prisma.measurement.create({ data: row })).rejects.toThrow( + Prisma.PrismaClientKnownRequestError, + ); + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(rows).toHaveLength(1); + }); + + it("supports findFirst + create/update idempotency for non-sleep measurements", async () => { + const prisma = getPrismaClient(); + const measuredAt = new Date("2026-05-12T08:30:00.000Z"); + + // Prisma's typed compound input requires a non-null `sleepStage`, + // so the Withings sync writes via findFirst + create/update. This + // mirrors that path; the second pass must update in place rather + // than insert a duplicate. + for (const value of [82.4, 82.1]) { + const existing = await prisma.measurement.findFirst({ + where: { + userId: TEST_USER_ID, + type: "WEIGHT", + measuredAt, + source: "WITHINGS", + sleepStage: null, + }, + select: { id: true }, + }); + if (existing) { + await prisma.measurement.update({ + where: { id: existing.id }, + data: { value }, + }); + } else { + await prisma.measurement.create({ + data: { + userId: TEST_USER_ID, + type: "WEIGHT", + value, + unit: "kg", + measuredAt, + source: "WITHINGS", + }, + }); + } + } + + const rows = await prisma.measurement.findMany({ + where: { userId: TEST_USER_ID, type: "WEIGHT" }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].value).toBe(82.1); + }); +});