feat(schema): extend Measurement composite with sleepStage (Migration 0055)

The Withings Sleep v2 sync writes one Measurement row per stage segment
(AWAKE | CORE | DEEP | REM) for the same night, all sharing the legacy
four-column composite (user_id, type=SLEEP_DURATION, measured_at,
source=WITHINGS). Migration 0055 adds sleep_stage as the fifth axis and
recreates the unique index with NULLS NOT DISTINCT so non-sleep rows
(sleep_stage IS NULL) still dedup on the first four columns — same
effective semantics as before for every meastype other than SLEEP_DURATION.

Prisma's generated compound input requires non-null fields, so the
Withings sync's upsert is reshaped as findFirst + create/update for the
non-sleep path (sleepStage is always NULL there). The new five-column
upsert key is the canonical path for the Sleep v2 ingest landing in W17c.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 18:18:40 +02:00
1 parent 5a7d2526c9
commit df5a82bc6f
4 files changed
+247 -15

No files matched your search

+29 -14
View File
@@ -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}`);