mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user