mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
5a7d2526c9
commit
df5a82bc6f
4 files changed
+247
-15
No files matched your search
@@ -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;
|
||||
+10
-1
@@ -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])
|
||||
|
||||
+29
-14
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user