feat(schema): Apple Health measurement types + sleep stages + dedup index

Extend MeasurementType with the seven HealthKit-shaped values the iOS
batch ingest needs (HRV, resting HR, active energy, flights climbed,
distance, VO2 max, body temperature) and add APPLE_HEALTH to the
MeasurementSource enum. Sleep duration unit shifts from hours to
minutes so per-stage HKCategorySample rows persist without precision
loss; the new SleepStage enum mirrors the existing GlucoseContext
column shape and a CHECK constraint scopes it to SLEEP_DURATION rows.

The composite unique index `(user_id, type, source, external_id)`
gives the new batch endpoint an idempotent dedup key while the legacy
`(user_id, type, measured_at, source)` index continues to back the
manual UI's no-duplicate-at-the-same-wall-clock guard. NULL externalId
rows stay distinct under Postgres semantics so manual entries never
collide.

Migration is strictly additive: new enum values append, new columns
are nullable, no rows mutate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:15:56 +02:00
1 parent 8569a1879f
commit 95209ebf79
9 files changed
+256 -8

No files matched your search

@@ -0,0 +1,67 @@
-- v1.4.23 — Apple Health backend foundation
--
-- Adds the enum values, columns, and composite unique index the
-- HealthKit batch-ingest endpoint needs. Strictly additive:
-- * No existing rows are rewritten.
-- * New columns are nullable.
-- * New enum values append (no reordering).
-- * The new composite unique index treats NULL externalId rows as
-- distinct (Postgres semantics) so manual entries — which have no
-- external id — don't collide.
--
-- Unit semantics change (advisory, no row mutation): `SLEEP_DURATION`
-- shifts from hours to minutes so per-stage HealthKit category samples
-- can be stored without precision loss. No production data exists for
-- this enum value yet (per the v1.4.23 W1 research), so the change is
-- effectively schema-only — the application enforces the new unit going
-- forward via `getUnitForType()`.
-- ── MeasurementType — append 7 Apple-Health-shaped enum values ──────
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HEART_RATE_VARIABILITY';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESTING_HEART_RATE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACTIVE_ENERGY_BURNED';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'FLIGHTS_CLIMBED';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_RUNNING_DISTANCE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'VO2_MAX';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE';
-- ── MeasurementSource — append APPLE_HEALTH ─────────────────────────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH';
-- ── SleepStage — new enum mirroring HKCategoryValueSleepAnalysis ────
CREATE TYPE "sleep_stage" AS ENUM (
'IN_BED',
'AWAKE',
'ASLEEP',
'REM',
'CORE',
'DEEP'
);
-- ── Measurement — new nullable columns + composite unique index ─────
ALTER TABLE "measurements"
ADD COLUMN "external_source_version" TEXT,
ADD COLUMN "sleep_stage" "sleep_stage";
-- Enforce: sleep_stage is only set for SLEEP_DURATION rows. Mirrors the
-- glucose_context CHECK constraint from migration 0021.
ALTER TABLE "measurements"
ADD CONSTRAINT "measurements_sleep_stage_requires_type"
CHECK (
sleep_stage IS NULL
OR (sleep_stage IS NOT NULL AND type = 'SLEEP_DURATION')
);
-- Composite unique index — dedup key for the Apple Health batch ingest
-- endpoint. Postgres treats NULL as distinct in unique indexes, so
-- manual entries (external_id = NULL) don't collide with each other or
-- with imported rows. Withings ingest historically used externalId
-- alongside the legacy `(userId, type, measuredAt, source)` unique
-- index — both indexes coexist; the legacy one stays as the manual-UI
-- "no duplicate at the same wall-clock" guard.
CREATE UNIQUE INDEX "measurements_user_id_type_source_external_id_key"
ON "measurements" ("user_id", "type", "source", "external_id");
+45 -3
View File
@@ -192,12 +192,20 @@ enum MeasurementType {
BLOOD_PRESSURE_DIA
PULSE
BODY_FAT
SLEEP_DURATION
SLEEP_DURATION // v1.4.23: unit semantics shifted from hours to minutes so HealthKit category samples (per-stage rows) survive without precision loss.
ACTIVITY_STEPS
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY // ms — HealthKit `heartRateVariabilitySDNN` (typically one sample per night).
RESTING_HEART_RATE // bpm — HealthKit `restingHeartRate` (one sample per day; distinct from spot PULSE).
ACTIVE_ENERGY_BURNED // kcal — HealthKit `activeEnergyBurned`, cumulative; daily-rollup pattern.
FLIGHTS_CLIMBED // count — HealthKit `flightsClimbed`, cumulative.
WALKING_RUNNING_DISTANCE // metres — HealthKit `distanceWalkingRunning`; SI metres in DB, UI converts per locale.
VO2_MAX // mL/(kg·min) — HealthKit `vo2Max`; one sample every few days at most.
BODY_TEMPERATURE // celsius — HealthKit `bodyTemperature`; iOS DTO already encodes this enum value.
@@map("measurement_type")
}
@@ -217,28 +225,62 @@ enum MeasurementSource {
MANUAL
WITHINGS
IMPORT
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
@@map("measurement_source")
}
/// Per-stage sleep label for HealthKit `HKCategoryValueSleepAnalysis`
/// samples. Non-NULL only when `Measurement.type = SLEEP_DURATION`,
/// mirroring the `GlucoseContext` pattern. A single night produces one
/// row per stage (analytics sums them for `total_minutes`).
enum SleepStage {
IN_BED
AWAKE
ASLEEP // legacy iOS 15- `asleepUnspecified` — pre-iOS-16 sleep tracking with no stage breakdown.
REM
CORE
DEEP
@@map("sleep_stage")
}
model Measurement {
id String @id @default(cuid())
userId String @map("user_id")
type MeasurementType
value Float
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius
source MeasurementSource @default(MANUAL)
measuredAt DateTime @map("measured_at")
notes String? // encrypted at app level if sensitive
externalId String? @map("external_id") // Withings grpid for dedup
// External-system dedup key. Withings used `grpid` (numeric); v1.4.23
// adds Apple Health which uses `HKSample.uuid` (string). NULL for
// manual entries — Postgres treats NULLs as distinct in unique
// indexes, so two manual rows with the same (user, type, source) are
// still allowed.
externalId String? @map("external_id")
// v1.4.23 — opaque marker for the source's schema version (HealthKit
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
externalSourceVersion String? @map("external_source_version")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
// pattern. A single night produces ~5 rows (one per stage).
sleepStage SleepStage? @map("sleep_stage")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// 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.23 — Apple Health batch dedup key. NULL externalId is distinct
// (Postgres semantics) so manual entries don't collide here.
@@unique([userId, type, source, externalId])
@@index([userId, type, measuredAt])
@@index([externalId])
@@map("measurements")