@gj-kit/expo-workouts
Expo에서 HealthKit·Health Connect 운동 데이터를 다룹니다. 저장된 데이터를 지워 버리는 실수는 컴파일 단계에서 막힙니다.
- npm
0.1.2 - unit·native·plugin 테스트 460개 이상
- @ts-expect-error 가드 33개
- 런타임 의존성 0
- 공유 fixture로 도는 XCTest 60개 + Kotlin 테스트 70개
계속 학습하기
섹션 제목: “계속 학습하기”왜 필요한가
섹션 제목: “왜 필요한가”Health Connect의 upsert는 full-state입니다. saveWorkout에서 route를 빠뜨리면 이미 저장돼 있던 route가 함께 지워지고, 더 낮은 version으로 쓰면 insert가 같은 record id를 돌려주며 정상 반환해서 성공한 쓰기와 구분되지 않습니다 — read-back 말고는 알아낼 방법이 없습니다. HealthKit은 읽기 권한 상태를 아예 돌려주지 않기 때문에, scope 하나를 선언하지 않으면 모든 워크아웃의 distanceM이 아무 에러 없이 undefined로만 들어옵니다. 그리고 방금 받은 항목보다 cursor를 먼저 커밋하는 sync 루프는 그 사이의 워크아웃을 영구히 잃습니다.
무엇으로 막는가
섹션 제목: “무엇으로 막는가”- route 누락은 컴파일 에러입니다 — Health Connect의 upsert는 full-state여서 route 없이 다시 저장하면 저장돼 있던 route가 지워지기 때문에,
WorkoutWrite.route를readonly RoutePoint[] | 'none'필수 필드로 두어 누락을 TS2741로 막습니다. - 잠긴 기기 분기를 건너뛸 수 없습니다 —
SaveResult는status로 갈라지는 discriminated union이라, 잠긴 기기에서만 나타나는pendingUnlock분기를status === 'saved'로 좁혀내기 전에는saved.nativeId가 TS2339로 막힙니다. - 빠진 scope를 읽기 전에 알려줍니다 —
unpopulatedWorkoutMetrics(state)는 워크아웃을 한 건도 읽기 전에, 읽기 scope가 거부됐거나 요청된 적이 없어서undefined로 들어올Workout필드 이름을distanceM까지 포함해 돌려줍니다. - 어디서 import해도 안전합니다 —
node/browser조건은 빌드 산출물에expo가 전혀 없는index.unsupported로 라우팅되고 Expo Go에서는requireOptionalNativeModule이null을 돌려주므로, import는 던지지 않고getAvailability()가unavailable을 resolve합니다. - 동기화 갭을 Node에서 재현합니다 —
createFakeWorkouts()가 대체하는 것은WorkoutsApi가 아니라 그 아래NativeWorkoutsModuleseam이라,CursorResetReason6종과 drain 도중 크래시를 실제./core코드로 vitest에서 돌려 볼 수 있습니다.
Golden path
섹션 제목: “Golden path”완료 상태: native Expo 앱에서 HealthKit 또는 Health Connect 사용 가능 여부를 확인하고 운동 권한을 요청합니다.
1. 설치
섹션 제목: “1. 설치”pnpm add @gj-kit/expo-workouts2. 앱이 소유할 경계를 정합니다
섹션 제목: “2. 앱이 소유할 경계를 정합니다”config plugin을 추가하고 development build를 사용하세요. Expo Go와 웹은 native module을 호출할 수 없습니다.
3. 최소 연결부터 시작합니다
섹션 제목: “3. 최소 연결부터 시작합니다”먼저 아래 코드를 복사한 뒤, 위에서 언급한 앱 소유 값만 교체하세요.
import { getAvailability, requestAuthorization } from '@gj-kit/expo-workouts';
export async function requestWorkoutAccess() { const availability = await getAvailability(); if (availability.status === 'available') { await requestAuthorization({ read: ['workouts'] }); } return availability;}실제로는 이렇게 걸립니다
섹션 제목: “실제로는 이렇게 걸립니다”saveWorkout에서 production에 가서야 드러나는 실수는 둘입니다. route 누락과 잠긴 기기 분기 미처리 — 각각 TS2741과 TS2339로 컴파일 단계에서 걸립니다.
import { workouts } from '@gj-kit/expo-workouts';import type { WorkoutWrite } from '@gj-kit/expo-workouts/core';
export const write: WorkoutWrite = { id: 'a2f6c0b8-0d7e-4f31-9d1a-7c2b5e8f0a11', // your own idempotency key, not the platform's version: 3, // never Date.now(): a crash retry would mint a second workout kind: 'running', startMs: 1_754_000_000_000, endMs: 1_754_000_600_000, route: 'none', // delete this line -> TS2741; an Android upsert without it erases the stored route};
export async function save(): Promise<string | null> { const saved = await workouts.saveWorkout(write); // @ts-expect-error TS2339 — `nativeId` is absent on the `pendingUnlock` branch void saved.nativeId; if (saved.status === 'pendingUnlock') return null; // locked device: retry the same id + version return saved.nativeId; // narrowed past pendingUnlock, so it exists}사용할 때
섹션 제목: “사용할 때”Expo 앱이 위치 수집, UI, 동기화 소유권을 유지하면서 플랫폼 건강 데이터가 필요할 때 사용합니다.
사용하지 않을 때
섹션 제목: “사용하지 않을 때”실시간 GPS 추적, 백그라운드 위치 정책, 서버 측 건강 데이터 처리를 위해 사용하지 마세요.
환경과 peer
섹션 제목: “환경과 peer”엔진: node >=20
| Peer | 지원 범위 | 필요한 위치 |
|---|---|---|
expo |
>=56.0.0 <58.0.0 |
문서화된 optional subpath에서만 |
공개 subpath
섹션 제목: “공개 subpath”| Entry | Exported symbols |
|---|---|
Root entry |
136 |
./core |
123 |
./plugin |
1 |
./testing |
10 |
안전 경계
섹션 제목: “안전 경계”이는 native module입니다. Expo Go와 web/Node에서는 native 호출을 의도적으로 지원하지 않습니다. 권한 요청 전에 건강 데이터 권한의 이유를 설명하세요.
API reference
섹션 제목: “API reference”전체 API reference에는 선언 파일에서 생성한 모든 공개 함수, 클래스, 타입, 상수, 오류 계약이 있습니다. 각 항목은 import path, release signature, package version을 표시합니다.
패키지 관계
섹션 제목: “패키지 관계”| 관계 | 패키지 | 함께 쓰는 이유 |
|---|---|---|
| 단독 사용 | — | 다른 GJ Kit 패키지 없이 도입할 수 있습니다. |
| 선택 조합 | @gj-kit/format |
앱이 선택한 표시 규칙으로 운동 timestamp와 합계를 포맷할 때 함께 사용합니다. |