Platform-wide hardening: the failure classes found live this week hunted down across every integration. Fitbit carried the same tombstone wedge fixed for Google — probe now matches soft-deleted rows and the update resurrects them; the comment describing a partial unique index that never existed is corrected. Sleep segment ids are stable across re-scoring for Withings (session+segment-start), WHOOP/Polar (one segment per stage, no positional index) and Oura (run-start keyed), each with a session-scoped sweep that soft-deletes rows a re-score orphaned — so legacy and affected nights heal as they are re-read. One platform rule for deleted rows: source-owned readings resurrect on re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV re-imports, Apple stats day-totals); Apple sample-grain deletions stick per the paired-client contract, and the mood bulk route's tombstone match is now a true no-op reported as duplicate. Import counts reconcile against actual createMany counts. Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so an expired connection parks instead of stamping success hourly; backfill and sleep-repair markers stamp only after a clean pass (pg-boss retries are now real); failed measurement writes hold the watermark; no-op updates are skipped; mapping.md tells the current truth. Scale: doctor-report reads only consumed columns and enabled sections; exports and backups keyset-paginate; achievements tally via SQL buckets; series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear two-pointer; distinct-type scans run server-side; the graded-series cold fallback is bounded; the fitbit defer tracker loops instead of spreading.
19 KiB
Google Health field → Measurement mapping
Single source of truth for the Google Health API v4 data-type → HealthLog
Measurement mapping. Keep this in sync with the per-type mappers in client.ts.
Every row ingests server-side with source = GOOGLE_HEALTH and
externalId = <anchor>:<fieldTag> (the anchor is the data point's sample time for
spot readings or its civil date for daily summaries; the field-tag disambiguates
the metric). The cumulative daily totals use the stats:<fieldTag>:<YYYY-MM-DD>
overwrite shape instead.
Google Health is a separate, coexisting provider from the classic Fitbit
integration (src/lib/fitbit/*, source = FITBIT). They never share a token,
connection row, source enum, or cookie.
The three encodings (casing)
Each data type has THREE on-the-wire encodings, all pinned in
GOOGLE_HEALTH_DATA_TYPES so a fetcher can never mix them up:
- request path — kebab-case (
body-fat,daily-resting-heart-rate). filterparameter — snake_case (body_fat.sample_time.physical_time) for sample / interval / session / sleep shapes. Daily-summary.datefilters are the documented self-contradiction: the data-types index's "filter parameter" column says snake_case (daily_heart_rate_variability), but thedataPoints.listreference's only worked daily example isdailyHeartRateVariability.date < "2024-08-15"— the camelCase payload key. Live, the snake form returned HTTP 200 with zero rows on accounts whose companion app visibly holds daily HRV/RHR. The client sends the camelCase worked-example form first and falls back to snake_case once if the first page 400s; the accepted style is annotated (googleHealth.dateFilter.style) and surfaced by the structure probe asrequestShape.- response payload — camelCase. The
DataPointvalue is a union keyed by the camelCase type name (bodyFat,dailyRestingHeartRate,activeEnergyBurned, …) with camelCase nested objects (sampleTime.physicalTime,interval.startTime,civilStartTime.date).
proto3 int64 fields arrive as JSON strings ("12345"): steps.countSum,
heartRate.beatsPerMinute, dailyRestingHeartRate.beatsPerMinute,
height.heightMillimeters, distance.millimetersSum, floors.countSum,
exercise.metricsSummary.averageHeartRateBeatsPerMinute, the sleep summary
minutes. Every numeric extractor coerces numeric strings before the finite
check. (dailyRespiratoryRate.breathsPerMinute and the temperature / glucose
fields are plain JSON numbers.)
Read method
Two read methods are in use — NOT every type supports dataPoints.list:
dataPoints.list(GET,nextPageTokenpagination) — spot samples (weight, body-fat, heart-rate, height), daily summaries (daily-oxygen-saturation, daily-heart-rate-variability, daily-resting-heart-rate, daily-respiratory-rate, daily-vo2-max), and the session types (sleep, exercise; pageSize default & max 25).dataPoints:dailyRollUp(POST,windowSizeDays: 1) — the cumulative activity totals (steps, distance, active-energy-burned, floors). Their list surface returns minute-grain observation buckets (≈1440/day), NOT daily totals — and floors has no list method at all. The request range is a pair of CivilDateTime bounds ({date:{year,month,day}, time:{hours,minutes, seconds,nanos}}), user-local; per the documented example, theendbound is the LAST covered civil day at 23:59:59 — not the next day's midnight. Max 90 days per request for these types (14 days only for heart-rate / total-calories / active-minutes / calories-in-heart-rate-zone); the client chunks longer ranges (buildDailyRollUpBody), falls back once to 14-day chunks if the first request 400s, and pins the full-sync horizon (GOOGLE_HEALTH_ROLLUP_BACKFILL_DAYS).
Legal incremental filter fields are per-shape — anything else is an HTTP 400
INVALID_ARGUMENT:
| Shape | Filter field | Bound format |
|---|---|---|
| Sample | {snake_type}.sample_time.physical_time |
RFC-3339 |
| Daily summary | {camelType}.date (worked example), snake fallback on first-page 400 |
YYYY-MM-DD |
| Sleep | sleep.interval.end_time (the ONLY legal time field) |
RFC-3339 |
| Session | {snake_type}.interval.civil_start_time (the ONLY legal one) |
offset-less civil ISO (no Z) |
The exercise civil bound is derived from the watermark in the USER'S zone.
Health-metrics bundle
Scope: googlehealth.health_metrics_and_measurements.readonly.
| Data type (path) | Payload value read | MeasurementType | Unit | fieldTag | Grain | Note |
|---|---|---|---|---|---|---|
weight |
weight.weightGrams ÷ 1000 |
WEIGHT |
kg | weight |
sample | Grams on the wire. Picker ranks a real Withings scale above Google Health. |
body-fat |
bodyFat.percentage |
BODY_FAT |
% | body_fat |
sample | Union key is camelCase bodyFat. |
daily-oxygen-saturation |
dailyOxygenSaturation.averagePercentage |
OXYGEN_SATURATION |
% | spo2 |
daily | The bare oxygen-saturation type is per-SAMPLE and rejects a .date filter. |
daily-heart-rate-variability |
dailyHeartRateVariability.averageHeartRateVariabilityMilliseconds |
HEART_RATE_VARIABILITY |
ms | hrv |
daily | Decision: SDNN slot (Apple-comparable), NOT HRV_RMSSD. The daily field is an unlabelled "average HRV ms" — re-confirm estimator live. |
daily-resting-heart-rate |
dailyRestingHeartRate.beatsPerMinute (int64 string) |
RESTING_HEART_RATE |
bpm | rhr |
daily | |
daily-respiratory-rate |
dailyRespiratoryRate.breathsPerMinute (number) |
RESPIRATORY_RATE |
breaths/min | resp_rate |
daily | respiratory-rate does NOT exist in the catalogue. Schema is {date, breathsPerMinute} — the earlier dailyRespiratoryRateBpm leaf never existed. |
heart-rate |
heartRate.beatsPerMinute (int64 string) |
PULSE |
bpm | hr |
sample | Intraday spot HR; per-minute volume — consider the 14-day-max rollup if PULSE should stay coarse. |
blood-glucose |
bloodGlucose.bloodGlucoseMilligramsPerDeciliter (number) |
BLOOD_GLUCOSE |
mg/dL | glucose |
sample | Already in the canonical mg/dL storage unit. Meal/timing/specimen enums not stored. |
core-body-temperature |
coreBodyTemperature.temperatureCelsius (number) |
BODY_TEMPERATURE |
celsius | core_temp |
sample | Core slot — distinct from SKIN_TEMPERATURE / WRIST_TEMPERATURE. |
daily-sleep-temperature-derivations |
dailySleepTemperatureDerivations.nightlyTemperatureCelsius (number) |
WRIST_TEMPERATURE |
celsius | wrist_temp |
daily | ABSOLUTE nightly skin temperature (mean over the night's samples) — the schema refutes the earlier "deviation-only" premise. Baseline/stddev derivations not stored. |
height |
height.heightMillimeters (int64 string) ÷ 10 |
User.heightCm |
cm | — | sample | Profile seed — written ONLY when null; never a Measurement. List order is DESCENDING → the seed picks max(sampleTime) explicitly. The earlier heightMeters leaf never existed. |
Daily-summary date fields are {year,month,day} objects, anchored at
UTC-midday so a timezone shift can't roll the civil day. Every value passes a
finite + strictly-positive guard (the metric-bundle readings are all positive —
a zero/NaN is a garbage/empty reading and is dropped).
Documented skips (full-catalogue reconciliation, 2026-07-07)
Every remaining catalogue type, with the reason it is NOT fetched:
oxygen-saturation,heart-rate-variability(per-sample) — the daily grain is the chosen product surface (daily-oxygen-saturation/daily-heart-rate-variability); a second per-sample reader would double the same nights. The per-sample HRV type carries explicit RMSSD + SDNN fields — the reference if the daily "average HRV ms" estimator ever needs re-checking.respiratory-rate-sleep-summary— per-sleep-stage breathing statistics;daily-respiratory-ratealready fillsRESPIRATORY_RATE.run-vo2-max— per-run sample;daily-vo2-maxalready fillsVO2_MAX(a second writer would double the same day).active-minutes,active-zone-minutes,time-in-heart-rate-zone,daily-heart-rate-zones,calories-in-heart-rate-zone— no HealthLog zone-minutes concept; not inventing a MeasurementType without a product surface.total-calories— folds BMR into the total; HealthLog stores the ACTIVE portion (active-energy-burned). No total-energy bucket (ENERGY_EXPENDITURE_KJis WHOOP-native and stays vendor-scoped).activity-level,sedentary-period— interval classifications with no HealthLog concept.altitude— elevation gain;floorsalready covers the climbed signal.swim-lengths-data— per-interval stroke data; swims land asexercisesessions (Workout rows).hydration-log— fluid INTAKE;TOTAL_BODY_WATERis body composition, not intake. No intake module.nutrition-log,food,food-measurement-unit— no nutrition module.electrocardiogram,irregular-rhythm-notification— deliberate scope policy: the ECG/IRN Restricted scopes are only added together with a real reader (seeresolveGoogleHealthScopes).
Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in catalogue yet)
blood-pressure → BLOOD_PRESSURE_SYS+_DIA and basal-body-temperature →
BODY_TEMPERATURE are roadmap items without catalogue entries today. ECG/IRN
light up when a reader is added together with its Restricted scopes.
Activity bundle — daily cumulative (dailyRollUp)
Scope: googlehealth.activity_and_fitness.readonly. The four running totals
read through POST :dailyRollUp with windowSizeDays: 1; each aggregate window
carries civilStartTime/civilEndTime (CivilDateTime objects —
{date:{year,month,day}, time:{…}}) and a union keyed by the camelCase type
name with the *Sum rollup fields. The day key comes from
civilStartTime.date, anchored at UTC-midday. The externalId carries the
stats: daily-total prefix — stats:<fieldTag>:<YYYY-MM-DD> — so a re-fetched
day overwrites in place, matching the Apple-Health
stats:<HK>:<YYYY-MM-DD> overwrite contract. The running totals preserve a
0 (a rest day is real data, not a gap).
| Data type (path) | Rollup value read | MeasurementType | Unit | fieldTag | Note |
|---|---|---|---|---|---|
steps |
steps.countSum (int64 string) |
ACTIVITY_STEPS |
steps | steps |
daily total; 0 valid |
distance |
distance.millimetersSum (int64 string) ÷1000 |
WALKING_RUNNING_DISTANCE |
m | distance |
millimetres on the wire |
active-energy-burned |
activeEnergyBurned.kcalSum (number) |
ACTIVE_ENERGY_BURNED |
kcal | active_energy |
ACTIVE portion only — NOT total-calories (which folds in BMR) |
floors |
floors.countSum (int64 string) |
FLIGHTS_CLIMBED |
flights | floors |
daily total; 0 valid; no list method — rollup is the only read |
VO2 max is NOT a rollup type — daily-vo2-max is a daily summary read via list
(daily_vo2_max.date filter), value dailyVo2Max.vo2Max, strictly positive,
daily latest-wins, same stats:-style per-day overwrite key (vo2_max).
Sleep bundle
Scope: googlehealth.sleep.readonly. Sleep sessions filter only on
sleep.interval.end_time (a night is fetched when it ENDS after the cursor).
The payload: sleep.interval (SessionTimeInterval — startTime/endTime
RFC-3339), sleep.type (CLASSIC | STAGES), sleep.stages[]
({startTime, startUtcOffset, endTime, endUtcOffset, type}), and
sleep.summary (minutesAsleep/minutesAwake, int64 strings). HealthLog
stores one SLEEP_DURATION row per stage SEGMENT, measuredAt = that segment's END instant, harmonised onto the shared SleepStage enum the night-total +
hypnogram readers consume. externalId = <anchor>:sleep:<segment-start-ISO>
(v1.28.18): the anchor prefers the DataPoint's invariant resource name,
falling back to the session interval.endTime (then startTime) parsed to a
UTC ISO instant, and the per-segment key is the segment's own START instant —
NOT a positional index and NOT the stage label, so a re-scored night overwrites
in place and a mere re-classification (LIGHT→DEEP on the same block) UPDATES
the row rather than orphaning it. After mapping, the sync runs a
replace-by-window cleanup (replaceStaleGoogleHealthSleep): any LIVE
GOOGLE_HEALTH SLEEP_DURATION row inside the session's
[windowStart, windowEnd] that this fetch did NOT re-produce is soft-deleted,
so segments an earlier scoring left behind (old volatile keys, dropped blocks)
never double-count the night total. Stage map (SLEEP_STAGE_TYPE enum,
lowercased):
LIGHT → CORE ("light" ↔ Apple "core" shallow-NREM band), DEEP → DEEP,
REM → REM, AWAKE/RESTLESS → AWAKE, classic ASLEEP → ASLEEP.
SLEEP_STAGE_TYPE_UNSPECIFIED / unknown labels are skipped, not mis-bucketed.
Exercise bundle — Workouts
Scope: googlehealth.activity_and_fitness.readonly. Sessions filter only on
exercise.interval.civil_start_time with an offset-less civil bound (derived
from the watermark in the user's zone). Each exercise session → one Workout
row (NOT a Measurement), keyed (userId, source: "GOOGLE_HEALTH", externalId)
where externalId is the DataPoint's top-level name resource name (there is no
session-id field; exercise:<startISO> when absent). Fields: sportType
(exercise.exerciseType UPPERCASE enum → canonical WorkoutSportType, other
fallback), startedAt/endedAt from exercise.interval.startTime/endTime
(RFC-3339), durationSec, totalEnergyKcal
(exercise.metricsSummary.caloriesKcal), totalDistanceM
(exercise.metricsSummary.distanceMillimeters ÷ 1000), avgHeartRate
(exercise.metricsSummary.averageHeartRateBeatsPerMinute, int64 string).
metricsSummary carries no max/min HR → null. A Google Health run and the
same run via Apple Health / WHOOP stay distinct rows; the read-time
pickCanonicalWorkoutRows picker collapses the cross-source twin per the user's
source ladder.
Idempotency
(userId, type, source: "GOOGLE_HEALTH", externalId) unique. Spot/daily metric
rows: externalId = <anchor>:<fieldTag>. Daily cumulative activity rows:
externalId = stats:<fieldTag>:<YYYY-MM-DD> (Apple-Health overwrite shape).
Sleep rows: externalId = <anchor>:sleep:<segment-start-ISO> (see the sleep
bundle above). Workouts: (userId, source: "GOOGLE_HEALTH", externalId) on the
Workout table. A re-fetch of the same window (daily summaries re-roll after
the fact, so the incremental overlap is 24 h) overwrites in place.
The measurements unique index is FULL — it covers soft-deleted rows too, so
exactly one row can ever exist per key, live or tombstoned. The upsert probe
therefore matches tombstoned rows as well, and a re-import RESURRECTS them
(the update branch clears deletedAt; v1.28.24) — Google is the source of
truth for its own rows, and treating a tombstone as absent would plan an insert
that skipDuplicates drops silently, wedging the key forever. An unchanged
LIVE row is skipped entirely (no write, no syncVersion bump) so the 24 h
overlap re-fetch doesn't churn the DB or the iOS delta pull.
Error surfacing
Non-2xx Google Health bodies carry the AIP-193 envelope
({"error":{code,message,status}}). The client extracts a redacted
STATUS: message detail (message capped at 200 chars, bearer tokens and URL
query strings stripped) into GoogleHealthApiError.upstreamError and the
external-call annotation, so a field-grammar 400 is diagnosable from operator
logs. The OAuth token endpoint uses the flat {"error":"invalid_grant"} shape
instead — handled separately in postToken.
Structure probe
POST /api/integrations/google-health/test with body
{"probe":"structure"} fetches ONE recent page/window per data type and returns
only the JSON structure (field names + typeof leaves — never values), plus
the per-type error verdict on failure. Self-hoster diagnostics for payload-shape
drift.