@gj-kit/expo-workouts core concepts
HealthKit and Health Connect workouts in Expo, where destroying stored data is a compile error.
Why this boundary exists
Section titled “Why this boundary exists”A Health Connect upsert is full-state: a saveWorkout that omits the route deletes the route already stored, and a write with a lower version returns normally with the same record id, so nothing but a read-back can tell it apart from a real write. HealthKit never reports read authorization at all, so a scope you forgot to declare leaves distanceM undefined on every workout with no error anywhere. And a sync loop that commits its cursor before the rows it just received loses those workouts permanently.
The native boundary stays visible
Section titled “The native boundary stays visible”HealthKit and Health Connect require a development build and platform permission. Availability is exposed instead of pretending Expo Go or web can perform native work.
Write results must be narrowed
Section titled “Write results must be narrowed”A locked device, denied permission, and a saved workout are distinct results. A native ID is available only after the saved branch is proven.
What the package enforces
Section titled “What the package enforces”- Route omission is a compile error — WorkoutWrite.route is a required
readonly RoutePoint[] | 'none', so omitting it is TS2741 — a Health Connect upsert without it deletes the route already stored. - Locked-device branch cannot be skipped — SaveResult is a union discriminated on status, so
saved.nativeIdis TS2339 untilstatus === 'saved'narrows away the pendingUnlock branch a locked device produces. - Missing scopes named before reading — unpopulatedWorkoutMetrics(state) returns the Workout field names, distanceM among them, whose gating read scope is denied or never requested — before you read a single workout.
- Safe to import anywhere — The node/browser condition routes to index.unsupported, whose built module graph contains no expo; in Expo Go requireOptionalNativeModule returns null, so import never throws and getAvailability() resolves unavailable.
- Sync gaps reproduce on Node — createFakeWorkouts() replaces the NativeWorkoutsModule seam rather than WorkoutsApi, so real ./core code runs under vitest for all six CursorResetReason values and a crash mid-drain.
What remains application-owned
Section titled “What remains application-owned”This is a native module: Expo Go and web/Node are intentionally unsupported for native calls. Explain health permissions before requesting them.
Continue with the two verified recipes.