Replaces the manual "export Apple Health → unzip → run etl/" loop with a
native iOS app that reads HealthKit on-device and syncs incrementally to
Neo4j Aura via a small FastAPI backend.
Architecture update — 2026-05-14: the FastAPI backend has been retired in favor of a direct iOS ↔ Aura GraphQL Data API path with Auth0 social sign-in (Apple / Google / GitHub / Microsoft). See
AUTH_RESEARCH.mdandAUTH_SETUP.md. The diagram below describes the new shape.
┌──────────────────────────┐
│ HealthGraphSync.app │ reads HKQuantitySample / HKCategorySample /
│ (iPhone, Apple Watch) │ HKWorkout via HealthKit; aggregates locally
│ │ into DailySummary mutations.
│ - LoginView │
│ - ConnectView │
│ - SyncView (delta) │
│ - DashboardView (WKWeb) │
│ - SettingsView │
└────────────┬─────────────┘
│ Bearer JWT (id_token from Auth0)
│ GraphQL: ingestDay / ingestWorkout / ingestSleep mutations
▼
┌──────────────────────────────────────┐
│ Aura GraphQL Data API │ Custom @cypher mutations defined in
│ (the user's own instance) │ cypher/graphql_schema.graphql do
│ │ the MERGE upserts.
│ Auth = JWKS provider → │
│ Auth0 .well-known/jwks.json │
└────────────┬─────────────────────────┘
│ Cypher (managed by the GraphQL Data API)
▼
┌──────────────────────────┐
│ Neo4j Aura DB │ Same graph etl/load_to_neo4j.py produces:
│ │ Person, Device, MetricType, Day, Week,
│ │ DailySummary, Workout, SleepSession,
│ │ + temporal relationships.
└────────────┬─────────────┘
│ Cypher (NeoDash queries)
▼
┌──────────────────────────┐
│ NeoDash dashboard │ Embedded in the app's Dashboard tab via WKWebView.
└──────────────────────────┘
Sign-in flow:
Continue → Auth0 universal sheet (Apple / Google / GitHub / Microsoft)
→ id_token in iOS Keychain → user pastes Aura endpoint once → ready.
- Backend reuses
etl/.backend/ingest.pybuilds the same dataclasses the offline parser produces and callsetl/load_to_neo4j.load_all. So the iOS path produces exactly the same graph the existing pipeline does — no schema drift, no duplicated MERGE logic. - No Neo4j credentials on the phone. Aura connection lives in the backend
.env. The phone only holds a JWT issued by the backend. - Idempotent ingest. Every write is MERGE. The iOS app re-sends the FULL
day of samples for any date touched in incremental sync; the backend
recomputes that day's
DailySummaryfrom scratch. - NeoDash via WebView. Less native, but reuses the dashboards you already
built (
neodash/longevity_dashboard.json,whoop_dashboard.json).
- User taps Initial sync in
SyncView. HealthKitService.initialSyncPayloadswalks history month-by-month from ~10 years ago to now. Yields oneIngestPayloadper month.- For each payload,
APIClient.ingestPOSTs to/ingest/healthkit. - Backend translates → transforms → MERGEs.
- For each tracked HK type,
HealthKitService.anchoredFetchDatesusesHKAnchoredObjectQuerywith the saved anchor (stored inUserDefaultsunderhk.anchor.<typeKey>). Returns the set of YYYY-MM-DD dates with new/modified samples since last sync, and saves the new anchor. - For each touched date, fetch the FULL day of samples with a plain
HKSampleQuery. - POST one payload covering all touched dates.
The existing etl/ pipeline aggregates to DailySummary nodes (no per-sample
nodes). If we sent only the new samples for a day, the backend would compute
a partial summary and MERGE would overwrite the full-day summary. By
sending the full day, the summary is always correct after each sync.
HKAnchoredObjectQuery does surface deletions, but we don't know the
deletion's original date (Apple gives us only a UUID). For v1, deletions are
dropped on the floor. If you need delete-fidelity:
- Add a per-sample node
(:Sample {uuid, ...})in the schema. - Store the UUID alongside DailySummary contributions.
- On delete, MATCH the sample by UUID, DETACH DELETE, then recompute the affected day's summary.
# Existing
NEO4J_URI=neo4j+s://....databases.neo4j.io
NEO4J_USER=neo4j
NEO4J_PASSWORD=...
# New for the sync backend
BACKEND_USER=you@example.com
BACKEND_PASSWORD_HASH=$2b$12$...
BACKEND_JWT_SECRET=...
BACKEND_JWT_TTL_HOURS=720
BACKEND_DRY_RUN=0ios/project.yml writes these for you; verify after xcodegen generate:
| Key | Type | Example |
|---|---|---|
API_BASE_URL |
String | https://healthgraph.example.com |
NEODASH_URL |
String | https://neodash.example.com/?... |
NSHealthShareUsageDescription |
String | (already populated) |
NSHealthUpdateUsageDescription |
String | (already populated) |
For a step-by-step check that Aura is reachable and the schema is installed,
see AURA_VERIFICATION.md. It includes copy-paste
probe scripts and a safe end-to-end ingest test (test date 2099-01-01,
cleaned up after).
- Backend: see
backend/README.md. For local testing, run withBACKEND_DRY_RUN=1. - Tunnel the backend so the phone can reach it (Tailscale Funnel is one
one-line option, ngrok another). Update
API_BASE_URLaccordingly. - Generate the Xcode project:
cd ios && xcodegen generate && open HealthGraphSync.xcodeproj. - Build & run on a real iPhone (HealthKit is unavailable on iPad and most simulator runtimes don't have any HealthKit data).
- Permissions prompt appears on first
Initial sync— grant read access.
- Background sync. All syncs are user-triggered. Add
HKObserverQuery+enableBackgroundDeliveryand a background task identifier if you want the app to sync without the user opening it. - Multi-user. Single user. See
backend/README.mdfor the upgrade path. - Robust deletion semantics. As above.
- Pagination. Initial sync sends one POST per month. For users with a lot
of high-frequency data (e.g. several years of continuous HRV), some months
may produce large payloads. Adding
HKSampleQuerypagination is the fix.