health-db
by JeiKeiLim
README.md
# health-db
Self-hosted Apple Health data platform: the **Health Auto Export (HAE)** iOS app POSTs
JSON v2 exports to a FastAPI ingest service, which archives every payload verbatim
(archive-first), normalizes it, and stores it in PostgreSQL. Queries are served by an
MCP streamable-HTTP server (12 tools + 3 resources, Bearer auth) with a thin CLI wrapper
for dev testing. Runs as a single Docker container next to any PostgreSQL 18 instance.
```
iPhone (HAE automations) ──POST /api/ingest──► ingest :8000 ──► data/archive/ (verbatim, iron law)
│ │
▼ ▼
PostgreSQL 18 ◄── health-replay (backfill / outage queue)
│
MCP clients (Claude, …) ──streamable-http :8100/mcp──► queries.py (shared read layer) ◄── health CLI
```
## Surfaces
| Surface | Endpoint | Auth |
|---|---|---|
| Ingest | `POST /api/ingest` | `X-Ingest-Token` header (`INGEST_SECRET`) |
| Liveness | `GET /api/health` | none (includes DB connectivity) |
| Ingest stats | `GET /api/ingest/stats` | `INGEST_SECRET` |
| MCP | `http://<host>:8100/mcp` (streamable-http) | `Authorization: Bearer <INGEST_SECRET>` |
| CLI | `uv run health <command>` | uses `PSQL_*` env directly |
### MCP tools
`query_day(date, metric?)` · `get_metric_series(metric, from, to)` · `list_metrics()` ·
`list_workouts(from?, to?, limit, offset)` — paged (default 500, max 2000; `next_offset`
None = last page) · `get_workout_detail(id, route_offset?, route_limit?)` — HR
inline; GPS route paged (default 1000, max 10000; `route_count` + `next_route_offset`
None = last page) · `get_trends(metric, window)` ·
`freshness_check()` · `raw_samples(metric?, from?, to?, limit, offset)` ·
`list_ecgs(from?, to?, limit)` — paged (default 50, max 500; classification, avg HR,
voltage count included) · `get_ecg_detail(id, voltage_offset?, voltage_limit?)` —
voltage samples paged (default 1000, max 10000; `next_voltage_offset` None = last
page) · `list_heart_notifications(from?, to?, limit)` — paged (default 50, max 500;
nested HR/HRV samples included) ·
`list_sleep_sessions(from?, to?, limit, offset)` — one row per recognized aggregated-sleep
record (per-source rows never merged; durations in hours verbatim)
### MCP resources
`health://metrics` (catalog) · `health://freshness` · `health://schema`
### CLI
```
uv run health query-day 2026-09-10
uv run health metrics
uv run health workouts --from 2026-09-01 --to 2026-09-10 --limit 100 --offset 0
uv run health trends step_count --window 30
uv run health freshness
uv run health raw --metric step_count --from 2026-09-01 --to 2026-09-10 --limit 1000
uv run health routes <workout-id> --offset 1000 --limit 1000
uv run health sleep --from 2026-09-27 --to 2026-09-28 --limit 100 --offset 0
```
JSON to stdout, diagnostics to stderr. Thin wrapper over the same query layer the MCP
tools use.
## Data semantics (the invariants)
- **Archive-first**: every authorized payload is on disk (`data/archive/YYYY/MM/DD/`)
before any processing verdict. DB outages queue payloads in `data/pending/` (503 +
`Retry-After: 300`) and `health-replay --pending` drains them when the DB returns.
Nothing is ever lost.
- **Idempotency**: duplicate payloads (same sha256) return `200 {"outcome":"duplicate"}`;
samples upsert latest-wins on `(metric_id, source_id, ts)`; workouts upsert by HAE
UUID. Re-posting any chunk is always safe — row counts never change.
- **Canonical units**: non-canonical-unit samples are converted with an exact
factor when one exists, else skipped visibly (counted in the ingest response's
`skipped.unit_unconvertible`). Conversion factors (iron law 4, amended
2026-09-17-normalizer-audit): lb→kg ÷ 2.20462, st→kg ÷ 0.157473,
°F→°C (°F−32)×5/9 (wrist temperature), kJ→kcal ÷ 4.184. Converted rows store
the canonical unit string; units with no exact factor are never stored next to
canonical rows — they are dropped but always counted. **All workout energies are
stored in kcal** (`active_energy_kcal`, `total_energy`, `basal_energy`): real HAE v2
payloads send `activeEnergyBurned`/`totalEnergy` as `{qty, units:'kJ'}` objects and
`activeEnergy`/`basalEnergy` as per-minute `kJ` point lists, which are summed then
converted. Distance (`distance_km`, `walking_running_distance_km`), HR, cadence,
speed, temperature, humidity and intensity are read from the real
`{qty, units}`/point-list shapes as well as the simplified fixture shapes.
- **KST days**: `day_kst` is a stored generated column with fixed UTC+9 — rollup
boundaries are KST calendar days, never server-local time.
- **Rollups**: `daily_rollups` are recomputed per affected day inside the ingest
transaction; queries read rollups (90-day series < 1 s).
## Deployment (Docker)
Target: a single service container next to any PostgreSQL 18 instance (docker, or
anywhere reachable over TCP). Prerequisites: postgres running with db/role
`health_app` created and its port reachable from the container.
The image is self-contained (built with `uv`, runs as non-root UID 10001) and takes
all configuration from environment variables — nothing is baked in.
1. **Build the image**:
```sh
docker build -t health-db:latest .
```
`.dockerignore` keeps `.git`, `.env`, `data/` etc. out of the build context, so no
health data or secrets end up in the image.
2. **Prepare the environment** (a `.env` file or your orchestrator's env settings;
`.env` is gitignored — never commit it):
```
PSQL_USER=health_app
PSQL_TABLE=health_app
PSQL_PASSWORD=<postgres password>
PSQL_URL=<postgres host> # LAN IP/hostname — NOT a container name, unless both run in one compose network
PSQL_PORT=5432 # the published postgres port
INGEST_SECRET=$(openssl rand -hex 32)
INGEST_HEADER=X-Ingest-Token
MAX_PAYLOAD_BYTES=67108864
```
See `.env.example` for the full key reference (`PSQL_URL` is the **host**;
`PSQL_TABLE` is the **database name** — HAE's odd key name is preserved for parity).
**How env vars reach the app** (they are never baked into the image — image layers
are readable by anyone with the image): the image declares every key with an empty
default so `docker inspect` and container wizards list them; the real values are
injected at container start from `.env` / `--env` / your orchestrator. All override
the image defaults. Leaving `INGEST_SECRET`/`PSQL_*` blank keeps the app fail-closed:
every ingest/MCP request is 401'd until a real secret is supplied, and the health
probe never dials ambient `localhost:5432`.
3. **Create the data folder** (orchestrators often don't create bind-mount folders,
and the image runs as UID 10001, not your user). The folder must be writable by
UID 10001 — both ownership AND mode bits matter:
```sh
mkdir -p /srv/health-db/data
chown -R 10001:10001 /srv/health-db/data
chmod -R 755 /srv/health-db/data
```
`chown` alone is not enough when the directory has no write bit (e.g. mode `555`):
the app gets `PermissionError` on `data/archive`. If the container still shows
`555`, the mounted path is not the one you changed — confirm from the host:
```sh
stat -c '%i %a %u:%g %n' /srv/health-db/data
docker exec health-db stat -c '%i %a %u:%g' /app/data
```
4. **Run the container**:
```sh
docker run -d --name health-db \
--env-file .env \
-p 8000:8000 -p 8100:8100 \
-v /srv/health-db/data:/app/data \
--restart unless-stopped \
health-db:latest
```
The entrypoint runs migrations at startup (`health-migrate` →
`migrations/0001_init.sql`), then serves ingest on :8000 and MCP on :8100. The
verbatim archive lives in the mounted `data/` folder and survives container
recreation.
With `docker compose`, the included `docker-compose.yml` builds the image and adds
a disposable postgres:18 for local use (`docker compose up --build`). For a
production run, point `PSQL_URL`/`PSQL_PORT` at your real postgres and replace the
named volume with a bind mount (or keep the named volume and back it up).
To ship code changes: `git pull`, rebuild with step 1, recreate the container.
5. **Verify**:
```sh
curl http://<host>:8000/api/health # {"status":"ok",...}
curl -H "X-Ingest-Token: $INGEST_SECRET" http://<host>:8000/api/ingest/stats
```
## HAE app configuration (iPhone)
Three REST automations (JSON, **v2** format, **Batch Requests ON**, header
`X-Ingest-Token: <INGEST_SECRET>`, timeout 60s), pointed at
`http://<server-host>:8000/api/ingest`:
| # | Name | Metrics | Grouping | Cadence |
|---|---|---|---|---|
| 1 | Metrics-all | all metrics, Summarize ON | hours | hourly |
| 2 | Metrics-HR+Steps | Heart Rate + Step Count only | minutes | hourly |
| 3 | Workouts | Include Workout Metrics ON (minutes) + Include Route Data ON | — | daily |
Automations 1 and 2 are deliberately disjoint to keep hourly payloads small.
## Backfill runbook (full history)
History is backfilled through the same ingest endpoint — no SQLite migration.
> **⚠️ Deploy BEFORE backfill.** Never run the full-history backfill against a
> deployment older than the `parse-everything` normalizer fix
> (2026-09-17-normalizer-audit). Older deployments store every Watch night's
> sleep as 0.0 (asleep:0 sentinel), silently drop the `data.ecg` /
> `data.heartRateNotifications` lanes, and skip lb/st/°F rows — ingesting 8
> years through them would bake those defects into the archive. Order:
>
> 1. Land the fixed code, rebuild, redeploy (`health-migrate` applies
> migration 0002).
> 2. Repair the already-ingested days:
> ```sh
> docker exec health-db health-replay --dir data/archive --force
> ```
> 3. Verify the repair via MCP before starting the backfill —
> `query_day` for a few recent days on `sleep_analysis` must return
> non-zero sleep totals (not 0.0).
> 4. Only then run the backfill chunks below (ECG/notifications and unit
> conversions are exercised on first ingestion).
1. In HAE: **Manual Export** → JSON v2, per-year date-range chunks (one chunk per year
of history), all metrics + workouts, Summarize ON, Batch Requests ON.
Each POST is ≤ 64 MiB (`MAX_PAYLOAD_BYTES`); Batch Requests splits larger exports
into sequential requests automatically.
2. Chunks flow through the same `/api/ingest` pipeline as live syncs: archived →
normalized → upserted → rollups recomputed.
3. **Re-runs are free**: re-posting any chunk (or re-running it via
`health-replay --dir data/archive`) changes nothing — sha256 dedup + latest-wins
upserts make overlap safe. Verify by re-running a chunk and comparing row counts
(spec scenario 3).
4. If the DB was down during a chunk, the payload sits in `data/pending/` (service
answered 503); drain with `docker exec health-db health-replay --pending`.
### Repairing rows written by an older normalizer
`health-replay --dir` short-circuits on `raw_ingest.sha256` (duplicate), so it cannot
re-normalize payloads already in the DB. To repair rows written by a buggy/older
normalizer, run the force/repair path against the archive on disk:
```sh
uv run health-replay --dir data/archive --force # --repair is an alias
```
This re-normalizes every archived payload even when its sha already exists and
re-upserts with latest-wins semantics, so **workout/HR/route columns are rewritten
with corrected values while `raw_samples` and `daily_rollups` are unchanged**. It is
idempotent — running it twice changes nothing the second time — and never deletes
`raw_ingest` rows (the audit row is updated in place with `outcome='repaired'`). It
reads only from the on-disk archive, so it is safe to run on a live deployment.
### Probe-metric cleanup (resolved 2026-10-01)
The ingest auto-registers unknown metrics, so the test-acceptance probes
`__probe_metric`/`__probe_metric2` once existed as real rows in the production DB.
They have been fully purged: samples/rollups/catalog rows deleted, and the 10 probe
payloads removed from both the `raw_ingest` DB archive and the on-disk archive —
the resurrection path is gone. No cleanup script exists anymore; a fresh
`health-replay --dir data/archive --force` cannot re-emit the probes.
## Post-deploy validation
1. Day 0: backfill completes (above); `GET /api/ingest/stats` shows
`stored` ≈ chunk count, `quarantined: 0`, `unauthorized_attempts: 0`.
2. Days 1–7: leave the 3 automations running. Hourly deltas should land within
5 minutes of the hour — check `stats.last_received_at` / `last_automation` and
`raw_ingest.received_at` (spec success criterion 3).
3. Spot-check parity: for a few captured payloads, `daily_rollups` values must match
the payload's own aggregates (recompute from the archived JSON if in doubt).
4. Zero unexplained 4xx/5xx: every non-2xx in the week must be accounted for
(quarantine / 413 oversize / deliberate 401 probes). Anything else is a bug.
5. Query checks: `freshness` shows current-day coverage per metric; a 90-day
`get_metric_series` answers < 1 s (perf contract, `tests/acceptance/slice_2_perf.py`).
## MCP client configuration
MCP endpoint: `http://<server-host>:8100/mcp` (streamable-http), auth
`Authorization: Bearer <INGEST_SECRET>` (same secret as ingest).
**When clients reach MCP by a name other than localhost** (server hostname, or a
reverse-proxy host:port), set `MCP_ALLOWED_HOSTS` in `.env` — a
comma-separated list of Host values, e.g. `healthbox.example:8443,healthbox.example`. The MCP
transport validates the `Host` header (DNS-rebinding protection) against a
localhost-only default allowlist; an unlisted name is rejected with
`421 Invalid Host header`. `base:*` wildcards are supported (any port on
`base`). The Bearer check remains the access control — this list only decides
which hostnames may reach it.
Example client config (Claude Desktop / any streamable-http MCP client):
```json
{
"mcpServers": {
"health-db": {
"type": "http",
"url": "http://<server-host>:8100/mcp",
"headers": {
"Authorization": "Bearer <INGEST_SECRET>"
}
}
}
}
```
Recommended agent flow: read `health://metrics` first to ground on the catalog, then
`get_metric_series` / `get_trends` for rollup-level questions, `raw_samples` for
fine-grained data (limit default 1000, max 10000), `freshness_check()` before
interpreting recent days.
## Testing
```sh
uv run pytest # unit + acceptance (pg tests need docker)
uv run pytest -m requires_pg # container-backed acceptance only
uv run pytest tests/acceptance/slice_2_perf.py -m requires_pg # perf contract (<1s @ 90d)
uv run ruff check src/ tests/
```
Integration tests always run against disposable postgres:18 containers — never a real
database. Slice-1 captured real payloads in `data/archive/` are the canonical fixtures
(never committed).
## Environment reference
| Key | Meaning | Default |
|---|---|---|
| `PSQL_URL` | postgres **host** (LAN IP/hostname, or `postgres` service name in local compose) | `localhost` |
| `PSQL_PORT` | postgres port | `5432` |
| `PSQL_USER` | role name | `health_app` |
| `PSQL_TABLE` | database **name** (HAE parity naming) | `health_app` |
| `PSQL_PASSWORD` | role password | — |
| `INGEST_SECRET` | shared secret (ingest header + MCP Bearer) | — |
| `INGEST_HEADER` | ingest header name | `X-Ingest-Token` |
| `MAX_PAYLOAD_BYTES` | 413 threshold | `67108864` (64 MiB) |
| `HOST` / `PORT` | ingest bind | `0.0.0.0:8000` |
| `MCP_HOST` / `MCP_PORT` | MCP bind | `0.0.0.0:8100` |
| `MCP_ALLOWED_HOSTS` | extra Host-header names for MCP (reverse proxy etc.), comma-separated | localhost only |
Out of scope (later manual steps): TLS termination / reverse-proxy port forwarding;
analysis features; predecessor decommission.This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues